Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 51 additions & 2 deletions docs/mcp.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,52 @@
# mcp
# MCP server

_Written in the docs phase._
The catalog ships as an MCP server so agents can search the docs, read the
reference code, execute the examples, and get pattern recommendations with
honest verdicts attached.

## Connect

From a checkout:

```bash
claude mcp add design-patterns -- uv run --directory /path/to/python-design-patterns python-design-patterns-mcp
```

Once published to PyPI:

```bash
claude mcp add design-patterns -- uvx python-design-patterns-mcp
```

Remote/HTTP (Streamable HTTP on `/mcp`):

```bash
python-design-patterns-mcp --http --host 127.0.0.1 --port 8734
```

## Tools

| Tool | What it does |
|---|---|
| `list_patterns(group?, verdict?)` | Catalog listing, filterable |
| `get_pattern(pattern_id, variant?)` | Full prose + example source (`naive`/`pythonic`/`real_world`/`all`) |
| `search_patterns(query, limit?)` | BM25 full-text search over names, aliases, problems, symptoms, prose |
| `run_example(pattern_id, variant)` | Executes the vendored example in a sandboxed subprocess; returns real stdout |
| `recommend_pattern(problem_statement, limit?)` | Ranked candidates with caveats; `prefer-alternative` verdicts tell you what to write instead |

## Resources

- `catalog://index` — the whole catalog as JSON
- `pattern://<group>/<slug>` — one pattern's prose
- `pattern://<group>/<slug>/<variant>` — one example's source

## Prompts

`refactor_toward(pattern_id, code)` · `explain_pattern(pattern_id, audience?)` · `choose_pattern(problem)`

## Sandbox contract

`run_example` executes only files resolved from the catalog index — the
`(id, variant)` pair is a dictionary lookup, never joined into a path. The
subprocess runs `python -I` in a temp cwd with a scrubbed environment, a 10s
timeout, and 64KB output caps. There is no arbitrary-code-execution tool.
6 changes: 5 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,17 @@ classifiers = [
"Topic :: Education",
]
dependencies = [
"mcp>=2.1.1",
"pyyaml>=6.0",
]

[project.urls]
Homepage = "https://github.com/SuperElectron/python-design-patterns"
Reference = "https://python-patterns.guide/"

[project.scripts]
python-design-patterns-mcp = "design_patterns_mcp.server:main"

