modern-python-guidance 0.1.0__tar.gz → 0.1.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. {modern_python_guidance-0.1.0 → modern_python_guidance-0.1.1}/CHANGELOG.md +13 -0
  2. {modern_python_guidance-0.1.0 → modern_python_guidance-0.1.1}/PKG-INFO +37 -2
  3. {modern_python_guidance-0.1.0 → modern_python_guidance-0.1.1}/README.md +36 -1
  4. {modern_python_guidance-0.1.0 → modern_python_guidance-0.1.1}/pyproject.toml +1 -1
  5. {modern_python_guidance-0.1.0 → modern_python_guidance-0.1.1}/src/modern_python_guidance/__init__.py +1 -1
  6. {modern_python_guidance-0.1.0 → modern_python_guidance-0.1.1}/src/modern_python_guidance/cli.py +11 -0
  7. modern_python_guidance-0.1.1/src/modern_python_guidance/mcp_server.py +398 -0
  8. {modern_python_guidance-0.1.0 → modern_python_guidance-0.1.1}/tests/test_cli_integration.py +3 -1
  9. modern_python_guidance-0.1.1/tests/test_mcp_server.py +304 -0
  10. {modern_python_guidance-0.1.0 → modern_python_guidance-0.1.1}/.github/workflows/ci.yml +0 -0
  11. {modern_python_guidance-0.1.0 → modern_python_guidance-0.1.1}/.github/workflows/publish.yml +0 -0
  12. {modern_python_guidance-0.1.0 → modern_python_guidance-0.1.1}/.gitignore +0 -0
  13. {modern_python_guidance-0.1.0 → modern_python_guidance-0.1.1}/LICENSE +0 -0
  14. {modern_python_guidance-0.1.0 → modern_python_guidance-0.1.1}/SECURITY.md +0 -0
  15. {modern_python_guidance-0.1.0 → modern_python_guidance-0.1.1}/docs/design.md +0 -0
  16. {modern_python_guidance-0.1.0 → modern_python_guidance-0.1.1}/skills/modern-python-guidance/SKILL.md +0 -0
  17. {modern_python_guidance-0.1.0 → modern_python_guidance-0.1.1}/skills/modern-python-guidance/guides/async/async-timeout-context.md +0 -0
  18. {modern_python_guidance-0.1.0 → modern_python_guidance-0.1.1}/skills/modern-python-guidance/guides/async/exception-groups.md +0 -0
  19. {modern_python_guidance-0.1.0 → modern_python_guidance-0.1.1}/skills/modern-python-guidance/guides/async/taskgroup-over-gather.md +0 -0
  20. {modern_python_guidance-0.1.0 → modern_python_guidance-0.1.1}/skills/modern-python-guidance/guides/data-structures/dataclass-modern.md +0 -0
  21. {modern_python_guidance-0.1.0 → modern_python_guidance-0.1.1}/skills/modern-python-guidance/guides/data-structures/dict-merge-operator.md +0 -0
  22. {modern_python_guidance-0.1.0 → modern_python_guidance-0.1.1}/skills/modern-python-guidance/guides/data-structures/match-case-patterns.md +0 -0
  23. {modern_python_guidance-0.1.0 → modern_python_guidance-0.1.1}/skills/modern-python-guidance/guides/fastapi/fastapi-annotated-depends.md +0 -0
  24. {modern_python_guidance-0.1.0 → modern_python_guidance-0.1.1}/skills/modern-python-guidance/guides/fastapi/fastapi-lifespan.md +0 -0
  25. {modern_python_guidance-0.1.0 → modern_python_guidance-0.1.1}/skills/modern-python-guidance/guides/fastapi/fastapi-typed-state.md +0 -0
  26. {modern_python_guidance-0.1.0 → modern_python_guidance-0.1.1}/skills/modern-python-guidance/guides/httpx/httpx-async-client-reuse.md +0 -0
  27. {modern_python_guidance-0.1.0 → modern_python_guidance-0.1.1}/skills/modern-python-guidance/guides/httpx/httpx-streaming.md +0 -0
  28. {modern_python_guidance-0.1.0 → modern_python_guidance-0.1.1}/skills/modern-python-guidance/guides/pydantic/pydantic-v2-config.md +0 -0
  29. {modern_python_guidance-0.1.0 → modern_python_guidance-0.1.1}/skills/modern-python-guidance/guides/pydantic/pydantic-v2-model-api.md +0 -0
  30. {modern_python_guidance-0.1.0 → modern_python_guidance-0.1.1}/skills/modern-python-guidance/guides/pydantic/pydantic-v2-serialization.md +0 -0
  31. {modern_python_guidance-0.1.0 → modern_python_guidance-0.1.1}/skills/modern-python-guidance/guides/pydantic/pydantic-v2-validators.md +0 -0
  32. {modern_python_guidance-0.1.0 → modern_python_guidance-0.1.1}/skills/modern-python-guidance/guides/stdlib/datetime-utc.md +0 -0
  33. {modern_python_guidance-0.1.0 → modern_python_guidance-0.1.1}/skills/modern-python-guidance/guides/stdlib/pathlib-over-os-path.md +0 -0
  34. {modern_python_guidance-0.1.0 → modern_python_guidance-0.1.1}/skills/modern-python-guidance/guides/stdlib/removeprefix-removesuffix.md +0 -0
  35. {modern_python_guidance-0.1.0 → modern_python_guidance-0.1.1}/skills/modern-python-guidance/guides/stdlib/tomllib-builtin.md +0 -0
  36. {modern_python_guidance-0.1.0 → modern_python_guidance-0.1.1}/skills/modern-python-guidance/guides/toolchain/no-pickle.md +0 -0
  37. {modern_python_guidance-0.1.0 → modern_python_guidance-0.1.1}/skills/modern-python-guidance/guides/toolchain/pyproject-toml-over-setup.md +0 -0
  38. {modern_python_guidance-0.1.0 → modern_python_guidance-0.1.1}/skills/modern-python-guidance/guides/toolchain/ruff-over-flake8.md +0 -0
  39. {modern_python_guidance-0.1.0 → modern_python_guidance-0.1.1}/skills/modern-python-guidance/guides/toolchain/safe-subprocess.md +0 -0
  40. {modern_python_guidance-0.1.0 → modern_python_guidance-0.1.1}/skills/modern-python-guidance/guides/toolchain/uv-over-pip.md +0 -0
  41. {modern_python_guidance-0.1.0 → modern_python_guidance-0.1.1}/skills/modern-python-guidance/guides/typing/override-decorator.md +0 -0
  42. {modern_python_guidance-0.1.0 → modern_python_guidance-0.1.1}/skills/modern-python-guidance/guides/typing/paramspec-decorators.md +0 -0
  43. {modern_python_guidance-0.1.0 → modern_python_guidance-0.1.1}/skills/modern-python-guidance/guides/typing/type-parameter-syntax.md +0 -0
  44. {modern_python_guidance-0.1.0 → modern_python_guidance-0.1.1}/skills/modern-python-guidance/guides/typing/typeis-vs-typeguard.md +0 -0
  45. {modern_python_guidance-0.1.0 → modern_python_guidance-0.1.1}/skills/modern-python-guidance/guides/typing/union-syntax.md +0 -0
  46. {modern_python_guidance-0.1.0 → modern_python_guidance-0.1.1}/skills/modern-python-guidance/guides/typing/use-builtin-generics.md +0 -0
  47. {modern_python_guidance-0.1.0 → modern_python_guidance-0.1.1}/src/modern_python_guidance/__main__.py +0 -0
  48. {modern_python_guidance-0.1.0 → modern_python_guidance-0.1.1}/src/modern_python_guidance/compat.py +0 -0
  49. {modern_python_guidance-0.1.0 → modern_python_guidance-0.1.1}/src/modern_python_guidance/frontmatter.py +0 -0
  50. {modern_python_guidance-0.1.0 → modern_python_guidance-0.1.1}/src/modern_python_guidance/guide_index.py +0 -0
  51. {modern_python_guidance-0.1.0 → modern_python_guidance-0.1.1}/src/modern_python_guidance/retrieve.py +0 -0
  52. {modern_python_guidance-0.1.0 → modern_python_guidance-0.1.1}/src/modern_python_guidance/search.py +0 -0
  53. {modern_python_guidance-0.1.0 → modern_python_guidance-0.1.1}/src/modern_python_guidance/version_detect.py +0 -0
  54. {modern_python_guidance-0.1.0 → modern_python_guidance-0.1.1}/tests/test_frontmatter.py +0 -0
  55. {modern_python_guidance-0.1.0 → modern_python_guidance-0.1.1}/tests/test_retrieve.py +0 -0
  56. {modern_python_guidance-0.1.0 → modern_python_guidance-0.1.1}/tests/test_search.py +0 -0
  57. {modern_python_guidance-0.1.0 → modern_python_guidance-0.1.1}/tests/test_version_detect.py +0 -0
