feat(mcp): add server entrypoint and test suite

This commit is contained in:
CREDO23 2026-07-06 02:29:19 +02:00
parent 34efe3aa44
commit 479ca3bbee
7 changed files with 345 additions and 0 deletions

View file

@ -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()

View file

@ -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()

View file

@ -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

View file

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

View file

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

View file

@ -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

View file

@ -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