Skip to content

Commit 07a2234

Browse files
authored
Merge pull request #62 from evidentloop/feature/mcp-tool-plane-s1
Add Sopify MCP read tool plane
2 parents 701f7ba + af96db3 commit 07a2234

5 files changed

Lines changed: 370 additions & 37 deletions

File tree

.sopify/plan/20260626_mcp_tool_plane_pilot/design.md

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,8 @@
44

55
- 核心技术: Python MCP server over stdio,复用现有 Python 模块。
66
- 实现要点:
7-
- S1 使用单文件 `scripts/sopify_mcp_server.py`,不新增 `sopify_mcp/` 包;tool 数量和写入场景稳定后再拆模块
8-
- MCP SDK 选型为官方 Python `mcp` SDK;实施前确认最小 stdio server 示例和依赖声明方式
7+
- S1 使用单文件 `scripts/sopify_mcp_server.py`,不新增 `sopify_mcp/` 包;文件内部按 workspace 校验、纯业务函数、MCP tool 绑定三层组织,保留后续拆包空间但不提前抽象
8+
- MCP SDK 选型为官方 Python `mcp` SDK 稳定线(`mcp[cli]>=1.27,<2`);v2 仍为预发布线,不作为 S1 基线
99
- 不重写 installer,不改变当前 prompt-only 宿主安装路径。
1010
- MCP tool 只包确定性能力,不把 `analyze/design/develop` 推理工作流做成 tool。
1111
- S1 不 import `installer.inspection``workspace_status_lite` 只检查 `.sopify/` 基础结构、active plan、handoff 和 plan 目录。
@@ -36,13 +36,13 @@ Host LLM
3636
| active_plan / current_handoff 读取 ||| AI 接续时高频需要,结构化读取更稳 |
3737
| plan receipt / history receipt 写入 | 暂保留库调用 | 第二阶段 | 需要更严格 tool 描述和测试 |
3838
| analyze / design / develop ||| 推理型工作流保留 prompt/skill 形态 |
39-
| host MCP 注册 | installer 后续支持 || S1 只在 Qoder 手动注册验证 |
39+
| host MCP 注册 | installer 后续支持 || S1 只做手动注册观察,不改 installer |
4040

4141
### S1 工具清单
4242

4343
1. `sopify.protocol_check`
4444
- 输入: `workspace_root``scenario` (`new-plan` / `continuation` / `finalize`)
45-
- 输出: `{scenario, verdict, failures, evidence}`
45+
- 输出: `{protocol_check: {scenario, verdict, failures, evidence}, error: null|{code, message}}`
4646

4747
2. `sopify.get_active_plan`
4848
- 输入: `workspace_root`
@@ -60,12 +60,12 @@ Host LLM
6060

6161
| 信号 | 判定 |
6262
|------|------|
63-
| Qoder 中 AI 能通过 MCP 完成 active plan 读取和 protocol check | go S2 |
64-
| Qoder 中 AI 仍优先拼 shell 命令完成同类动作 | stop,先调整 tool 名称和描述 |
63+
| Codex / Qoder 中 AI 能通过 MCP 完成 active plan 读取和 protocol check | go S2 |
64+
| Codex / Qoder 中 AI 仍优先拼 shell 命令完成同类动作 | stop,先调整 tool 名称和描述 |
6565
| MCP server 启动或响应延迟明显影响对话体验 | stop,先排查依赖和启动开销 |
6666
| pytest / MCP stdio smoke 不稳定 | stop,先补测试和错误处理 |
6767

68-
说明:`AI 优先调 MCP` 是 Qoder 手动试点观察项,不属于 pytest 自动化验收。
68+
说明:`AI 优先调 MCP` 是手动试点观察项,不属于 pytest 自动化验收。Codex / Qoder 为主观察对象;Claude / Copilot 参与兼容性观察,但不阻塞 S1 收口,观察结论作为 S3 输入
6969

7070
### S2 写入工具
7171

.sopify/plan/20260626_mcp_tool_plane_pilot/tasks.md

Lines changed: 23 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -4,24 +4,32 @@
44

55
## S1 Build + Test
66

