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>
212 lines
7.7 KiB
Python
212 lines
7.7 KiB
Python
"""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]
|