From 479ca3bbeed2ee719489862b4499e6b67789c085 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Mon, 6 Jul 2026 02:29:19 +0200 Subject: [PATCH] feat(mcp): add server entrypoint and test suite --- surfsense_mcp/src/surfsense_mcp/__main__.py | 31 ++++++ surfsense_mcp/src/surfsense_mcp/selfcheck.py | 89 +++++++++++++++++ surfsense_mcp/src/surfsense_mcp/server.py | 28 ++++++ surfsense_mcp/tests/test_client_errors.py | 46 +++++++++ surfsense_mcp/tests/test_note_ingestion.py | 31 ++++++ surfsense_mcp/tests/test_rendering.py | 22 +++++ surfsense_mcp/tests/test_workspace_context.py | 98 +++++++++++++++++++ 7 files changed, 345 insertions(+) create mode 100644 surfsense_mcp/src/surfsense_mcp/__main__.py create mode 100644 surfsense_mcp/src/surfsense_mcp/selfcheck.py create mode 100644 surfsense_mcp/src/surfsense_mcp/server.py create mode 100644 surfsense_mcp/tests/test_client_errors.py create mode 100644 surfsense_mcp/tests/test_note_ingestion.py create mode 100644 surfsense_mcp/tests/test_rendering.py create mode 100644 surfsense_mcp/tests/test_workspace_context.py diff --git a/surfsense_mcp/src/surfsense_mcp/__main__.py b/surfsense_mcp/src/surfsense_mcp/__main__.py new file mode 100644 index 000000000..6c878ebfa --- /dev/null +++ b/surfsense_mcp/src/surfsense_mcp/__main__.py @@ -0,0 +1,31 @@ +"""Entry point: load settings from the environment and run the MCP server. + +Defaults to stdio (what Cursor, Claude Code, and Claude Desktop launch). stdout +is the protocol channel, so every log line goes to stderr. +""" + +from __future__ import annotations + +import logging +import os +import sys + +from .config import Settings +from .server import build_server + + +def main() -> None: + logging.basicConfig( + level=logging.INFO, + stream=sys.stderr, + format="%(levelname)s %(name)s: %(message)s", + ) + settings = Settings.from_env() + mcp, _client = build_server(settings) + + transport = os.environ.get("SURFSENSE_MCP_TRANSPORT", "stdio").strip() or "stdio" + mcp.run(transport=transport) + + +if __name__ == "__main__": + main() diff --git a/surfsense_mcp/src/surfsense_mcp/selfcheck.py b/surfsense_mcp/src/surfsense_mcp/selfcheck.py new file mode 100644 index 000000000..c4cba397e --- /dev/null +++ b/surfsense_mcp/src/surfsense_mcp/selfcheck.py @@ -0,0 +1,89 @@ +"""Offline smoke check: every tool registers with a usable name, doc, and schema. + +Runs without a backend or network — it only assembles the server and inspects +the tool manifest the client would see. Fails loudly if a tool is missing, its +description is too thin to route on, or its input schema is malformed. +""" + +from __future__ import annotations + +import asyncio +import sys + +from .config import Settings +from .server import build_server + +EXPECTED_TOOLS = { + # search-space selector + "surfsense_list_workspaces", + "surfsense_select_workspace", + # scrapers (all platforms) + run history + "surfsense_web_crawl", + "surfsense_google_search", + "surfsense_reddit_scrape", + "surfsense_youtube_scrape", + "surfsense_youtube_comments", + "surfsense_google_maps_scrape", + "surfsense_google_maps_reviews", + "surfsense_list_scraper_runs", + "surfsense_get_scraper_run", + # knowledge-base management + "surfsense_search_knowledge_base", + "surfsense_list_documents", + "surfsense_get_document", + "surfsense_add_document", + "surfsense_upload_file", + "surfsense_update_document", + "surfsense_delete_document", +} + +_MIN_DESCRIPTION_CHARS = 40 + + +async def _collect_tools() -> dict[str, object]: + settings = Settings( + base_url="http://localhost:8000", + pat="ss_pat_selfcheck", + api_prefix="/api/v1", + timeout=5.0, + default_workspace=None, + ) + mcp, _client = build_server(settings) + tools = await mcp.list_tools() + return {tool.name: tool for tool in tools} + + +def run() -> list[str]: + """Return a list of problems; empty means the manifest is healthy.""" + tools = asyncio.run(_collect_tools()) + problems: list[str] = [] + + missing = EXPECTED_TOOLS - tools.keys() + if missing: + problems.append(f"missing tools: {sorted(missing)}") + unexpected = tools.keys() - EXPECTED_TOOLS + if unexpected: + problems.append(f"unexpected tools: {sorted(unexpected)}") + + for name, tool in tools.items(): + description = tool.description or "" + if len(description) < _MIN_DESCRIPTION_CHARS: + problems.append(f"{name}: description too short to route on") + schema = tool.inputSchema + if not isinstance(schema, dict) or "properties" not in schema: + problems.append(f"{name}: malformed input schema") + return problems + + +def main() -> None: + problems = run() + if problems: + print("selfcheck FAILED:", file=sys.stderr) + for problem in problems: + print(f" - {problem}", file=sys.stderr) + sys.exit(1) + print(f"selfcheck OK: {len(EXPECTED_TOOLS)} tools registered and well-formed") + + +if __name__ == "__main__": + main() diff --git a/surfsense_mcp/src/surfsense_mcp/server.py b/surfsense_mcp/src/surfsense_mcp/server.py new file mode 100644 index 000000000..e1413dadf --- /dev/null +++ b/surfsense_mcp/src/surfsense_mcp/server.py @@ -0,0 +1,28 @@ +"""Composition root: build the MCP server and wire in every feature slice. + +Creates the REST transport and workspace context from settings, then lets each +feature register its tools on the server. +""" + +from __future__ import annotations + +from mcp.server.fastmcp import FastMCP + +from .config import Settings +from .core.client import SurfSenseClient +from .core.workspace_context import WorkspaceContext +from .features import knowledge_base, scrapers, workspaces + + +def build_server(settings: Settings) -> tuple[FastMCP, SurfSenseClient]: + """Assemble a configured server and the client whose lifecycle it shares.""" + client = SurfSenseClient( + api_base=settings.api_base, pat=settings.pat, timeout=settings.timeout + ) + context = WorkspaceContext(client, preferred_reference=settings.default_workspace) + + mcp = FastMCP("SurfSense") + workspaces.register(mcp, context) + scrapers.register(mcp, client, context) + knowledge_base.register(mcp, client, context) + return mcp, client diff --git a/surfsense_mcp/tests/test_client_errors.py b/surfsense_mcp/tests/test_client_errors.py new file mode 100644 index 000000000..fc953410d --- /dev/null +++ b/surfsense_mcp/tests/test_client_errors.py @@ -0,0 +1,46 @@ +"""HTTP failure translation: status hints, server detail, and body parsing.""" + +from __future__ import annotations + +import httpx + +from surfsense_mcp.core.client import SurfSenseClient + +_REQUEST = httpx.Request("GET", "http://localhost:8000/api/v1/documents") + + +def _response(status: int, **kwargs) -> httpx.Response: + return httpx.Response(status, request=_REQUEST, **kwargs) + + +def test_explains_401_with_token_hint(): + message = SurfSenseClient._explain_failure(_response(401, json={"detail": "bad"})) + assert "SURFSENSE_PAT" in message + assert "bad" in message + + +def test_explains_403_as_access_or_api_disabled(): + message = SurfSenseClient._explain_failure(_response(403, json={"detail": "no"})) + assert "API access" in message + + +def test_extracts_nested_detail_message(): + response = _response(402, json={"detail": {"message": "out of credits"}}) + assert "out of credits" in SurfSenseClient._explain_failure(response) + + +def test_unmapped_status_still_reports_detail(): + message = SurfSenseClient._explain_failure(_response(500, json={"detail": "boom"})) + assert "500" in message and "boom" in message + + +def test_parses_json_body(): + assert SurfSenseClient._parse_body(_response(200, json={"ok": 1})) == {"ok": 1} + + +def test_empty_body_parses_to_none(): + assert SurfSenseClient._parse_body(_response(204, content=b"")) is None + + +def test_non_json_body_falls_back_to_text(): + assert SurfSenseClient._parse_body(_response(200, text="hello")) == "hello" diff --git a/surfsense_mcp/tests/test_note_ingestion.py b/surfsense_mcp/tests/test_note_ingestion.py new file mode 100644 index 000000000..6ab16acfc --- /dev/null +++ b/surfsense_mcp/tests/test_note_ingestion.py @@ -0,0 +1,31 @@ +"""The note-to-document envelope mapping.""" + +from __future__ import annotations + +from surfsense_mcp.features.knowledge_base.note_ingestion import build_note_document + + +def test_builds_extension_document_with_content(): + doc = build_note_document( + workspace_id=3, title="Meeting notes", content="body", source_url=None + ) + assert doc["document_type"] == "EXTENSION" + assert doc["workspace_id"] == 3 + entry = doc["content"][0] + assert entry["pageContent"] == "body" + assert entry["metadata"]["VisitedWebPageTitle"] == "Meeting notes" + + +def test_synthesizes_url_when_none_given(): + doc = build_note_document( + workspace_id=1, title="Q3 Plan!", content="x", source_url=None + ) + url = doc["content"][0]["metadata"]["VisitedWebPageURL"] + assert url.startswith("https://surfsense.local/mcp-note/q3-plan-") + + +def test_keeps_provided_source_url(): + doc = build_note_document( + workspace_id=1, title="t", content="x", source_url="https://example.com/a" + ) + assert doc["content"][0]["metadata"]["VisitedWebPageURL"] == "https://example.com/a" diff --git a/surfsense_mcp/tests/test_rendering.py b/surfsense_mcp/tests/test_rendering.py new file mode 100644 index 000000000..40253ddfb --- /dev/null +++ b/surfsense_mcp/tests/test_rendering.py @@ -0,0 +1,22 @@ +"""Output shaping: clipping oversized text and JSON serialization.""" + +from __future__ import annotations + +from surfsense_mcp.core.rendering import clip, to_json + + +def test_clip_leaves_short_text_untouched(): + assert clip("short", limit=100) == "short" + + +def test_clip_truncates_and_marks_dropped_characters(): + clipped = clip("x" * 50, limit=10) + assert clipped.startswith("x" * 10) + assert "40 more characters truncated" in clipped + + +def test_to_json_serializes_non_native_values(): + from datetime import datetime + + rendered = to_json({"at": datetime(2026, 1, 2, 3, 4, 5)}) + assert "2026-01-02" in rendered diff --git a/surfsense_mcp/tests/test_workspace_context.py b/surfsense_mcp/tests/test_workspace_context.py new file mode 100644 index 000000000..9ba9c2ca3 --- /dev/null +++ b/surfsense_mcp/tests/test_workspace_context.py @@ -0,0 +1,98 @@ +"""Workspace resolution: names, ids, defaults, and the ambiguous cases.""" + +from __future__ import annotations + +import asyncio + +import pytest + +from surfsense_mcp.core.errors import ToolError +from surfsense_mcp.core.workspace_context import WorkspaceContext + + +class FakeClient: + """Stands in for SurfSenseClient, serving a fixed workspace list.""" + + def __init__(self, rows: list[dict]) -> None: + self._rows = rows + + async def request(self, method: str, path: str, **_kwargs): + assert (method, path) == ("GET", "/workspaces") + return self._rows + + +def _rows(*names_ids: tuple[str, int]) -> list[dict]: + return [{"id": wid, "name": name} for name, wid in names_ids] + + +def _context(rows: list[dict], preferred: str | None = None) -> WorkspaceContext: + return WorkspaceContext(FakeClient(rows), preferred_reference=preferred) + + +def test_resolves_exact_name(): + ctx = _context(_rows(("Research", 1), ("Marketing", 2))) + assert asyncio.run(ctx.resolve("Research")).id == 1 + + +def test_resolves_case_insensitively(): + ctx = _context(_rows(("Research", 1))) + assert asyncio.run(ctx.resolve("research")).id == 1 + + +def test_resolves_unique_substring(): + ctx = _context(_rows(("Research Space", 1), ("Marketing", 2))) + assert asyncio.run(ctx.resolve("resea")).id == 1 + + +def test_ambiguous_substring_is_rejected(): + ctx = _context(_rows(("Research A", 1), ("Research B", 2))) + with pytest.raises(ToolError): + asyncio.run(ctx.resolve("research")) + + +def test_resolves_by_numeric_id(): + ctx = _context(_rows(("Research", 1), ("Marketing", 2))) + assert asyncio.run(ctx.resolve(2)).name == "Marketing" + assert asyncio.run(ctx.resolve("2")).name == "Marketing" + + +def test_unknown_id_is_rejected(): + ctx = _context(_rows(("Research", 1))) + with pytest.raises(ToolError): + asyncio.run(ctx.resolve(99)) + + +def test_unknown_name_is_rejected(): + ctx = _context(_rows(("Research", 1))) + with pytest.raises(ToolError): + asyncio.run(ctx.resolve("Nope")) + + +def test_default_auto_selects_single_workspace(): + ctx = _context(_rows(("Only", 7))) + assert asyncio.run(ctx.resolve(None)).id == 7 + + +def test_default_with_multiple_requires_a_choice(): + ctx = _context(_rows(("A", 1), ("B", 2))) + with pytest.raises(ToolError): + asyncio.run(ctx.resolve(None)) + + +def test_default_with_no_workspaces_is_rejected(): + ctx = _context([]) + with pytest.raises(ToolError): + asyncio.run(ctx.resolve(None)) + + +def test_default_uses_preferred_reference(): + ctx = _context(_rows(("A", 1), ("Research", 2)), preferred="Research") + assert asyncio.run(ctx.resolve(None)).id == 2 + + +def test_resolution_is_remembered_as_active(): + ctx = _context(_rows(("A", 1), ("B", 2))) + asyncio.run(ctx.resolve("B")) + assert ctx.active is not None and ctx.active.id == 2 + # a later default call reuses the active selection without re-choosing + assert asyncio.run(ctx.resolve(None)).id == 2