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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion doc/architecture/SPEC/thin_mcp_server.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# thin_mcp_server.py —— 规范

<!-- verified-against: 2026-08-19 -->
<!-- verified-against: 2026-08-25 -->

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

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

## 边界 —— 不属于这里
Direct 路径里不调模型;不含 Strict 后台机器(`mcp_server.py`);不定义策略
Expand Down
16 changes: 16 additions & 0 deletions doc/guide/hosts/codex.md
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,22 @@ Direct 模式**不跑第二个模型、不改知识、不发评论、不推代
描述里选出有界的知识 owner;范围校验和被引用代码证据的真伪,仍然由 Codex 自己负责。
完成校验器检查的是**评审结构**。

## 已知行为:MCP 工具审批

Codex 会对 MCP 工具调用弹出**逐次批准**对话框(elicitation)。两个后果:

- **交互式(TUI)**:第一次调用 `review` 时会先看到批准框,批准后才执行。若你的
Codex 版本在这个批准框上崩溃,请升级 Codex —— server 侧此时**尚未开始**任何工作
(run 目录都不会创建),崩溃发生在 Codex 自己的审批 UI 里。
- **headless(`codex exec`,approval=never)**:需要审批的调用会被自动取消,
报 `user cancelled MCP tool call` —— 这不是 server 错误,而是 Codex 把
"从不询问"解释为"取消一切需要询问的调用"。

server 已为每个工具声明 MCP `ToolAnnotations`:除 `review`(预留 Strict run、
子进程触网)以外,全部 `readOnlyHint=true`,让按注解放行只读面的 Codex 版本可以
自动批准。另注意首次启动经由 `uvx` 拉包较慢——安装器已把该 server 的
`startup_timeout_sec` 设为 120。

## 可选:autonomous BYOK 工作流

autonomous 工作流有独立的配置和文档:
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ dependencies = [
dev = ["pytest>=8.0"]
# MCP server (Claude Code / Codex integration) — kept optional so a standalone
# CLI install stays dependency-free.
mcp = ["mcp>=1.2,<2"]
mcp = ["mcp>=1.8,<2"]

[project.scripts]
infermatrix-copilot = "infermatrix_copilot.cli:main"
Expand Down
25 changes: 18 additions & 7 deletions src/infermatrix_copilot/thin_mcp_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -945,8 +945,16 @@ def build_mcp(
core: CopilotMCP | None = None,
):
from mcp.server.fastmcp import FastMCP
from mcp.types import ToolAnnotations

core = core or CopilotMCP(settings)

# Approval-gating hosts (codex asks per-call approval for tools without
# hints, and cancels them outright in headless runs) read these
# annotations, so they must stay truthful: `review` reserves run state
# and its Strict child reaches the network; everything else only reads.
read_only = ToolAnnotations(
readOnlyHint=True, idempotentHint=True, openWorldHint=False)
Comment on lines +956 to +957

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Do not mark reconciling poll tools read-only

When an unfinished run's owner and child have died, both get_review_result and get_review_status call CopilotMCP.get_result/get_status, which invoke reconcile_if_dead and rewrite run_status.json to interrupted (and even ordinary polls create/truncate the status lock file). Reusing this readOnlyHint=True annotation therefore lets approval-gating hosts auto-approve tools that can mutate durable run state, contradicting the annotation's security contract; either make polling observational or give these two tools non-read-only annotations.

Useful? React with 👍 / 👎.

mcp = FastMCP(
"infermatrix-copilot",
instructions=(
Expand Down Expand Up @@ -984,7 +992,10 @@ def build_mcp(
),
)

@mcp.tool()
@mcp.tool(annotations=ToolAnnotations(
title="Start a Direct or Strict review",
readOnlyHint=False, destructiveHint=False, idempotentHint=False,
openWorldHint=True))
def review(
target: str,
repo: str = "vllm-omni",
Expand Down Expand Up @@ -1140,7 +1151,7 @@ def run() -> dict:

return _guard(run)

@mcp.tool()
@mcp.tool(annotations=read_only)
def validate_direct_review(
subtraction_signal: str = "",
subtraction: list[dict[str, str]] | None = None,
Expand Down Expand Up @@ -1182,7 +1193,7 @@ def validate_direct_review(
}
return result

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

return _guard(run)

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

return _guard(run)

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

@mcp.tool()
@mcp.tool(annotations=read_only)
def doc_search(
query: str,
repo: str = "vllm-omni",
Expand Down Expand Up @@ -1248,7 +1259,7 @@ def run() -> dict:

return _guard(run)

@mcp.tool()
@mcp.tool(annotations=read_only)
def doc_read(
path: str,
repo: str = "vllm-omni",
Expand Down
37 changes: 36 additions & 1 deletion test/test_thin_mcp_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,24 +21,34 @@ def _fake_mcp(monkeypatch):
class FakeMCP:
def __init__(self, *_args, **_kwargs):
self.tools = {}
self.tool_annotations = {}

def tool(self):
def tool(self, **kwargs):
def register(fn):
self.tools[fn.__name__] = fn
self.tool_annotations[fn.__name__] = kwargs.get("annotations")
return fn

return register

class FakeToolAnnotations:
def __init__(self, **kwargs):
self.__dict__.update(kwargs)

fastmcp_module.FastMCP = FakeMCP
types_module = ModuleType("mcp.types")
types_module.ToolAnnotations = FakeToolAnnotations
mcp_module = ModuleType("mcp")
mcp_module.__path__ = []
server_module = ModuleType("mcp.server")
server_module.__path__ = []
mcp_module.server = server_module
mcp_module.types = types_module
server_module.fastmcp = fastmcp_module
monkeypatch.setitem(sys.modules, "mcp", mcp_module)
monkeypatch.setitem(sys.modules, "mcp.server", server_module)
monkeypatch.setitem(sys.modules, "mcp.server.fastmcp", fastmcp_module)
monkeypatch.setitem(sys.modules, "mcp.types", types_module)

class FakeCore:
def __init__(self):
Expand Down Expand Up @@ -67,6 +77,31 @@ def get_status(self, run_id):
return build_mcp(core=core), core


def test_tool_annotations_mark_the_read_only_surface(monkeypatch):
"""Approval-gating hosts (codex) read these hints; keep them truthful.

Unannotated tools trigger a per-call approval dialog in codex and are
auto-cancelled in its headless runs (#86), so every tool declares its
hints: `review` is the only tool that mutates state (reserves a Strict
run) or reaches the network; everything else only reads.
"""
mcp, _core = _fake_mcp(monkeypatch)

assert set(mcp.tool_annotations) == set(mcp.tools)
review = mcp.tool_annotations["review"]
assert review is not None
assert review.readOnlyHint is False
assert review.destructiveHint is False
assert review.openWorldHint is True
for name in ("validate_direct_review", "get_review_result",
"get_review_status", "update_knowledge", "doc_search",
"doc_read"):
hints = mcp.tool_annotations[name]
assert hints is not None, name
assert hints.readOnlyHint is True, name
assert hints.openWorldHint is False, name


def test_direct_entrypoints_do_not_resolve_repo(monkeypatch):
mcp, core = _fake_mcp(monkeypatch)
assert set(mcp.tools) == {
Expand Down
Loading