refactor(mcp): flatten to mcp_server package, drop src layout

Rename the import package surfsense_mcp -> mcp_server and remove the
src/ layer so the project mirrors the backend's shape (project folder
!= package name, e.g. surfsense_backend/app). Kills the redundant
surfsense_mcp/src/surfsense_mcp nesting.

Distribution name and console command (surfsense-mcp) are unchanged;
only python -m and internal import paths move to mcp_server. Dockerfile
CMD updated; no PYTHONPATH added since the editable install already
makes the package importable.
This commit is contained in:
CREDO23 2026-07-07 20:24:53 +02:00
parent 839618ef09
commit 116291a3b6
46 changed files with 31 additions and 31 deletions

View file

@ -0,0 +1,22 @@
"""Knowledge-base tools: search the KB and manage its documents.
Semantic search plus the document lifecycle list, read, add text, upload a
file, update, and delete over a workspace's knowledge base. Read tools live in
search_tools, mutations in document_tools.
"""
from __future__ import annotations
from mcp.server.fastmcp import FastMCP
from ...core.client import SurfSenseClient
from ...core.workspace_context import WorkspaceContext
from . import document_tools, search_tools
def register(
mcp: FastMCP, client: SurfSenseClient, context: WorkspaceContext
) -> None:
"""Register every knowledge-base tool on the server."""
search_tools.register(mcp, client, context)
document_tools.register(mcp, client, context)

View file

@ -0,0 +1,34 @@
"""Tool-call policy hints and shared parameter types for knowledge-base tools."""
from __future__ import annotations
from typing import Annotated
from mcp.types import ToolAnnotations
from pydantic import Field
READ = ToolAnnotations(
readOnlyHint=True, destructiveHint=False, idempotentHint=True, openWorldHint=False
)
WRITE = ToolAnnotations(
readOnlyHint=False, destructiveHint=False, idempotentHint=False, openWorldHint=False
)
DELETE = ToolAnnotations(
readOnlyHint=False, destructiveHint=True, idempotentHint=False, openWorldHint=False
)
DocumentId = Annotated[
int,
Field(
description="Document id from surfsense_search_knowledge_base or "
"surfsense_list_documents results."
),
]
DocumentTypes = Annotated[
list[str] | None,
Field(
description="Restrict to these document types, e.g. "
"['FILE', 'CRAWLED_URL', 'YOUTUBE_VIDEO']. Omit for all types."
),
]

View file

