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.
- {modern_python_guidance-0.1.0 → modern_python_guidance-0.1.1}/CHANGELOG.md +13 -0
- {modern_python_guidance-0.1.0 → modern_python_guidance-0.1.1}/PKG-INFO +37 -2
- {modern_python_guidance-0.1.0 → modern_python_guidance-0.1.1}/README.md +36 -1
- {modern_python_guidance-0.1.0 → modern_python_guidance-0.1.1}/pyproject.toml +1 -1
- {modern_python_guidance-0.1.0 → modern_python_guidance-0.1.1}/src/modern_python_guidance/__init__.py +1 -1
- {modern_python_guidance-0.1.0 → modern_python_guidance-0.1.1}/src/modern_python_guidance/cli.py +11 -0
- modern_python_guidance-0.1.1/src/modern_python_guidance/mcp_server.py +398 -0
- {modern_python_guidance-0.1.0 → modern_python_guidance-0.1.1}/tests/test_cli_integration.py +3 -1
- modern_python_guidance-0.1.1/tests/test_mcp_server.py +304 -0
- {modern_python_guidance-0.1.0 → modern_python_guidance-0.1.1}/.github/workflows/ci.yml +0 -0
- {modern_python_guidance-0.1.0 → modern_python_guidance-0.1.1}/.github/workflows/publish.yml +0 -0
- {modern_python_guidance-0.1.0 → modern_python_guidance-0.1.1}/.gitignore +0 -0
- {modern_python_guidance-0.1.0 → modern_python_guidance-0.1.1}/LICENSE +0 -0
- {modern_python_guidance-0.1.0 → modern_python_guidance-0.1.1}/SECURITY.md +0 -0
- {modern_python_guidance-0.1.0 → modern_python_guidance-0.1.1}/docs/design.md +0 -0
- {modern_python_guidance-0.1.0 → modern_python_guidance-0.1.1}/skills/modern-python-guidance/SKILL.md +0 -0
- {modern_python_guidance-0.1.0 → modern_python_guidance-0.1.1}/skills/modern-python-guidance/guides/async/async-timeout-context.md +0 -0
- {modern_python_guidance-0.1.0 → modern_python_guidance-0.1.1}/skills/modern-python-guidance/guides/async/exception-groups.md +0 -0
- {modern_python_guidance-0.1.0 → modern_python_guidance-0.1.1}/skills/modern-python-guidance/guides/async/taskgroup-over-gather.md +0 -0
- {modern_python_guidance-0.1.0 → modern_python_guidance-0.1.1}/skills/modern-python-guidance/guides/data-structures/dataclass-modern.md +0 -0
- {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
- {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
- {modern_python_guidance-0.1.0 → modern_python_guidance-0.1.1}/skills/modern-python-guidance/guides/fastapi/fastapi-annotated-depends.md +0 -0
- {modern_python_guidance-0.1.0 → modern_python_guidance-0.1.1}/skills/modern-python-guidance/guides/fastapi/fastapi-lifespan.md +0 -0
- {modern_python_guidance-0.1.0 → modern_python_guidance-0.1.1}/skills/modern-python-guidance/guides/fastapi/fastapi-typed-state.md +0 -0
- {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
- {modern_python_guidance-0.1.0 → modern_python_guidance-0.1.1}/skills/modern-python-guidance/guides/httpx/httpx-streaming.md +0 -0
- {modern_python_guidance-0.1.0 → modern_python_guidance-0.1.1}/skills/modern-python-guidance/guides/pydantic/pydantic-v2-config.md +0 -0
- {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
- {modern_python_guidance-0.1.0 → modern_python_guidance-0.1.1}/skills/modern-python-guidance/guides/pydantic/pydantic-v2-serialization.md +0 -0
- {modern_python_guidance-0.1.0 → modern_python_guidance-0.1.1}/skills/modern-python-guidance/guides/pydantic/pydantic-v2-validators.md +0 -0
- {modern_python_guidance-0.1.0 → modern_python_guidance-0.1.1}/skills/modern-python-guidance/guides/stdlib/datetime-utc.md +0 -0
- {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
- {modern_python_guidance-0.1.0 → modern_python_guidance-0.1.1}/skills/modern-python-guidance/guides/stdlib/removeprefix-removesuffix.md +0 -0
- {modern_python_guidance-0.1.0 → modern_python_guidance-0.1.1}/skills/modern-python-guidance/guides/stdlib/tomllib-builtin.md +0 -0
- {modern_python_guidance-0.1.0 → modern_python_guidance-0.1.1}/skills/modern-python-guidance/guides/toolchain/no-pickle.md +0 -0
- {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
- {modern_python_guidance-0.1.0 → modern_python_guidance-0.1.1}/skills/modern-python-guidance/guides/toolchain/ruff-over-flake8.md +0 -0
- {modern_python_guidance-0.1.0 → modern_python_guidance-0.1.1}/skills/modern-python-guidance/guides/toolchain/safe-subprocess.md +0 -0
- {modern_python_guidance-0.1.0 → modern_python_guidance-0.1.1}/skills/modern-python-guidance/guides/toolchain/uv-over-pip.md +0 -0
- {modern_python_guidance-0.1.0 → modern_python_guidance-0.1.1}/skills/modern-python-guidance/guides/typing/override-decorator.md +0 -0
- {modern_python_guidance-0.1.0 → modern_python_guidance-0.1.1}/skills/modern-python-guidance/guides/typing/paramspec-decorators.md +0 -0
- {modern_python_guidance-0.1.0 → modern_python_guidance-0.1.1}/skills/modern-python-guidance/guides/typing/type-parameter-syntax.md +0 -0
- {modern_python_guidance-0.1.0 → modern_python_guidance-0.1.1}/skills/modern-python-guidance/guides/typing/typeis-vs-typeguard.md +0 -0
- {modern_python_guidance-0.1.0 → modern_python_guidance-0.1.1}/skills/modern-python-guidance/guides/typing/union-syntax.md +0 -0
- {modern_python_guidance-0.1.0 → modern_python_guidance-0.1.1}/skills/modern-python-guidance/guides/typing/use-builtin-generics.md +0 -0
- {modern_python_guidance-0.1.0 → modern_python_guidance-0.1.1}/src/modern_python_guidance/__main__.py +0 -0
- {modern_python_guidance-0.1.0 → modern_python_guidance-0.1.1}/src/modern_python_guidance/compat.py +0 -0
- {modern_python_guidance-0.1.0 → modern_python_guidance-0.1.1}/src/modern_python_guidance/frontmatter.py +0 -0
- {modern_python_guidance-0.1.0 → modern_python_guidance-0.1.1}/src/modern_python_guidance/guide_index.py +0 -0
- {modern_python_guidance-0.1.0 → modern_python_guidance-0.1.1}/src/modern_python_guidance/retrieve.py +0 -0
- {modern_python_guidance-0.1.0 → modern_python_guidance-0.1.1}/src/modern_python_guidance/search.py +0 -0
- {modern_python_guidance-0.1.0 → modern_python_guidance-0.1.1}/src/modern_python_guidance/version_detect.py +0 -0
- {modern_python_guidance-0.1.0 → modern_python_guidance-0.1.1}/tests/test_frontmatter.py +0 -0
- {modern_python_guidance-0.1.0 → modern_python_guidance-0.1.1}/tests/test_retrieve.py +0 -0
- {modern_python_guidance-0.1.0 → modern_python_guidance-0.1.1}/tests/test_search.py +0 -0
- {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.
|
|
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.
|
|
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"
|
{modern_python_guidance-0.1.0 → modern_python_guidance-0.1.1}/src/modern_python_guidance/cli.py
RENAMED
|
@@ -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
|
|
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
|
+
)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{modern_python_guidance-0.1.0 → modern_python_guidance-0.1.1}/skills/modern-python-guidance/SKILL.md
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{modern_python_guidance-0.1.0 → modern_python_guidance-0.1.1}/src/modern_python_guidance/__main__.py
RENAMED
|
File without changes
|
{modern_python_guidance-0.1.0 → modern_python_guidance-0.1.1}/src/modern_python_guidance/compat.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{modern_python_guidance-0.1.0 → modern_python_guidance-0.1.1}/src/modern_python_guidance/retrieve.py
RENAMED
|
File without changes
|
{modern_python_guidance-0.1.0 → modern_python_guidance-0.1.1}/src/modern_python_guidance/search.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|