7-
- [ ] 1.1 确认官方 Python `mcp` SDK 的最小 stdio server 示例和依赖声明方式。
8-
- [ ] 1.2 新增单文件 `scripts/sopify_mcp_server.py`,不新增 `sopify_mcp/` 包。
9-
- [ ] 1.3 实现 `sopify.get_active_plan``sopify.get_current_handoff`,复用 `ProtocolStore`
10-
- [ ] 1.4 实现 `sopify.workspace_status_lite`,只做 `.sopify/` 基础结构和 active plan 指向检查。
11-
- [ ] 1.5 在 `scripts/sopify_protocol_check.py` 抽取 `run_protocol_check(workspace_root, scenario)`
12-
- [ ] 1.6 实现 `sopify.protocol_check`,返回现有 `{scenario, verdict, failures, evidence}` 结构。
13-
- [ ] 1.7 保持 `sopify_protocol_check.py` CLI 行为、exit code 和 JSON 输出兼容。
14-
- [ ] 1.8 为 MCP tool 输入校验、lite status、protocol check 补测试。
15-
- [ ] 1.9 启动 MCP server,通过 stdio client smoke 调用 S1 tool。
16-
- [ ] 1.10 跑 `python3 -m pytest tests -v`,确认现有测试不回退。
17-
- [ ] 1.11 在 Qoder 手动注册 MCP server,验证 AI 能通过 MCP 读取 active plan 和执行 protocol check。
7+
- [x] 1.1 确认官方 Python `mcp` SDK 的最小 stdio server 示例和依赖声明方式。
8+
- [x] 1.2 新增单文件 `scripts/sopify_mcp_server.py`,不新增 `sopify_mcp/` 包。
9+
- [x] 1.3 实现 `sopify.get_active_plan``sopify.get_current_handoff`,复用 `ProtocolStore`
10+
- [x] 1.4 实现 `sopify.workspace_status_lite`,只做 `.sopify/` 基础结构和 active plan 指向检查。
11+
- [x] 1.5 在 `scripts/sopify_protocol_check.py` 抽取 `run_protocol_check(workspace_root, scenario)`
12+
- [x] 1.6 实现 `sopify.protocol_check`,返回现有 `{scenario, verdict, failures, evidence}` 结构。
13+
- [x] 1.7 保持 `sopify_protocol_check.py` CLI 行为、exit code 和 JSON 输出兼容。
14+
- [x] 1.8 为 MCP tool 输入校验、lite status、protocol check 补测试。
15+
- [x] 1.9 启动 MCP server,通过 stdio client smoke 调用 S1 tool。
16+
- [x] 1.10 跑 `python3 -m pytest tests -v`,确认现有测试不回退。
17+
- [ ] 1.11 在 Codex / Qoder 手动注册 MCP server 作为主观察,并让 Claude / Copilot 参与兼容性观察,验证 AI 能通过 MCP 读取 active plan 和执行 protocol check。
18+
19+
### S1.11 手动观察记录
20+
21+
- [x] Codex 主观察:已发现并调用 Sopify MCP tools,未走 shell;`get_active_plan` 返回 `null``protocol_check(continuation)` 返回预期 `FAIL: Missing state/active_plan.json``workspace_status_lite` 返回结构化 workspace 状态。
22+
- [ ] Qoder 主观察:待验证。
23+
- [ ] Claude 兼容性观察:待验证。
24+
- [ ] Copilot 兼容性观察:待验证。
1825

1926
### S1 go/no-go
2027

21-
- [ ] AI 能通过 MCP 完成 active plan 读取和 protocol check。
22-
- [ ] Qoder 手动观察中,AI 对同类动作不再优先拼 shell 命令。
23-
- [ ] MCP server 启动和响应没有明显拖慢对话体验。
24-
- [ ] pytest 与 MCP stdio smoke 稳定通过。
28+
- [ ] Codex / Qoder 中 AI 能通过 MCP 完成 active plan 读取和 protocol check。(Codex 已通过,Qoder 待验证)
29+
- [ ] Codex / Qoder 手动观察中,AI 对同类动作不再优先拼 shell 命令。(Codex 已通过,Qoder 待验证)
30+
- [ ] Claude / Copilot 参与兼容性观察;不阻塞 S1,记录为 S3 输入。
31+
- [x] MCP server 启动和响应没有明显拖慢对话体验。
32+
- [x] pytest 与 MCP stdio smoke 稳定通过。
2533

2634
若任一项失败,先停在 S1,调整 tool 名称、描述、依赖或错误处理,不进入 S2。
2735