@@ -2,6 +2,18 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
+ ## [0.1.1] — 2026-05-25
6
+
7
+ ### Added
8
+
9
+ - Built-in MCP server (`mpg mcp`) exposing all 4 commands as tools over JSON-RPC 2.0 stdio transport — zero additional dependencies
10
+ - Setup: `claude mcp add mpg -- mpg mcp` for Claude Code, or add to `.mcp.json` manually
11
+ - 4 MCP tools: `search_guides`, `retrieve_guides`, `list_guides`, `detect_python_version`
12
+ - CWD confinement for `detect_python_version` (rejects absolute paths, traversal, symlink escape)
13
+ - Resilient message parsing: malformed messages are skipped instead of crashing the server
14
+ - JSON-RPC 2.0 notification compliance (no response for messages without `id`)
15
+ - 19 subprocess-based integration tests for MCP server
16
+
5
17
  ## [0.1.0] — 2026-05-24
6
18
 
7
19
  Initial release.
@@ -18,4 +30,5 @@ Initial release.
18
30
  - Strict YAML-subset frontmatter parser (no PyYAML dependency)
19
31
  - GitHub Actions CI (pytest + ruff on Python 3.11, 3.12, 3.13)
20
32
 
33
+ [0.1.1]: https://github.com/yottayoshida/modern-python-guidance/releases/tag/v0.1.1
21
34
  [0.1.0]: https://github.com/yottayoshida/modern-python-guidance/releases/tag/v0.1.0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: modern-python-guidance
3
- Version: 0.1.0
3
+ Version: 0.1.1
4
4
  Summary: Version-aware BAD/GOOD pattern guides that help AI coding agents generate modern Python
5
5
  Project-URL: Homepage, https://github.com/yottayoshida/modern-python-guidance
6
6
  Project-URL: Repository, https://github.com/yottayoshida/modern-python-guidance