@ -0,0 +1,185 @@
"""Knowledge-base write tools: add a note, upload a file, update, and delete.
Add and upload target the active workspace; update and delete address a document
by its account-unique id, so they need no workspace.
"""
from __future__ import annotations
import mimetypes
from pathlib import Path
from typing import Annotated
from mcp.server.fastmcp import FastMCP
from pydantic import Field
from ...core.client import SurfSenseClient
from ...core.errors import ToolError
from ...core.workspace_context import WorkspaceContext, WorkspaceParam
from .annotations import DELETE, WRITE, DocumentId
from .note_ingestion import build_note_document
def register(
mcp: FastMCP, client: SurfSenseClient, context: WorkspaceContext
) -> None:
"""Register the knowledge-base write and delete tools."""
@mcp.tool(
name="surfsense_add_document",
title="Add a note",
annotations=WRITE,
structured_output=False,
)
async def add_document(
title: Annotated[
str,
Field(min_length=1, description="Short descriptive title for the note."),
],
content: Annotated[
str,
Field(
min_length=1,
description="The note's body; plain text or markdown.",
),
],
source_url: Annotated[
str | None,
Field(description="Where the text came from, if anywhere."),
] = None,
workspace: WorkspaceParam = None,
) -> str:
"""Save a text or markdown note into the workspace's knowledge base.
Use this to store notes, summaries, or findings so they become
searchable later e.g. after finishing a piece of research. For files
on disk use surfsense_upload_file instead. Indexing is asynchronous,
so the note may take a moment to appear in search.
Example: title='NotebookLM subreddits', content='- r/notebooklm ...'.
"""
resolved = await context.resolve(workspace)
await client.request(
"POST",
"/documents",
json=build_note_document(
workspace_id=resolved.id,
title=title,
content=content,
source_url=source_url,
),
)
return (
f"Queued '{title}' for indexing in '{resolved.name}'. "
"It will be searchable once processing completes."
)
@mcp.tool(
name="surfsense_upload_file",
title="Upload a file",
annotations=WRITE,
structured_output=False,
)
async def upload_file(
file_path: Annotated[
str,
Field(
description="Path to a local file, e.g. "
"'C:/Users/me/report.pdf' or '~/notes/summary.md'."
),
],
use_vision_llm: Annotated[
bool,
Field(
description="True reads scanned or image-heavy files with a "
"vision model (slower)."
),
] = False,
workspace: WorkspaceParam = None,
) -> str:
"""Upload a local file (PDF, docx, markdown, etc.) into the knowledge base.
Use this to ingest a file from disk so its content becomes searchable;
for text you already have in hand use surfsense_add_document instead.
The file is parsed, chunked, and indexed asynchronously. Duplicate
files are detected and skipped.
Example: file_path='C:/Users/me/report.pdf'.
"""
resolved = await context.resolve(workspace)
payload = _read_upload(file_path)
result = await client.request(
"POST",
"/documents/fileupload",
data={
"workspace_id": str(resolved.id),
"use_vision_llm": str(use_vision_llm).lower(),
"processing_mode": "basic",
},
files=[("files", payload)],
)
pending = (result or {}).get("pending_files", 0)
skipped = (result or {}).get("skipped_duplicates", 0)
note = " (already present, skipped)" if skipped and not pending else ""
return (
f"Uploaded '{Path(file_path).name}' to '{resolved.name}'{note}. "
"It will be searchable once processing completes."
)
@mcp.tool(
name="surfsense_update_document",
title="Replace a document's content",
annotations=WRITE,
structured_output=False,
)
async def update_document(
document_id: DocumentId,
content: Annotated[
str,
Field(
min_length=1,
description="New full text; replaces the existing content "
"entirely.",
),
],
) -> str:
"""Replace a document's stored content by id.
Use this to correct or rewrite a document's text. The new content
REPLACES the old entirely to append, read the document first with
surfsense_get_document and resend the combined text. Search chunks are
not re-indexed by this call.
"""
existing = await client.request("GET", f"/documents/{document_id}")
await client.request(
"PUT",
f"/documents/{document_id}",
json={
"document_type": existing["document_type"],
"workspace_id": existing["workspace_id"],
"content": content,
},
)
return f"Updated document {document_id} ('{existing.get('title', '')}')."
@mcp.tool(
name="surfsense_delete_document",
title="Delete a document",
annotations=DELETE,
structured_output=False,
)
async def delete_document(document_id: DocumentId) -> str:
"""Permanently delete a document from the knowledge base by id.
Use this only when the user explicitly asks to remove a document
deletion cannot be undone. The document stops appearing in searches
immediately.
"""
await client.request("DELETE", f"/documents/{document_id}")
return f"Deleted document {document_id}."
def _read_upload(file_path: str) -> tuple[str, bytes, str]:
path = Path(file_path).expanduser()
if not path.is_file():
raise ToolError(f"No file at '{file_path}'.")
mime, _ = mimetypes.guess_type(path.name)
return (path.name, path.read_bytes(), mime or "application/octet-stream")

View file

@ -0,0 +1,45 @@
"""Translate a plain note into SurfSense's document-ingestion envelope.
The REST API ingests free text through the browser-extension document shape
(title + page content + visit metadata); the backend then chunks and embeds it
like any saved page. Isolating that mapping lets the KB tool offer a simple
title+content surface without leaking the envelope's shape.
"""
from __future__ import annotations
import re
from datetime import datetime, timezone
def build_note_document(
*, workspace_id: int, title: str, content: str, source_url: str | None
) -> dict:
"""Wrap a note in the EXTENSION document payload the create endpoint expects."""
# ponytail: reuses the extension-ingestion path to add free text. Ceiling —
# visit metadata is synthesized; the "real page URL" is a stable synthetic
# link derived from the title. Upgrade path: a first-class note endpoint.
captured_at = datetime.now(timezone.utc).isoformat()
return {
"document_type": "EXTENSION",
"workspace_id": workspace_id,
"content": [
{
"metadata": {
"BrowsingSessionId": "surfsense-mcp",
"VisitedWebPageURL": source_url or _synthetic_url(title),
"VisitedWebPageTitle": title,
"VisitedWebPageDateWithTimeInISOString": captured_at,
"VisitedWebPageReffererURL": "",
"VisitedWebPageVisitDurationInMilliseconds": "0",
},
"pageContent": content,
}
],
}
def _synthetic_url(title: str) -> str:
slug = re.sub(r"[^a-z0-9]+", "-", title.casefold()).strip("-") or "note"
stamp = datetime.now(timezone.utc).strftime("%Y%m%d%H%M%S")
return f"https://surfsense.local/mcp-note/{slug}-{stamp}"

View file