scripts/sopify_mcp_server.py

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
#!/usr/bin/env python3
2+
"""Read-only MCP tool plane for Sopify protocol state.
3+
4+
S1 intentionally exposes only deterministic read/check operations. Workflow
5+
decisions, checkpoint confirmation, installer setup, and all state writes stay
6+
with the host prompt, CLI, and sopify_writer.
7+
"""
8+
9+
from __future__ import annotations
10+
11+
import sys
12+
from pathlib import Path
13+
from typing import Any, Callable
14+
15+
REPO_ROOT = Path(__file__).resolve().parents[1]
16+
if str(REPO_ROOT) not in sys.path:
17+
sys.path.insert(0, str(REPO_ROOT))
18+
19+
from scripts.sopify_protocol_check import run_protocol_check # noqa: E402
20+
from sopify_writer import ProtocolStore # noqa: E402
21+
22+
MCP_DEPENDENCY = "mcp[cli]>=1.27,<2"
23+
24+
25+
def resolve_workspace_root(workspace_root: str | Path) -> Path:
26+
"""Resolve and validate the caller-provided workspace root."""
27+
candidate = Path(workspace_root).expanduser().resolve()
28+
if not candidate.exists():
29+
raise ValueError(f"workspace_root does not exist: {candidate}")
30+
if not candidate.is_dir():
31+
raise ValueError(f"workspace_root is not a directory: {candidate}")
32+
return candidate
33+
34+
35+
def sopify_root_for(workspace_root: str | Path) -> Path:
36+
return resolve_workspace_root(workspace_root) / ".sopify"
37+
38+
39+
def read_active_plan(workspace_root: str | Path) -> dict[str, Any] | None:
40+
"""Return state/active_plan.json through ProtocolStore, or null."""
41+
return ProtocolStore(sopify_root_for(workspace_root)).get_active_plan()
42+
43+
44+
def read_current_handoff(workspace_root: str | Path) -> dict[str, Any] | None:
45+
"""Return state/current_handoff.json through ProtocolStore, or null."""
46+
handoff = ProtocolStore(sopify_root_for(workspace_root)).get_current_handoff()
47+
return handoff.to_dict() if handoff is not None else None
48+
49+
50+
def workspace_status_lite(workspace_root: str | Path) -> dict[str, Any]:
51+
"""Return a minimal, dependency-light Sopify workspace status."""
52+
workspace = resolve_workspace_root(workspace_root)
53+
sopify_root = workspace / ".sopify"
54+
state_root = sopify_root / "state"
55+
active_plan = read_active_plan(workspace) if sopify_root.exists() else None
56+
active_plan_id = active_plan.get("plan_id") if isinstance(active_plan, dict) else None
57+
active_plan_dir = sopify_root / "plan" / str(active_plan_id) if active_plan_id else None
58+
59+
return {
60+
"workspace_root": str(workspace),
61+
"sopify_exists": sopify_root.is_dir(),
62+
"paths": {
63+
"blueprint": (sopify_root / "blueprint").is_dir(),
64+
"plan": (sopify_root / "plan").is_dir(),
65+
"history": (sopify_root / "history").is_dir(),
66+
"state": state_root.is_dir(),
67+
},
68+
"active_plan": active_plan,
69+
"active_plan_dir_exists": active_plan_dir.is_dir() if active_plan_dir else None,
70+
"handoff_exists": (state_root / "current_handoff.json").is_file(),
71+
}
72+
73+
74+
def protocol_check(workspace_root: str | Path, scenario: str) -> dict[str, Any]:
75+
return run_protocol_check(workspace_root, scenario)
76+
77+
78+
def _tool_error(exc: Exception) -> dict[str, str]:
79+
return {
80+
"code": type(exc).__name__,
81+
"message": str(exc),
82+
}
83+
84+
85+
def _safe_tool(key: str, fn: Callable[..., Any], *args: Any) -> dict[str, Any]:
86+
try:
87+
return {key: fn(*args), "error": None}
88+
except Exception as exc:
89+
return {key: None, "error": _tool_error(exc)}
90+
91+
92+
def get_mcp_dependency_hint() -> str:
93+
return f'Install the stable MCP Python SDK with: python3 -m pip install "{MCP_DEPENDENCY}"'
94+
95+
96+
def create_mcp_server() -> Any:
97+
"""Create the FastMCP server lazily so tests can run without the SDK."""
98+
try:
99+
from mcp.server.fastmcp import FastMCP
100+
except ModuleNotFoundError as exc:
101+
raise RuntimeError(get_mcp_dependency_hint()) from exc
102+
103+
server = FastMCP("sopify", json_response=True)
104+
105+
@server.tool(name="sopify.get_active_plan")
106+
def tool_get_active_plan(workspace_root: str) -> dict[str, Any]:
107+
"""Read Sopify state/active_plan.json for a workspace."""
108+
return _safe_tool("active_plan", read_active_plan, workspace_root)
109+
110+
@server.tool(name="sopify.get_current_handoff")
111+
def tool_get_current_handoff(workspace_root: str) -> dict[str, Any]:
112+
"""Read Sopify state/current_handoff.json for a workspace."""
113+
return _safe_tool("current_handoff", read_current_handoff, workspace_root)
114+
115+
@server.tool(name="sopify.workspace_status_lite")
116+
def tool_workspace_status_lite(workspace_root: str) -> dict[str, Any]:
117+
"""Inspect only the lightweight .sopify/ workspace structure."""
118+
return _safe_tool("status", workspace_status_lite, workspace_root)
119+
120+
@server.tool(name="sopify.protocol_check")
121+
def tool_protocol_check(workspace_root: str, scenario: str) -> dict[str, Any]:
122+
"""Run the Sopify protocol checker for new-plan, continuation, or finalize."""
123+
return _safe_tool("protocol_check", protocol_check, workspace_root, scenario)
124+
125+
return server
126+
127+
128+
def main() -> int:
129+
try:
130+
create_mcp_server().run(transport="stdio")
131+
except RuntimeError as exc:
132+
print(str(exc), file=sys.stderr)
133+
return 2
134+
return 0
135+
136+
137+
if __name__ == "__main__":
138+
raise SystemExit(main())