@@ -110,6 +110,40 @@ mpg list --python-version 3.9
110
110
  # Excludes: TaskGroup (3.11+), match/case (3.10+), etc.
111
111
  ```
112
112
 
113
+ ## MCP server
114
+
115
+ mpg includes a built-in [MCP](https://modelcontextprotocol.io) server that exposes all 4 commands as tools. AI agents (Claude Code, Cursor, Gemini CLI, etc.) can discover and call them directly.
116
+
117
+ ### Setup with Claude Code
118
+
119
+ ```bash
120
+ claude mcp add mpg -- mpg mcp
121
+ ```
122
+
123
+ Or add to `.mcp.json` manually:
124
+
125
+ ```json
126
+ {
127
+ "mcpServers": {
128
+ "mpg": {
129
+ "command": "mpg",
130
+ "args": ["mcp"]
131
+ }
132
+ }
133
+ }
134
+ ```
135
+
136
+ ### Available tools
137
+
138
+ | Tool | Description |
139
+ |------|-------------|
140
+ | `search_guides` | Search guides by keyword with fuzzy matching |
141
+ | `retrieve_guides` | Get full BAD/GOOD content by guide ID |
142
+ | `list_guides` | Browse all guides, filter by category/version |
143
+ | `detect_python_version` | Auto-detect project Python version |
144
+
145
+ The MCP server uses stdio transport (JSON-RPC 2.0) and adds zero additional dependencies.
146
+
113
147
  ## Agent Skills integration
114
148
 
115
149
  This project doubles as a [Claude Code Agent Skills](https://docs.anthropic.com/en/docs/claude-code) plugin. Install it into your project's `.claude/skills/` to give Claude automatic access to modern Python patterns when writing or reviewing code.
@@ -139,7 +173,8 @@ ruff check src/ tests/
139
173
 
140
174
  ```
141
175
  src/modern_python_guidance/
142
- ├── cli.py # Entry point (search, retrieve, list, detect-version)
176
+ ├── cli.py # Entry point (search, retrieve, list, detect-version, mcp)
177
+ ├── mcp_server.py # MCP server (JSON-RPC 2.0 over stdio)
143
178
  ├── frontmatter.py # YAML-subset parser (no PyYAML dependency)
144
179
  ├── guide_index.py # Guide discovery and indexing
145
180
  ├── search.py # Weighted keyword search + fuzzy fallback
@@ -81,6 +81,40 @@ mpg list --python-version 3.9
81
81
  # Excludes: TaskGroup (3.11+), match/case (3.10+), etc.
82
82
  ```
83
83
 
84
+ ## MCP server
85
+
86
+ mpg includes a built-in [MCP](https://modelcontextprotocol.io) server that exposes all 4 commands as tools. AI agents (Claude Code, Cursor, Gemini CLI, etc.) can discover and call them directly.
87
+
88
+ ### Setup with Claude Code
89
+
90
+ ```bash
91
+ claude mcp add mpg -- mpg mcp
92
+ ```
93
+
94
+ Or add to `.mcp.json` manually:
95
+
96
+ ```json
97
+ {
98
+ "mcpServers": {
99
+ "mpg": {
100
+ "command": "mpg",
101
+ "args": ["mcp"]
102
+ }
103
+ }
104
+ }
105
+ ```
106
+
107
+ ### Available tools
108
+
109
+ | Tool | Description |
110
+ |------|-------------|
111
+ | `search_guides` | Search guides by keyword with fuzzy matching |
112
+ | `retrieve_guides` | Get full BAD/GOOD content by guide ID |
113
+ | `list_guides` | Browse all guides, filter by category/version |
114
+ | `detect_python_version` | Auto-detect project Python version |
115
+
116
+ The MCP server uses stdio transport (JSON-RPC 2.0) and adds zero additional dependencies.
117
+
84
118
  ## Agent Skills integration
85
119
 
86
120
  This project doubles as a [Claude Code Agent Skills](https://docs.anthropic.com/en/docs/claude-code) plugin. Install it into your project's `.claude/skills/` to give Claude automatic access to modern Python patterns when writing or reviewing code.
@@ -110,7 +144,8 @@ ruff check src/ tests/
110
144
 
111
145
  ```
112
146
  src/modern_python_guidance/
113
- ├── cli.py # Entry point (search, retrieve, list, detect-version)
147
+ ├── cli.py # Entry point (search, retrieve, list, detect-version, mcp)
148
+ ├── mcp_server.py # MCP server (JSON-RPC 2.0 over stdio)
114
149
  ├── frontmatter.py # YAML-subset parser (no PyYAML dependency)
115
150
  ├── guide_index.py # Guide discovery and indexing
116
151
  ├── search.py # Weighted keyword search + fuzzy fallback
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "modern-python-guidance"
7
- version = "0.1.0"
7
+ version = "0.1.1"
8
8
  description = "Version-aware BAD/GOOD pattern guides that help AI coding agents generate modern Python"
9
9
  readme = "README.md"
10
10
  license = "Apache-2.0"
@@ -1,3 +1,3 @@
1
1
  """Modern Python Guidance — version-aware BAD/GOOD pattern guides for AI coding agents."""
2
2
 
3
- __version__ = "0.1.0"
3
+ __version__ = "0.1.1"
@@ -62,6 +62,9 @@ def main(argv: list[str] | None = None) -> None:
62
62
  p_detect = subparsers.add_parser("detect-version", help="Detect project Python version")
