Skip to content

Commit d4e87ed

Browse files
authored
Merge pull request #103 from JiusiServe/fix/thin-mcp-tool-annotations
thin mcp: declare ToolAnnotations so approval-gating hosts can pass (#86)
2 parents f3e7b48 + 4f9f13b commit d4e87ed

5 files changed

Lines changed: 77 additions & 10 deletions

File tree

‎doc/architecture/SPEC/thin_mcp_server.md‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# thin_mcp_server.py —— 规范
22

3-
<!-- verified-against: 2026-08-19 -->
3+
<!-- verified-against: 2026-08-25 -->
44

55
`LOC ~1286 · 默认 MCP:Direct 路由 + Strict 入口 · refactor-status: oversized`
66

@@ -44,6 +44,11 @@
4444
- **Strict 绝不启动注定失败的 run**:Strict 分支先查 `strict_readiness`,
4545
改为返回缺失项。
4646
- `update_knowledge` 只返回知识贡献入口 —— 它**不是** `imupdate` 的发版审计器。
47+
- **每个工具都声明 `ToolAnnotations`,且提示必须真实。** 审批门控的宿主(codex 对
48+
无注解工具逐次弹批准框,headless 下自动取消,见 #86)靠这些提示放行只读面:
49+
`review` 是唯一保留状态变更(预留 Strict run)与触网(Strict 子进程)的工具,
50+
其余六个全部 `readOnlyHint=true`。把一个会写的工具标成只读,比不标更糟。
51+
(要求 `mcp>=1.8`,注解类型自该版本起可用。)
4752

4853
## 边界 —— 不属于这里
4954
Direct 路径里不调模型;不含 Strict 后台机器(`mcp_server.py`);不定义策略

‎doc/guide/hosts/codex.md‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,22 @@ Direct 模式**不跑第二个模型、不改知识、不发评论、不推代
173173
描述里选出有界的知识 owner;范围校验和被引用代码证据的真伪,仍然由 Codex 自己负责。
174174
完成校验器检查的是**评审结构**。
175175

176+
## 已知行为:MCP 工具审批
177+
178+
Codex 会对 MCP 工具调用弹出**逐次批准**对话框(elicitation)。两个后果:
179+
180+
- **交互式(TUI)**:第一次调用 `review` 时会先看到批准框,批准后才执行。若你的
181+
Codex 版本在这个批准框上崩溃,请升级 Codex —— server 侧此时**尚未开始**任何工作
182+
(run 目录都不会创建),崩溃发生在 Codex 自己的审批 UI 里。
183+
- **headless(`codex exec`,approval=never)**:需要审批的调用会被自动取消,
184+
报 `user cancelled MCP tool call` —— 这不是 server 错误,而是 Codex 把
185+
"从不询问"解释为"取消一切需要询问的调用"。
186+
187+
server 已为每个工具声明 MCP `ToolAnnotations`:除 `review`(预留 Strict run、
188+
子进程触网)以外,全部 `readOnlyHint=true`,让按注解放行只读面的 Codex 版本可以
189+
自动批准。另注意首次启动经由 `uvx` 拉包较慢——安装器已把该 server 的
190+
`startup_timeout_sec` 设为 120。
191+
176192
## 可选:autonomous BYOK 工作流
177193

178194
autonomous 工作流有独立的配置和文档:

‎pyproject.toml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ dependencies = [
1616
dev = ["pytest>=8.0"]
1717
# MCP server (Claude Code / Codex integration) — kept optional so a standalone
1818
# CLI install stays dependency-free.
19-
mcp = ["mcp>=1.2,<2"]
19+
mcp = ["mcp>=1.8,<2"]
2020

2121
[project.scripts]
2222
infermatrix-copilot = "infermatrix_copilot.cli:main"

‎src/infermatrix_copilot/thin_mcp_server.py‎

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -945,8 +945,16 @@ def build_mcp(
945945
core: CopilotMCP | None = None,
946946
):
947947
from mcp.server.fastmcp import FastMCP
948+
from mcp.types import ToolAnnotations
948949

949950
core = core or CopilotMCP(settings)
951+
952+
# Approval-gating hosts (codex asks per-call approval for tools without
953+
# hints, and cancels them outright in headless runs) read these
954+
# annotations, so they must stay truthful: `review` reserves run state
955+
# and its Strict child reaches the network; everything else only reads.
956+
read_only = ToolAnnotations(
957+
readOnlyHint=True, idempotentHint=True, openWorldHint=False)
950958
mcp = FastMCP(
951959
"infermatrix-copilot",
952960
instructions=(
@@ -984,7 +992,10 @@ def build_mcp(
984992
),
985993
)
986994

987-
@mcp.tool()
995+
@mcp.tool(annotations=ToolAnnotations(
996+
title="Start a Direct or Strict review",
997+
readOnlyHint=False, destructiveHint=False, idempotentHint=False,
998+
openWorldHint=True))
988999
def review(
9891000
target: str,
9901001
repo: str = "vllm-omni",
@@ -1140,7 +1151,7 @@ def run() -> dict:
11401151

11411152
return _guard(run)
11421153

1143-
@mcp.tool()
1154+
@mcp.tool(annotations=read_only)
11441155
def validate_direct_review(
11451156
subtraction_signal: str = "",
11461157
subtraction: list[dict[str, str]] | None = None,
@@ -1182,7 +1193,7 @@ def validate_direct_review(
11821193
}
11831194
return result
11841195

1185-
@mcp.tool()
1196+
@mcp.tool(annotations=read_only)
11861197
def get_review_result(run_id: str, offset: int = 0) -> dict:
11871198
"""Poll a Strict run and page its final report with ``next_offset``."""
11881199
def run() -> dict:
@@ -1197,7 +1208,7 @@ def run() -> dict:
11971208

11981209
return _guard(run)
11991210

1200-
@mcp.tool()
1211+
@mcp.tool(annotations=read_only)
12011212
def get_review_status(run_id: str) -> dict:
12021213
"""Return a Strict run's durable status and step progress."""
12031214
def run() -> dict:
@@ -1212,12 +1223,12 @@ def run() -> dict:
12121223

12131224
return _guard(run)
12141225

1215-
@mcp.tool()
1226+
@mcp.tool(annotations=read_only)
12161227
def update_knowledge(repo: str = "vllm-omni") -> dict:
12171228
"""Return the knowledge contribution entrypoint for the host to follow."""
12181229
return _guard(lambda: {"knowledge_entry": _contributing_entry()})
12191230

1220-
@mcp.tool()
1231+
@mcp.tool(annotations=read_only)
12211232
def doc_search(
12221233
query: str,
12231234
repo: str = "vllm-omni",
@@ -1248,7 +1259,7 @@ def run() -> dict:
12481259

12491260
return _guard(run)
12501261

1251-
@mcp.tool()
1262+
@mcp.tool(annotations=read_only)
12521263
def doc_read(
12531264
path: str,
12541265
repo: str = "vllm-omni",

‎test/test_thin_mcp_server.py‎

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,24 +21,34 @@ def _fake_mcp(monkeypatch):
2121
class FakeMCP:
2222
def __init__(self, *_args, **_kwargs):
2323
self.tools = {}
24+
self.tool_annotations = {}
2425

25-
def tool(self):
26+
def tool(self, **kwargs):
2627
def register(fn):
2728
self.tools[fn.__name__] = fn
29+
self.tool_annotations[fn.__name__] = kwargs.get("annotations")
2830
return fn
2931

3032
return register
3133

34+
class FakeToolAnnotations:
35+
def __init__(self, **kwargs):
36+
self.__dict__.update(kwargs)
37+
3238
fastmcp_module.FastMCP = FakeMCP
39+
types_module = ModuleType("mcp.types")
40+
types_module.ToolAnnotations = FakeToolAnnotations
3341
mcp_module = ModuleType("mcp")
3442
mcp_module.__path__ = []
3543
server_module = ModuleType("mcp.server")
3644
server_module.__path__ = []
3745
mcp_module.server = server_module
46+
mcp_module.types = types_module
3847
server_module.fastmcp = fastmcp_module
3948
monkeypatch.setitem(sys.modules, "mcp", mcp_module)
4049
monkeypatch.setitem(sys.modules, "mcp.server", server_module)
4150
monkeypatch.setitem(sys.modules, "mcp.server.fastmcp", fastmcp_module)
51+
monkeypatch.setitem(sys.modules, "mcp.types", types_module)
4252

4353
class FakeCore:
4454
def __init__(self):
@@ -67,6 +77,31 @@ def get_status(self, run_id):
6777
return build_mcp(core=core), core
6878

6979

80+
def test_tool_annotations_mark_the_read_only_surface(monkeypatch):
81+
"""Approval-gating hosts (codex) read these hints; keep them truthful.
82+
83+
Unannotated tools trigger a per-call approval dialog in codex and are
84+
auto-cancelled in its headless runs (#86), so every tool declares its
85+
hints: `review` is the only tool that mutates state (reserves a Strict
86+
run) or reaches the network; everything else only reads.
87+
"""
88+
mcp, _core = _fake_mcp(monkeypatch)
89+
90+
assert set(mcp.tool_annotations) == set(mcp.tools)
91+
review = mcp.tool_annotations["review"]
92+
assert review is not None
93+
assert review.readOnlyHint is False
94+
assert review.destructiveHint is False
95+
assert review.openWorldHint is True
96+
for name in ("validate_direct_review", "get_review_result",
97+
"get_review_status", "update_knowledge", "doc_search",
98+
"doc_read"):
99+
hints = mcp.tool_annotations[name]
100+
assert hints is not None, name
101+
assert hints.readOnlyHint is True, name
102+
assert hints.openWorldHint is False, name
103+
104+
70105
def test_direct_entrypoints_do_not_resolve_repo(monkeypatch):
71106
mcp, core = _fake_mcp(monkeypatch)
72107
assert set(mcp.tools) == {

0 commit comments

Comments
 (0)