scripts/sopify_protocol_check.py

Lines changed: 38 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@
3030
"Next",
3131
]
3232

33+
SCENARIOS = ("new-plan", "continuation", "finalize")
34+
3335
# Patterns that must NOT appear in active contract surfaces.
3436
# Matches in [RETIRED], [DEPRECATED], [SUPERSEDED], MUST NOT, 禁止, ~~ contexts are allowed.
3537
FORBIDDEN_PATTERNS = [
@@ -343,11 +345,45 @@ def make_result(scenario: str, failures: list[str], fixture: Path) -> dict:
343345
}
344346

345347

348+
def run_protocol_check(workspace_root: Path | str, scenario: str) -> dict:
349+
"""Run the protocol checker with the same result shape as the CLI.
350+
351+
The CLI still owns argument parsing and exit codes. This function is the
352+
shared deterministic boundary used by tests and the MCP read-only tool.
353+
"""
354+
fixture = Path(workspace_root).expanduser()
355+
if scenario not in SCENARIOS:
356+
return make_result(
357+
scenario,
358+
[f"Unsupported scenario: {scenario!r}; expected one of {', '.join(SCENARIOS)}"],
359+
fixture,
360+
)
361+
if not fixture.exists():
362+
return make_result(scenario, [f"fixture not found: {fixture}"], fixture)
363+
if not fixture.is_dir():
364+
return make_result(scenario, [f"fixture is not a directory: {fixture}"], fixture)
365+
366+
runners = {
367+
"new-plan": run_new_plan,
368+
"continuation": run_continuation,
369+
"finalize": run_finalize,
370+
}
371+
try:
372+
return runners[scenario](fixture)
373+
except Exception as e:
374+
return {
375+
"scenario": scenario,
376+
"verdict": "FAIL",
377+
"failures": [f"Unexpected error: {type(e).__name__}: {e}"],
378+
"evidence": {"fixture": str(fixture)},
379+
}
380+
381+
346382
def main():
347383
parser = argparse.ArgumentParser(description="Sopify P8 protocol check")
348384
sub = parser.add_subparsers(dest="command")
349385
check_p = sub.add_parser("check")
350-
check_p.add_argument("--scenario", required=True, choices=["new-plan", "continuation", "finalize"])
386+
check_p.add_argument("--scenario", required=True, choices=SCENARIOS)
351387
check_p.add_argument("--fixture", required=True, type=Path)
352388
args = parser.parse_args()
353389

@@ -359,20 +395,7 @@ def main():
359395
print(json.dumps({"error": f"fixture not found: {args.fixture}"}), file=sys.stderr)
360396
sys.exit(2)
361397

362-
runners = {
363-
"new-plan": run_new_plan,
364-
"continuation": run_continuation,
365-
"finalize": run_finalize,
366-
}
367-
try:
368-
result = runners[args.scenario](args.fixture)
369-
except Exception as e:
370-
result = {
371-
"scenario": args.scenario,
372-
"verdict": "FAIL",
373-
"failures": [f"Unexpected error: {type(e).__name__}: {e}"],
374-
"evidence": {"fixture": str(args.fixture)},
375-
}
398+
result = run_protocol_check(args.fixture, args.scenario)
376399
print(json.dumps(result, indent=2, ensure_ascii=False))
377400
sys.exit(0 if result["verdict"] == "PASS" else 1)
378401

0 commit comments

Comments
 (0)