feat(mcp): FastMCP server exposing read-only KB tools (stdio)
Implements F-14: thin FastMCP wrapper over existing codex domain modules.
Seven read-only tools: search, ask, wiki_read, wiki_list, discover_leads,
provenance_verify, synthesis_browse. All optional-feature tools degrade
gracefully to {"error": "feature not available"} instead of crashing.
Adds mcp[cli]>=1.0 dependency and codex-mcp console_script entry-point.
28 new tests across test_server, test_search, test_readonly, test_graceful,
test_http_auth — all green; 0 regressions in existing 172 tests.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
83
tests/mcp/test_readonly.py
Normal file
83
tests/mcp/test_readonly.py
Normal file
@@ -0,0 +1,83 @@
|
||||
"""test_readonly.py — CRITICAL: ensure no write/ingest tools are exposed via MCP.
|
||||
|
||||
This test MUST fail if any write or ingest tool is accidentally registered.
|
||||
It is the primary enforcement point for the read-only invariant of F-14.
|
||||
|
||||
Read-only tools (allowed):
|
||||
search, ask, wiki_read, wiki_list, discover_leads,
|
||||
provenance_verify, synthesis_browse
|
||||
|
||||
Write/ingest tools that must NEVER appear:
|
||||
Any tool whose name contains an ingest/write/delete/add/update/remove
|
||||
verb, or any of the known write-mode CLI operations.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import re
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Write-verb pattern — any tool matching this is a forbidden write tool.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_WRITE_VERB_RE = re.compile(
|
||||
r"(?:^|_)(ingest|write|delete|remove|add|update|upsert|insert|create|sync|compile|export)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
# Explicit allowlist — only these names may appear.
|
||||
ALLOWED_TOOLS = frozenset(
|
||||
{
|
||||
"search",
|
||||
"ask",
|
||||
"wiki_read",
|
||||
"wiki_list",
|
||||
"discover_leads",
|
||||
"provenance_verify",
|
||||
"synthesis_browse",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def test_no_write_tools_registered() -> None:
|
||||
"""CRITICAL: fail if any write-verb tool is registered on the MCP server."""
|
||||
from codex.mcp_server import mcp
|
||||
|
||||
tools = asyncio.run(mcp.list_tools())
|
||||
registered = {t.name for t in tools}
|
||||
|
||||
violations: list[str] = []
|
||||
for name in registered:
|
||||
if _WRITE_VERB_RE.search(name):
|
||||
violations.append(name)
|
||||
|
||||
assert not violations, (
|
||||
f"Write/ingest tools found in MCP server (FORBIDDEN): {violations}. "
|
||||
"MCP server must expose read-only tools only."
|
||||
)
|
||||
|
||||
|
||||
def test_only_allowed_tools_registered() -> None:
|
||||
"""CRITICAL: registered tools must be a subset of the explicit allowlist."""
|
||||
from codex.mcp_server import mcp
|
||||
|
||||
tools = asyncio.run(mcp.list_tools())
|
||||
registered = {t.name for t in tools}
|
||||
|
||||
disallowed = registered - ALLOWED_TOOLS
|
||||
assert not disallowed, (
|
||||
f"Tools not on the read-only allowlist: {disallowed}. "
|
||||
"Add them to ALLOWED_TOOLS only after verifying they are read-only."
|
||||
)
|
||||
|
||||
|
||||
def test_all_allowed_tools_present() -> None:
|
||||
"""Every tool in the allowlist must be registered (completeness check)."""
|
||||
from codex.mcp_server import mcp
|
||||
|
||||
tools = asyncio.run(mcp.list_tools())
|
||||
registered = {t.name for t in tools}
|
||||
|
||||
missing = ALLOWED_TOOLS - registered
|
||||
assert not missing, f"Expected read-only tools missing from server: {missing}"
|
||||
Reference in New Issue
Block a user