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>
84 lines
2.6 KiB
Python
84 lines
2.6 KiB
Python
"""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}"
|