Files
codex-py/tests/mcp/test_readonly.py
Tarik Moussa b803811701 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>
2026-06-14 08:55:49 +02:00

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}"