mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-07-20 23:21:06 +02:00
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:
parent
839618ef09
commit
116291a3b6
46 changed files with 31 additions and 31 deletions
22
surfsense_mcp/mcp_server/features/knowledge_base/__init__.py
Normal file
22
surfsense_mcp/mcp_server/features/knowledge_base/__init__.py
Normal 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)
|
||||
|
|
@ -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."
|
||||
),
|
||||
]
|
||||
|
|
@ -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")
|
||||
|
|
@ -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}"
|
||||
188
surfsense_mcp/mcp_server/features/knowledge_base/search_tools.py
Normal file
188
surfsense_mcp/mcp_server/features/knowledge_base/search_tools.py
Normal 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}"
|
||||
)
|
||||
Loading…
Add table
Add a link
Reference in a new issue