[dependency-groups]
dev = [
"pytest>=8.0",
Expand All @@ -40,7 +44,7 @@ requires = ["hatchling"]
build-backend = "hatchling.build"

[tool.hatch.build.targets.wheel]
packages = ["src/design_patterns"]
packages = ["src/design_patterns", "src/design_patterns_mcp"]

[tool.ruff]
line-length = 100
Expand Down
1 change: 1 addition & 0 deletions src/design_patterns_mcp/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""MCP server exposing the pattern catalog to agents."""
63 changes: 63 additions & 0 deletions src/design_patterns_mcp/sandbox.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
"""Sandboxed execution of catalog example files -- and nothing else.

The contract: only paths resolved from the catalog index are runnable.
The (id, variant) pair is looked up, never joined into a path, so there is
no traversal and no arbitrary-file execution surface.
"""

from __future__ import annotations

import subprocess
import sys
import tempfile
from dataclasses import dataclass

from design_patterns.catalog import Catalog

TIMEOUT_SECONDS = 10
MAX_OUTPUT_BYTES = 64 * 1024


@dataclass(frozen=True)
class RunResult:
exit_code: int
stdout: str
stderr: str
timed_out: bool = False


def run_example(catalog: Catalog, pattern_id: str, variant: str) -> RunResult:
"""Execute one vendored example in a subprocess and capture its output."""
pattern = catalog.get(pattern_id) # KeyError for unknown ids -- by design
variants = pattern.variants()
if variant not in variants:
raise KeyError(f"{pattern_id} has no variant {variant!r} (has: {sorted(variants)})")
path = variants[variant] # resolved by the catalog, never by the caller

repo_root = pattern.path.parents[2]
module = f"patterns.{pattern.group}.{pattern.slug}.{variant}"
# -I ignores PYTHONPATH by design, so the repo root (resolved by the
# catalog, never by the caller) is injected in the bootstrap itself.
bootstrap = (
f"import sys, runpy; sys.path.insert(0, {str(repo_root)!r}); "
f"runpy.run_module({module!r}, run_name='__main__')"
)
with tempfile.TemporaryDirectory() as scratch_cwd:
try:
completed = subprocess.run(
[sys.executable, "-I", "-c", bootstrap],
cwd=scratch_cwd,
env={}, # scrubbed environment
capture_output=True,
timeout=TIMEOUT_SECONDS,
text=True,
)
except subprocess.TimeoutExpired as exc:
out = exc.stdout.decode() if isinstance(exc.stdout, bytes) else (exc.stdout or "")
return RunResult(exit_code=-1, stdout=out[:MAX_OUTPUT_BYTES], stderr="", timed_out=True)
assert path.is_file()
return RunResult(
exit_code=completed.returncode,
stdout=completed.stdout[:MAX_OUTPUT_BYTES],
stderr=completed.stderr[:MAX_OUTPUT_BYTES],
)
78 changes: 78 additions & 0 deletions src/design_patterns_mcp/search.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
"""BM25 search over the pattern catalog. Pure stdlib, no network."""

from __future__ import annotations

import math
import re
from dataclasses import dataclass

from design_patterns.catalog import Catalog, Pattern

_TOKEN = re.compile(r"[a-z0-9']+")

# Field weights: a hit in the problem statement or symptoms should count
# for more than a hit deep in the prose.
_WEIGHTS = {"name": 4.0, "aliases": 4.0, "problem": 3.0, "symptoms": 3.0, "prose": 1.0}


def _tokenize(text: str) -> list[str]:
return _TOKEN.findall(text.lower())


def _document(pattern: Pattern) -> list[str]:
tokens: list[str] = []
fields = {
"name": pattern.name,
"aliases": " ".join(pattern.aliases),
"problem": pattern.problem,
"symptoms": " ".join(pattern.symptoms),
"prose": pattern.prose,
}
for field, text in fields.items():
weight = int(_WEIGHTS[field])
tokens.extend(_tokenize(text) * weight)
return tokens


@dataclass(frozen=True)
class Hit:
pattern: Pattern
score: float


class SearchIndex:
"""A small BM25 index over every unit's frontmatter and prose."""

K1 = 1.5
B = 0.75

def __init__(self, catalog: Catalog) -> None:
self._patterns = catalog.patterns
self._docs = [_document(p) for p in self._patterns]
self._doc_lens = [len(d) for d in self._docs]
self._avg_len = sum(self._doc_lens) / len(self._docs)
self._freqs = [{t: doc.count(t) for t in set(doc)} for doc in self._docs]
self._df: dict[str, int] = {}
for freq in self._freqs:
for term in freq:
self._df[term] = self._df.get(term, 0) + 1

def _idf(self, term: str) -> float:
n, df = len(self._docs), self._df.get(term, 0)
return math.log(1 + (n - df + 0.5) / (df + 0.5))

def search(self, query: str, limit: int = 5) -> list[Hit]:
terms = _tokenize(query)
hits: list[Hit] = []
for i, pattern in enumerate(self._patterns):
score = 0.0
for term in terms:
tf = self._freqs[i].get(term, 0)
if tf == 0:
continue
norm = self.K1 * (1 - self.B + self.B * self._doc_lens[i] / self._avg_len)
score += self._idf(term) * tf * (self.K1 + 1) / (tf + norm)
if score > 0:
hits.append(Hit(pattern, round(score, 3)))
hits.sort(key=lambda h: h.score, reverse=True)
return hits[:limit]
Loading
Loading