@ -0,0 +1,188 @@
"""Knowledge-base read tools: semantic search, list, and read one document.
Search and list default to the active workspace; a document read is addressed by
id, which is unique across the account, so it needs no workspace.
"""
from __future__ import annotations
from typing import Annotated
from mcp.server.fastmcp import FastMCP
from pydantic import Field
from ...core.client import SurfSenseClient
from ...core.rendering import ResponseFormatParam, clip, to_json
from ...core.workspace_context import WorkspaceContext, WorkspaceParam
from .annotations import READ, DocumentId, DocumentTypes
def register(
mcp: FastMCP, client: SurfSenseClient, context: WorkspaceContext
) -> None:
"""Register the knowledge-base read tools."""
@mcp.tool(
name="surfsense_search_knowledge_base",
title="Search knowledge base",
annotations=READ,
structured_output=False,
)
async def search_knowledge_base(
query: Annotated[
str,
Field(
min_length=1,
description="Natural-language search, e.g. "
"'notebooklm user complaints'.",
),
],
top_k: Annotated[
int, Field(ge=1, le=20, description="Maximum documents to return.")
] = 5,
document_types: DocumentTypes = None,
workspace: WorkspaceParam = None,
response_format: ResponseFormatParam = "markdown",
) -> str:
"""Search the workspace's knowledge base by meaning and keywords.
Use this FIRST when a question might be answered by content already
stored in SurfSense notes, uploaded files, saved pages, past
research. Do NOT use it to fetch new data from the web; use the
scraper tools for that. Returns the most relevant documents with the
passages that matched, ranked by relevance score.
Example: query='pricing feedback', top_k=5.
"""
resolved = await context.resolve(workspace)
hits = await client.request(
"POST",
"/documents/search-semantic",
json={
"workspace_id": resolved.id,
"query": query,
"top_k": max(1, min(top_k, 20)),
"document_types": document_types,
},
)
items = (hits or {}).get("items", [])
if response_format == "json":
return to_json(items)
return _render_search(query, items)
@mcp.tool(
name="surfsense_list_documents",
title="List documents",
annotations=READ,
structured_output=False,
)
async def list_documents(
document_types: DocumentTypes = None,
folder_id: Annotated[
int | None,
Field(description="Only documents in this folder. Omit for all."),
] = None,
page: Annotated[
int, Field(ge=0, description="Zero-based page number.")
] = 0,
page_size: Annotated[
int, Field(ge=1, description="Documents per page.")
] = 20,
workspace: WorkspaceParam = None,
response_format: ResponseFormatParam = "markdown",
) -> str:
"""List documents in the workspace's knowledge base, newest first.
Use this to browse or inventory what is stored; to find documents
about a topic, prefer surfsense_search_knowledge_base. Returns each
document's title, id, type, and update time, plus a has_more flag —
request the next page by increasing page.
Example: document_types=['FILE'], page=0, page_size=20.
"""
resolved = await context.resolve(workspace)
result = await client.request(
"GET",
"/documents",
params={
"workspace_id": resolved.id,
"page": page,
"page_size": page_size,
"document_types": _join(document_types),
"folder_id": folder_id,
},
)
if response_format == "json":
return to_json(result)
return _render_document_list(result)
@mcp.tool(
name="surfsense_get_document",
title="Read one document",
annotations=READ,
structured_output=False,
)
async def get_document(
document_id: DocumentId,
response_format: ResponseFormatParam = "markdown",
) -> str:
"""Read one document's full content and metadata by id.
Use this after surfsense_search_knowledge_base or
surfsense_list_documents to open a specific document search results
only include the matching passages, this returns the whole text.
"""
document = await client.request("GET", f"/documents/{document_id}")
if response_format == "json":
return clip(to_json(document))
return _render_document(document)
def _join(values: list[str] | None) -> str | None:
return ",".join(values) if values else None
def _render_search(query: str, items: list[dict]) -> str:
if not items:
return f'No matches for "{query}".'
lines = [f'# {len(items)} result(s) for "{query}"', ""]
for hit in items:
lines.append(
f"## {hit.get('title', 'Untitled')} "
f"(id {hit.get('document_id')}) — score {hit.get('score', 0):.3f}"
)
for chunk in hit.get("chunks", []):
excerpt = clip(chunk.get("content", "").strip(), 500)
lines.append(f"> {excerpt}")
lines.append("")
return "\n".join(lines).strip()
def _render_document_list(result: dict | None) -> str:
items = (result or {}).get("items", [])
if not items:
return "No documents found."
lines = ["# Documents", ""]
for doc in items:
lines.append(
f"- **{doc.get('title', 'Untitled')}** (id {doc.get('id')}) · "
f"{doc.get('document_type')} · updated {doc.get('updated_at')}"
)
total = (result or {}).get("total", len(items))
page = (result or {}).get("page", 0)
has_more = (result or {}).get("has_more", False)
lines.append("")
lines.append(
f"_Page {page} · showing {len(items)} of {total}"
+ (" · more available_" if has_more else "_")
)
return "\n".join(lines)
def _render_document(document: dict) -> str:
content = clip(document.get("content", "") or "(empty)")
return (
f"# {document.get('title', 'Untitled')} (id {document.get('id')})\n"
f"- type: {document.get('document_type')}\n"
f"- workspace: {document.get('workspace_id')}\n"
f"- updated: {document.get('updated_at')}\n\n"
f"{content}"
)