63
63
  p_detect.add_argument("--project-dir", type=Path, help="Project directory (default: cwd)")
64
64
 
65
+ # mcp
66
+ subparsers.add_parser("mcp", help="Start MCP server (JSON-RPC over stdio)")
67
+
65
68
  args = parser.parse_args(argv)
66
69
 
67
70
  if args.command is None:
@@ -81,6 +84,8 @@ def main(argv: list[str] | None = None) -> None:
81
84
  _cmd_list(args)
82
85
  elif args.command == "detect-version":
83
86
  _cmd_detect_version(args)
87
+ elif args.command == "mcp":
88
+ _cmd_mcp()
84
89
  except BrokenPipeError:
85
90
  sys.exit(0)
86
91
 
@@ -200,3 +205,9 @@ def _cmd_list(args: argparse.Namespace) -> None:
200
205
  def _cmd_detect_version(args: argparse.Namespace) -> None:
201
206
  version = detect_version(project_dir=args.project_dir)
202
207
  print(version)
208
+
209
+
210
+ def _cmd_mcp() -> None:
211
+ from modern_python_guidance.mcp_server import serve
212
+
213
+ serve()
@@ -0,0 +1,398 @@
1
+ """MCP server — JSON-RPC 2.0 over stdio, zero external dependencies."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import logging
7
+ import sys
8
+ from pathlib import Path
9
+
10
+ from modern_python_guidance import __version__
11
+ from modern_python_guidance.compat import VERSION_RE
12
+ from modern_python_guidance.guide_index import GuideIndex, build_index
13
+ from modern_python_guidance.retrieve import retrieve
14
+ from modern_python_guidance.search import search
15
+ from modern_python_guidance.version_detect import detect_version
16
+
17
+ log = logging.getLogger(__name__)
18
+
19
+ _index: GuideIndex | None = None
20
+
21
+
22
+ def _get_index() -> GuideIndex:
23
+ global _index
24
+ if _index is None:
25
+ _index = build_index()
26
+ return _index
27
+
28
+
29
+ # --- JSON-RPC framing (Content-Length, LSP-style) ---
30
+
31
+
32
+ class _Skip(Exception):
33
+ """Raised to skip a malformed message without terminating the server."""
34
+
35
+
36
+ def _read_message(stream: object = None) -> dict | None:
37
+ buf = stream or sys.stdin.buffer
38
+ headers: dict[str, str] = {}
39
+ while True:
40
+ line = buf.readline()
41
+ if not line:
42
+ return None
43
+ line_str = line.decode("utf-8", errors="replace").rstrip("\r\n")
44
+ if line_str == "":
45
+ break
46
+ if ":" in line_str:
47
+ key, _, value = line_str.partition(":")
48
+ headers[key.strip().lower()] = value.strip()
49
+
50
+ raw_length = headers.get("content-length", "0")
51
+ try:
52
+ length = int(raw_length)
53
+ except ValueError as exc:
54
+ raise _Skip(f"invalid Content-Length: {raw_length!r}") from exc
55
+ if length == 0:
56
+ raise _Skip("missing or zero Content-Length")
57
+ body = buf.read(length)
58
+ try:
59
+ return json.loads(body)
60
+ except json.JSONDecodeError as exc:
61
+ raise _Skip(f"invalid JSON body: {exc}") from exc
62
+
63
+
64
+ def _write_message(msg: dict, stream: object = None) -> None:
65
+ out = stream or sys.stdout.buffer
66
+ body = json.dumps(msg, ensure_ascii=False).encode("utf-8")
67
+ header = f"Content-Length: {len(body)}\r\n\r\n".encode()
68
+ out.write(header)
69
+ out.write(body)
70
+ out.flush()
71
+
72
+
73
+ def _error_response(req_id: int | str | None, code: int, message: str) -> dict:
74
+ return {"jsonrpc": "2.0", "id": req_id, "error": {"code": code, "message": message}}
75
+
76
+
77
+ def _result_response(req_id: int | str | None, result: dict) -> dict:
78
+ return {"jsonrpc": "2.0", "id": req_id, "result": result}
79
+
80
+
81
+ def _tool_result(text: str, *, is_error: bool = False) -> dict:
82
+ result: dict = {"content": [{"type": "text", "text": text}]}
83
+ if is_error:
84
+ result["isError"] = True
85
+ return result
86
+
87
+
88
+ # --- Tool schemas ---
89
+
90
+ TOOLS = [
91
+ {
92
+ "name": "search_guides",
93
+ "description": (
94
+ "Search modern Python pattern guides by keyword. Returns guide IDs, titles, "
95
+ "scores, and token estimates. Use this to discover which guides exist before "
96
+ "retrieving full content. Supports fuzzy matching when exact matches fail."
97
+ ),
98
+ "inputSchema": {
99
+ "type": "object",
100
+ "properties": {
101
+ "query": {
102
+ "type": "string",
103
+ "description": "Search keywords (e.g. 'typing list', 'pydantic v2', 'httpx')",
104
+ },
105
+ "python_version": {
106
+ "type": "string",
107
+ "description": (
108
+ "Filter by Python version (e.g. '3.12'). "
109
+ "Only returns guides applicable to this version."
110
+ ),
111
+ "pattern": r"^\d+\.\d+$",
112
+ },
113
+ "category": {
114
+ "type": "string",
115
+ "description": "Filter by category (e.g. 'stdlib', 'pydantic', 'fastapi')",
116
+ },
117
+ "limit": {
118
+ "type": "integer",
119
+ "description": "Maximum results to return (1-50, default: 10)",
120
+ "minimum": 1,
121
+ "maximum": 50,
122
+ "default": 10,
123
+ },
124
+ },
125
+ "required": ["query"],
126
+ },
127
+ },
128
+ {
129
+ "name": "retrieve_guides",
130
+ "description": (
131
+ "Retrieve full content of one or more guides by ID. Returns the complete "
132
+ "BAD/GOOD pattern with explanation, version compatibility, and token estimate. "
133
+ "Call search_guides first to find guide IDs."
134
+ ),
135
+ "inputSchema": {
136
+ "type": "object",
137
+ "properties": {
138
+ "guide_ids": {
139
+ "type": "array",
140
+ "items": {"type": "string"},
141
+ "description": "Guide IDs to retrieve (max 30)",
142
+ "maxItems": 30,
143
+ },
144
+ "python_version": {
145
+ "type": "string",
146
+ "description": "Target Python version for compatibility check (e.g. '3.12')",
147
+ "pattern": r"^\d+\.\d+$",
148
+ },
149
+ },
150
+ "required": ["guide_ids"],
151
+ },
152
+ },
153
+ {
154
+ "name": "list_guides",
155
+ "description": (
156
+ "List all available guides with metadata. Returns IDs, titles, categories, "
157
+ "layers, and Python version requirements. Use to browse the full catalog "
158
+ "or filter by category/version."
159
+ ),
160
+ "inputSchema": {
161
+ "type": "object",
162
+ "properties": {
163
+ "category": {
164
+ "type": "string",
165
+ "description": "Filter by category (e.g. 'stdlib', 'pydantic')",
166
+ },
167
+ "python_version": {
168
+ "type": "string",
169
+ "description": "Filter by Python version compatibility (e.g. '3.13')",
170
+ "pattern": r"^\d+\.\d+$",
171
+ },
172
+ },
173
+ },
174
+ },
175
+ {
176
+ "name": "detect_python_version",
177
+ "description": (
178
+ "Detect the target Python version for a project by reading pyproject.toml "
179
+ "and .python-version. Returns a version string like '3.12'. Use this to "
180
+ "determine which version to pass to search_guides and retrieve_guides."
181
+ ),
182
+ "inputSchema": {
183
+ "type": "object",
184
+ "properties": {
185
+ "project_dir": {
186
+ "type": "string",
187
+ "description": (
188
+ "Relative path to project directory (relative to server CWD). "
189
+ "Defaults to current directory. Absolute paths are rejected."
190
+ ),
191
+ },
192
+ },
193
+ },
194
+ },
195
+ ]
196
+
197
+
198
+ # --- CWD confinement ---
199
+
200
+
201
+ def _confine_path(project_dir_str: str | None) -> Path | str:
202
+ """Validate project_dir stays within CWD. Returns Path on success, error string on failure."""
203
+ cwd = Path.cwd()
204
+ if cwd == Path("/"):
205
+ return "Cannot detect version: server working directory is root"
206
+
207
+ if project_dir_str is None:
208
+ return cwd
209
+
210
+ if Path(project_dir_str).is_absolute():
211
+ return "project_dir must be a relative path"
212
+
213
+ resolved = (cwd / project_dir_str).resolve()
214
+ try:
215
+ resolved.relative_to(cwd.resolve())
216
+ except ValueError:
217
+ return "project_dir must stay within the server working directory"
218
+
219
+ if not resolved.is_dir():
220
+ return "directory not found"
221
+
222
+ return resolved
223
+
224
+
225
+ # --- Tool dispatch ---
226
+
227
+
228
+ def _handle_tool_call(name: str, arguments: dict) -> dict:
229
+ try:
230
+ if name == "search_guides":
231
+ return _tool_search(arguments)
232
+ if name == "retrieve_guides":
233
+ return _tool_retrieve(arguments)
234
+ if name == "list_guides":
235
+ return _tool_list(arguments)
236
+ if name == "detect_python_version":
237
+ return _tool_detect_version(arguments)
238
+ return _tool_result(f"Unknown tool: {name}", is_error=True)
239
+ except Exception:
240
+ log.exception("Tool execution error")
241
+ return _tool_result("Internal error during tool execution", is_error=True)
242
+
243
+
244
+ def _validate_python_version(pv: str | None) -> str | None:
245
+ if pv is not None and not VERSION_RE.match(pv):
246
+ return f"Invalid python_version format: expected N.N (e.g. 3.12), got '{pv}'"
247
+ return None
248
+
249
+
250
+ def _tool_search(arguments: dict) -> dict:
251
+ query = arguments.get("query", "")
252
+ if not query:
253
+ return _tool_result("query is required", is_error=True)
254
+
255
+ pv = arguments.get("python_version")
256
+ err = _validate_python_version(pv)
257
+ if err:
258
+ return _tool_result(err, is_error=True)
259
+
260
+ limit = max(1, min(50, arguments.get("limit", 10)))
261
+ category = arguments.get("category")
262
+
263
+ index = _get_index()
264
+ results = search(index, query, python_version=pv, category=category, limit=limit)
265
+
266
+ out = [
267
+ {
268
+ "id": r.guide_id,
269
+ "title": r.meta.title,
270
+ "category": r.meta.category,
271
+ "layer": r.meta.layer,
272
+ "score": r.score,
273
+ "token_estimate": r.token_estimate,
274
+ "fuzzy": r.fuzzy,
275
+ }
276
+ for r in results
277
+ ]
278
+ return _tool_result(json.dumps(out, indent=2, ensure_ascii=False))
279
+
280
+
281
+ def _tool_retrieve(arguments: dict) -> dict:
282
+ guide_ids = arguments.get("guide_ids", [])
283
+ if not guide_ids:
284
+ return _tool_result("guide_ids is required and must not be empty", is_error=True)
285
+ if len(guide_ids) > 30:
286
+ return _tool_result("guide_ids exceeds maximum of 30", is_error=True)
287
+
288
+ pv = arguments.get("python_version")
289
+ err = _validate_python_version(pv)
290
+ if err:
291
+ return _tool_result(err, is_error=True)
292
+
293
+ index = _get_index()
294
+ results = retrieve(index, guide_ids, python_version=pv)
295
+ return _tool_result(json.dumps(results, indent=2, ensure_ascii=False))
296
+
297
+
298
+ def _tool_list(arguments: dict) -> dict:
299
+ pv = arguments.get("python_version")
300
+ err = _validate_python_version(pv)
301
+ if err:
302
+ return _tool_result(err, is_error=True)
303
+
304
+ category = arguments.get("category")
305
+ index = _get_index()
306
+ metas = index.all_meta()
307
+
308
+ if category:
309
+ metas = [m for m in metas if m.category == category]
310
+ if pv:
311
+ from modern_python_guidance.compat import version_compatible
312
+
313
+ metas = [m for m in metas if version_compatible(m.python, pv)]
314
+
315
+ metas.sort(key=lambda m: (m.layer, m.category, m.id))
316
+
317
+ out = [
318
+ {
319
+ "id": m.id,
320
+ "title": m.title,
321
+ "category": m.category,
322
+ "layer": m.layer,
323
+ "python": m.python,
324
+ "frequency": m.frequency,
325
+ }
326
+ for m in metas
327
+ ]
328
+ return _tool_result(json.dumps(out, indent=2, ensure_ascii=False))
329
+
330
+
331
+ def _tool_detect_version(arguments: dict) -> dict:
332
+ project_dir_str = arguments.get("project_dir")
333
+ result = _confine_path(project_dir_str)
334
+ if isinstance(result, str):
335
+ return _tool_result(result, is_error=True)
336
+
337
+ version = detect_version(project_dir=result)
338
+ return _tool_result(json.dumps({"python_version": version}))
339
+
340
+
341
+ # --- Server main loop ---
342
+
343
+ PROTOCOL_VERSION = "2024-11-05"
344
+
345
+
346
+ def _handle_request(msg: dict) -> dict | None:
347
+ method = msg.get("method", "")
348
+ req_id = msg.get("id")
349
+ params = msg.get("params", {})
350
+ is_notification = "id" not in msg
351
+
352
+ if method == "notifications/initialized":
353
+ return None
354
+
355
+ if method == "initialize":
356
+ result = _result_response(req_id, {
357
+ "protocolVersion": PROTOCOL_VERSION,
358
+ "capabilities": {"tools": {}},
359
+ "serverInfo": {"name": "modern-python-guidance", "version": __version__},
360
+ })
361
+ return None if is_notification else result
362
+
363
+ if method == "tools/list":
364
+ result = _result_response(req_id, {"tools": TOOLS})
365
+ return None if is_notification else result
366
+
367
+ if method == "tools/call":
368
+ tool_name = params.get("name", "")
369
+ arguments = params.get("arguments", {})
370
+ tool_result = _handle_tool_call(tool_name, arguments)
371
+ result = _result_response(req_id, tool_result)
372
+ return None if is_notification else result
373
+
374
+ if not is_notification:
375
+ return _error_response(req_id, -32601, f"Method not found: {method}")
376
+
377
+ return None
378
+
379
+
380
+ def serve(*, stdin: object = None, stdout: object = None) -> None:
381
+ logging.basicConfig(stream=sys.stderr, level=logging.WARNING, format="%(message)s")
382
+
383
+ while True:
384
+ try:
385
+ msg = _read_message(stdin)
386
+ except _Skip as exc:
387
+ log.warning("Skipping malformed message: %s", exc)
388
+ continue
389
+ if msg is None:
390
+ break
391
+
392
+ response = _handle_request(msg)
393
+ if response is not None:
394
+ _write_message(response, stdout)
395
+
396
+
397
+ if __name__ == "__main__":
398
+ serve()
@@ -6,6 +6,8 @@ import json
6
6
  import subprocess
7
7
  import sys
8
8
 
9
+ from modern_python_guidance import __version__
10
+
9
11
  BIN = [sys.executable, "-m", "modern_python_guidance"]
10
12
 
11
13
 
@@ -146,4 +148,4 @@ class TestVersion:
146
148
  def test_version_flag(self):
147
149
  r = run_cli("--version")
148
150
  assert "modern-python-guidance" in r.stdout
149
- assert "0.1.0" in r.stdout
151
+ assert __version__ in r.stdout
@@ -0,0 +1,304 @@
1
+ """MCP server integration tests — subprocess-based stdio communication."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import subprocess
7
+ import sys
8
+
9
+ BIN = [sys.executable, "-m", "modern_python_guidance", "mcp"]
10
+
11
+
12
+ def _encode_message(msg: dict) -> bytes:
13
+ body = json.dumps(msg).encode("utf-8")
14
+ return f"Content-Length: {len(body)}\r\n\r\n".encode() + body
15
+
16
+
17
+ def _decode_messages(data: bytes) -> list[dict]:
18
+ messages = []
19
+ pos = 0
20
+ while pos < len(data):
21
+ header_end = data.find(b"\r\n\r\n", pos)
22
+ if header_end == -1:
23
+ break
24
+ header_block = data[pos:header_end].decode("utf-8")
25
+ content_length = 0
26
+ for line in header_block.split("\r\n"):
27
+ if line.lower().startswith("content-length:"):
28
+ content_length = int(line.split(":", 1)[1].strip())
29
+ body_start = header_end + 4
30
+ body = data[body_start : body_start + content_length]
31
+ messages.append(json.loads(body))
32
+ pos = body_start + content_length
33
+ return messages
34
+
35
+
36
+ def _build_session(*requests: dict) -> bytes:
37
+ return b"".join(_encode_message(r) for r in requests)
38
+
39
+
40
+ def _run_mcp(*requests: dict, timeout: int = 10) -> list[dict]:
41
+ stdin_data = _build_session(*requests)
42
+ proc = subprocess.run(
43
+ BIN,
44
+ input=stdin_data,
45
+ capture_output=True,
46
+ timeout=timeout,
47
+ )
48
+ assert proc.returncode == 0, f"stderr: {proc.stderr.decode()}"
49
+ return _decode_messages(proc.stdout)
50
+
51
+
52
+ def _init_handshake() -> list[dict]:
53
+ return [
54
+ {"jsonrpc": "2.0", "id": 0, "method": "initialize", "params": {
55
+ "protocolVersion": "2024-11-05",
56
+ "capabilities": {},
57
+ "clientInfo": {"name": "test", "version": "0.0.1"},
58
+ }},
59
+ {"jsonrpc": "2.0", "method": "notifications/initialized"},
60
+ ]
61
+
62
+
63
+ class TestInitialize:
64
+ def test_initialize_returns_capabilities(self):
65
+ responses = _run_mcp(*_init_handshake())
66
+ assert len(responses) == 1
67
+ result = responses[0]["result"]
68
+ assert result["protocolVersion"] == "2024-11-05"
69
+ assert "tools" in result["capabilities"]
70
+ assert result["serverInfo"]["name"] == "modern-python-guidance"
71
+
72
+
73
+ class TestToolsList:
74
+ def test_lists_four_tools(self):
75
+ responses = _run_mcp(
76
+ *_init_handshake(),
77
+ {"jsonrpc": "2.0", "id": 1, "method": "tools/list", "params": {}},
78
+ )
79
+ tools_response = responses[1]
80
+ tools = tools_response["result"]["tools"]
81
+ names = {t["name"] for t in tools}
82
+ expected = {"search_guides", "retrieve_guides", "list_guides", "detect_python_version"}
83
+ assert names == expected
84
+
85
+ def test_schemas_have_required_fields(self):
86
+ responses = _run_mcp(
87
+ *_init_handshake(),
88
+ {"jsonrpc": "2.0", "id": 1, "method": "tools/list", "params": {}},
89
+ )
90
+ tools = responses[1]["result"]["tools"]
91
+ for tool in tools:
92
+ assert "name" in tool
93
+ assert "description" in tool
94
+ assert "inputSchema" in tool
95
+ assert tool["inputSchema"]["type"] == "object"
96
+
97
+
98
+ class TestSearchGuides:
99
+ def test_search_returns_results(self):
100
+ responses = _run_mcp(
101
+ *_init_handshake(),
102
+ {"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": {
103
+ "name": "search_guides",
104
+ "arguments": {"query": "typing list"},
105
+ }},
106
+ )
107
+ result = responses[1]["result"]
108
+ assert "isError" not in result
109
+ data = json.loads(result["content"][0]["text"])
110
+ assert isinstance(data, list)
111
+ assert len(data) >= 1
112
+
113
+ def test_search_empty_query(self):
114
+ responses = _run_mcp(
115
+ *_init_handshake(),
116
+ {"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": {
117
+ "name": "search_guides",
118
+ "arguments": {"query": ""},
119
+ }},
120
+ )
121
+ result = responses[1]["result"]
122
+ assert result["isError"] is True
123
+
124
+ def test_search_with_version_filter(self):
125
+ responses = _run_mcp(
126
+ *_init_handshake(),
127
+ {"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": {
128
+ "name": "search_guides",
129
+ "arguments": {"query": "typing", "python_version": "3.12"},
130
+ }},
131
+ )
132
+ result = responses[1]["result"]
133
+ assert "isError" not in result
134
+
135
+ def test_search_invalid_version_format(self):
136
+ responses = _run_mcp(
137
+ *_init_handshake(),
138
+ {"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": {
139
+ "name": "search_guides",
140
+ "arguments": {"query": "typing", "python_version": "invalid"},
141
+ }},
142
+ )
143
+ result = responses[1]["result"]
144
+ assert result["isError"] is True
145
+
146
+ def test_search_limit_clamped(self):
147
+ responses = _run_mcp(
148
+ *_init_handshake(),
149
+ {"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": {
150
+ "name": "search_guides",
151
+ "arguments": {"query": "typing", "limit": 100},
152
+ }},
153
+ )
154
+ result = responses[1]["result"]
155
+ assert "isError" not in result
156
+ data = json.loads(result["content"][0]["text"])
157
+ assert len(data) <= 50
158
+
159
+
160
+ class TestRetrieveGuides:
161
+ def test_retrieve_single_guide(self):
162
+ responses = _run_mcp(
163
+ *_init_handshake(),
164
+ {"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": {
165
+ "name": "retrieve_guides",
166
+ "arguments": {"guide_ids": ["use-builtin-generics"]},
167
+ }},
168
+ )
169
+ result = responses[1]["result"]
170
+ assert "isError" not in result
171
+ data = json.loads(result["content"][0]["text"])
172
+ assert len(data) == 1
173
+ assert data[0]["id"] == "use-builtin-generics"
174
+
175
+ def test_retrieve_empty_ids(self):
176
+ responses = _run_mcp(
177
+ *_init_handshake(),
178
+ {"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": {
179
+ "name": "retrieve_guides",
180
+ "arguments": {"guide_ids": []},
181
+ }},
182
+ )
183
+ result = responses[1]["result"]
184
+ assert result["isError"] is True
185
+
186
+ def test_retrieve_nonexistent_id(self):
187
+ responses = _run_mcp(
188
+ *_init_handshake(),
189
+ {"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": {
190
+ "name": "retrieve_guides",
191
+ "arguments": {"guide_ids": ["nonexistent-guide-xyz"]},
192
+ }},
193
+ )
194
+ result = responses[1]["result"]
195
+ assert "isError" not in result
196
+ data = json.loads(result["content"][0]["text"])
197
+ assert data == []
198
+
199
+
200
+ class TestListGuides:
201
+ def test_list_all_guides(self):
202
+ responses = _run_mcp(
203
+ *_init_handshake(),
204
+ {"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": {
205
+ "name": "list_guides",
206
+ "arguments": {},
207
+ }},
208
+ )
209
+ result = responses[1]["result"]
210
+ assert "isError" not in result
211
+ data = json.loads(result["content"][0]["text"])
212
+ assert isinstance(data, list)
213
+ assert len(data) >= 1
214
+
215
+ def test_list_with_category_filter(self):
216
+ responses = _run_mcp(
217
+ *_init_handshake(),
218
+ {"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": {
219
+ "name": "list_guides",
220
+ "arguments": {"category": "stdlib"},
221
+ }},
222
+ )
223
+ result = responses[1]["result"]
224
+ data = json.loads(result["content"][0]["text"])
225
+ for guide in data:
226
+ assert guide["category"] == "stdlib"
227
+
228
+
229
+ class TestDetectPythonVersion:
230
+ def test_detect_version_default(self):
231
+ responses = _run_mcp(
232
+ *_init_handshake(),
233
+ {"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": {
234
+ "name": "detect_python_version",
235
+ "arguments": {},
236
+ }},
237
+ )
238
+ result = responses[1]["result"]
239
+ assert "isError" not in result
240
+ data = json.loads(result["content"][0]["text"])
241
+ assert "python_version" in data
242
+
243
+ def test_detect_version_rejects_absolute_path(self):
244
+ responses = _run_mcp(
245
+ *_init_handshake(),
246
+ {"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": {
247
+ "name": "detect_python_version",
248
+ "arguments": {"project_dir": "/etc"},
249
+ }},
250
+ )
251
+ result = responses[1]["result"]
252
+ assert result["isError"] is True
253
+ assert "/etc" not in result["content"][0]["text"]
254
+
255
+ def test_detect_version_rejects_traversal(self):
256
+ responses = _run_mcp(
257
+ *_init_handshake(),
258
+ {"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": {
259
+ "name": "detect_python_version",
260
+ "arguments": {"project_dir": "../../.."},
261
+ }},
262
+ )
263
+ result = responses[1]["result"]
264
+ assert result["isError"] is True
265
+
266
+
267
+ class TestProtocol:
268
+ def test_unknown_method_returns_error(self):
269
+ responses = _run_mcp(
270
+ *_init_handshake(),
271
+ {"jsonrpc": "2.0", "id": 1, "method": "unknown/method", "params": {}},
272
+ )
273
+ error = responses[1].get("error")
274
+ assert error is not None
275
+ assert error["code"] == -32601
276
+
277
+ def test_unknown_tool_returns_tool_error(self):
278
+ responses = _run_mcp(
279
+ *_init_handshake(),
280
+ {"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": {
281
+ "name": "nonexistent_tool",
282
+ "arguments": {},
283
+ }},
284
+ )
285
+ result = responses[1]["result"]
286
+ assert result["isError"] is True
287
+
288
+
289
+ class TestStdoutPollution:
290
+ def test_no_non_jsonrpc_output(self):
291
+ stdin_data = _build_session(
292
+ *_init_handshake(),
293
+ {"jsonrpc": "2.0", "id": 1, "method": "tools/list", "params": {}},
294
+ )
295
+ proc = subprocess.run(BIN, input=stdin_data, capture_output=True, timeout=10)
296
+ decoded = _decode_messages(proc.stdout)
297
+ total_expected_bytes = sum(
298
+ len(f"Content-Length: {len(json.dumps(m).encode())}\r\n\r\n".encode())
299
+ + len(json.dumps(m).encode())
300
+ for m in decoded
301
+ )
302
+ assert len(proc.stdout) == total_expected_bytes, (
303
+ f"stdout contains {len(proc.stdout) - total_expected_bytes} extra bytes"
304
+ )