mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-07-06 22:12:12 +02:00
feat(mcp): add server entrypoint and test suite
This commit is contained in:
parent
34efe3aa44
commit
479ca3bbee
7 changed files with 345 additions and 0 deletions
31
surfsense_mcp/src/surfsense_mcp/__main__.py
Normal file
31
surfsense_mcp/src/surfsense_mcp/__main__.py
Normal 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()
|
||||
89
surfsense_mcp/src/surfsense_mcp/selfcheck.py
Normal file
89
surfsense_mcp/src/surfsense_mcp/selfcheck.py
Normal 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()
|
||||
28
surfsense_mcp/src/surfsense_mcp/server.py
Normal file
28
surfsense_mcp/src/surfsense_mcp/server.py
Normal 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
|
||||
46
surfsense_mcp/tests/test_client_errors.py
Normal file
46
surfsense_mcp/tests/test_client_errors.py
Normal 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"
|
||||
31
surfsense_mcp/tests/test_note_ingestion.py
Normal file
31
surfsense_mcp/tests/test_note_ingestion.py
Normal 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"
|
||||
22
surfsense_mcp/tests/test_rendering.py
Normal file
22
surfsense_mcp/tests/test_rendering.py
Normal 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
|
||||
98
surfsense_mcp/tests/test_workspace_context.py
Normal file
98
surfsense_mcp/tests/test_workspace_context.py
Normal 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
|
||||
Loading…
Add table
Add a link
Reference in a new issue