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:
211
tests/mcp/test_graceful.py
Normal file
211
tests/mcp/test_graceful.py
Normal file
@@ -0,0 +1,211 @@
|
||||
"""test_graceful.py — missing/unavailable features must degrade, never crash.
|
||||
|
||||
All tools that wrap optional features (wiki, synthesis leads, provenance)
|
||||
must return a structured "feature not available" response instead of raising.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ask — always returns "not available" (RAG not implemented)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_ask_returns_not_available() -> None:
|
||||
"""ask() must return {'error': 'not available'} — RAG not yet implemented."""
|
||||
from codex.mcp_server import ask
|
||||
|
||||
result = ask("What is conformal geometry?")
|
||||
assert result == {"error": "not available"}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# wiki_read — page missing → "feature not available"
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_wiki_read_missing_page_returns_not_available() -> None:
|
||||
"""wiki_read() with a nonexistent page must return {"error": "feature not available"}."""
|
||||
from codex.mcp_server import wiki_read
|
||||
|
||||
with (
|
||||
tempfile.TemporaryDirectory() as tmpdir,
|
||||
patch("codex.config.get_settings") as mock_cfg,
|
||||
):
|
||||
mock_cfg.return_value.wiki_dir = tmpdir
|
||||
result = wiki_read("nonexistent_concept")
|
||||
|
||||
assert result == {"error": "feature not available"}
|
||||
|
||||
|
||||
def test_wiki_read_existing_page_returns_markdown() -> None:
|
||||
"""wiki_read() with an existing page must return {markdown, sources}."""
|
||||
from codex.mcp_server import wiki_read
|
||||
|
||||
with (
|
||||
tempfile.TemporaryDirectory() as tmpdir,
|
||||
patch("codex.config.get_settings") as mock_cfg,
|
||||
):
|
||||
mock_cfg.return_value.wiki_dir = tmpdir
|
||||
# Write a fake wiki page
|
||||
page = Path(tmpdir) / "conformal_maps.md"
|
||||
page.write_text("# Conformal Maps\n\nSome content.", encoding="utf-8")
|
||||
result = wiki_read("conformal_maps")
|
||||
|
||||
assert "markdown" in result
|
||||
assert "sources" in result
|
||||
assert "# Conformal Maps" in str(result["markdown"])
|
||||
|
||||
|
||||
def test_wiki_read_exception_returns_not_available() -> None:
|
||||
"""wiki_read() must degrade gracefully when get_settings raises."""
|
||||
from codex.mcp_server import wiki_read
|
||||
|
||||
with patch("codex.config.get_settings", side_effect=RuntimeError("config error")):
|
||||
result = wiki_read("any_slug")
|
||||
|
||||
assert result == {"error": "feature not available"}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# wiki_list — missing wiki dir → empty list
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_wiki_list_missing_dir_returns_empty() -> None:
|
||||
"""wiki_list() must return [] when the wiki directory does not exist."""
|
||||
from codex.mcp_server import wiki_list
|
||||
|
||||
with (
|
||||
tempfile.TemporaryDirectory() as tmpdir,
|
||||
patch("codex.config.get_settings") as mock_cfg,
|
||||
):
|
||||
mock_cfg.return_value.wiki_dir = str(Path(tmpdir) / "no_wiki_here")
|
||||
result = wiki_list()
|
||||
|
||||
assert result == []
|
||||
|
||||
|
||||
def test_wiki_list_existing_dir_returns_pages() -> None:
|
||||
"""wiki_list() must return page entries for .md files in the wiki dir."""
|
||||
from codex.mcp_server import wiki_list
|
||||
|
||||
with (
|
||||
tempfile.TemporaryDirectory() as tmpdir,
|
||||
patch("codex.config.get_settings") as mock_cfg,
|
||||
):
|
||||
mock_cfg.return_value.wiki_dir = tmpdir
|
||||
(Path(tmpdir) / "concept_a.md").write_text("# A", encoding="utf-8")
|
||||
(Path(tmpdir) / "concept_b.md").write_text("# B", encoding="utf-8")
|
||||
(Path(tmpdir) / "index.md").write_text("# Index", encoding="utf-8") # must be skipped
|
||||
result = wiki_list()
|
||||
|
||||
slugs = {r["slug"] for r in result}
|
||||
assert "concept_a" in slugs
|
||||
assert "concept_b" in slugs
|
||||
assert "index" not in slugs # index.md is excluded
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# discover_leads — domain function raises → error dict, no crash
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_discover_leads_db_error_returns_error() -> None:
|
||||
"""discover_leads() must return [{"error": ...}] when the discovery call fails."""
|
||||
from codex.mcp_server import discover_leads
|
||||
|
||||
# Patch the domain function directly so the exception always surfaces
|
||||
with patch("codex.discover.discovery_leads", side_effect=RuntimeError("no DB")):
|
||||
result = discover_leads(limit=5)
|
||||
|
||||
assert len(result) == 1
|
||||
assert "error" in result[0]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# synthesis_browse — missing leads dir → "feature not available"
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_synthesis_browse_missing_dir_returns_not_available() -> None:
|
||||
"""synthesis_browse() must return [{'error': 'feature not available'}] when leads/ absent."""
|
||||
from codex.mcp_server import synthesis_browse
|
||||
|
||||
with (
|
||||
tempfile.TemporaryDirectory() as tmpdir,
|
||||
patch("codex.config.get_settings") as mock_cfg,
|
||||
):
|
||||
mock_cfg.return_value.leads_dir = str(Path(tmpdir) / "no_leads_here")
|
||||
result = synthesis_browse()
|
||||
|
||||
assert result == [{"error": "feature not available"}]
|
||||
|
||||
|
||||
def test_synthesis_browse_existing_dir_returns_items() -> None:
|
||||
"""synthesis_browse() must parse and return JSON files from the leads dir."""
|
||||
from codex.mcp_server import synthesis_browse
|
||||
|
||||
with (
|
||||
tempfile.TemporaryDirectory() as tmpdir,
|
||||
patch("codex.config.get_settings") as mock_cfg,
|
||||
):
|
||||
leads_dir = Path(tmpdir) / "leads"
|
||||
leads_dir.mkdir()
|
||||
(leads_dir / "lead1.json").write_text(
|
||||
json.dumps({"kind": "connection", "title": "C1"}), encoding="utf-8"
|
||||
)
|
||||
(leads_dir / "lead2.json").write_text(
|
||||
json.dumps({"kind": "conjecture", "title": "C2"}), encoding="utf-8"
|
||||
)
|
||||
mock_cfg.return_value.leads_dir = str(leads_dir)
|
||||
result = synthesis_browse(kind="all")
|
||||
|
||||
assert len(result) == 2
|
||||
kinds = {str(r.get("kind")) for r in result}
|
||||
assert "connection" in kinds
|
||||
assert "conjecture" in kinds
|
||||
|
||||
|
||||
def test_synthesis_browse_kind_filter() -> None:
|
||||
"""synthesis_browse(kind='conjecture') must return only conjectures."""
|
||||
from codex.mcp_server import synthesis_browse
|
||||
|
||||
with (
|
||||
tempfile.TemporaryDirectory() as tmpdir,
|
||||
patch("codex.config.get_settings") as mock_cfg,
|
||||
):
|
||||
leads_dir = Path(tmpdir) / "leads"
|
||||
leads_dir.mkdir()
|
||||
(leads_dir / "lead1.json").write_text(
|
||||
json.dumps({"kind": "connection", "title": "C1"}), encoding="utf-8"
|
||||
)
|
||||
(leads_dir / "lead2.json").write_text(
|
||||
json.dumps({"kind": "conjecture", "title": "C2"}), encoding="utf-8"
|
||||
)
|
||||
mock_cfg.return_value.leads_dir = str(leads_dir)
|
||||
result = synthesis_browse(kind="conjecture")
|
||||
|
||||
assert all(str(r.get("kind")) == "conjecture" for r in result)
|
||||
assert len(result) == 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# provenance_verify — exception → "feature not available"
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_provenance_verify_exception_returns_not_available() -> None:
|
||||
"""provenance_verify() must return [{"error": ...}] on scan failure."""
|
||||
from codex.mcp_server import provenance_verify
|
||||
|
||||
with patch("codex.provenance.scan_cite_tags", side_effect=RuntimeError("scan failed")):
|
||||
result = provenance_verify("/some/nonexistent/path")
|
||||
|
||||
assert len(result) == 1
|
||||
assert "error" in result[0]
|
||||
Reference in New Issue
Block a user