mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-07-08 22:22:17 +02:00
feat(mcp): add knowledge base tools
This commit is contained in:
parent
39dd413711
commit
34efe3aa44
2 changed files with 325 additions and 0 deletions
|
|
@ -0,0 +1,280 @@
|
|||
"""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. Search and reads
|
||||
default to the active workspace; document ids identify a single document across
|
||||
the whole account, so id-addressed tools need no workspace.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import mimetypes
|
||||
from pathlib import Path
|
||||
|
||||
from mcp.server.fastmcp import FastMCP
|
||||
from mcp.types import ToolAnnotations
|
||||
|
||||
from ...core.client import SurfSenseClient
|
||||
from ...core.errors import ToolError
|
||||
from ...core.rendering import ResponseFormat, clip, to_json
|
||||
from ...core.workspace_context import WorkspaceContext
|
||||
from .note_ingestion import build_note_document
|
||||
|
||||
_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
|
||||
)
|
||||
|
||||
|
||||
def register(
|
||||
mcp: FastMCP, client: SurfSenseClient, context: WorkspaceContext
|
||||
) -> None:
|
||||
"""Register the knowledge-base tools on the server."""
|
||||
|
||||
@mcp.tool(
|
||||
name="surfsense_search_knowledge_base",
|
||||
annotations=_READ,
|
||||
structured_output=False,
|
||||
)
|
||||
async def search_knowledge_base(
|
||||
query: str,
|
||||
top_k: int = 5,
|
||||
document_types: list[str] | None = None,
|
||||
workspace: str | None = None,
|
||||
response_format: ResponseFormat = "markdown",
|
||||
) -> str:
|
||||
"""Search the workspace's knowledge base by meaning and keyword.
|
||||
|
||||
Use this to answer questions from stored content: it returns the most
|
||||
relevant documents with the passages that matched, ranked by relevance.
|
||||
top_k caps documents (1–20). Optionally restrict to document_types.
|
||||
"""
|
||||
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", annotations=_READ, structured_output=False
|
||||
)
|
||||
async def list_documents(
|
||||
document_types: list[str] | None = None,
|
||||
folder_id: int | None = None,
|
||||
page: int = 0,
|
||||
page_size: int = 20,
|
||||
workspace: str | None = None,
|
||||
response_format: ResponseFormat = "markdown",
|
||||
) -> str:
|
||||
"""List documents in the workspace's knowledge base, newest first.
|
||||
|
||||
Use this to browse or inventory what is stored. Optionally filter by
|
||||
document_types or a folder_id. Paginated: returns page_size items and a
|
||||
has_more flag; request the next page by increasing page.
|
||||
"""
|
||||
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", annotations=_READ, structured_output=False
|
||||
)
|
||||
async def get_document(
|
||||
document_id: int, response_format: ResponseFormat = "markdown"
|
||||
) -> str:
|
||||
"""Read one document's full content and metadata by id.
|
||||
|
||||
Use this after search or list to open a specific document. The id comes
|
||||
from those tools' results.
|
||||
"""
|
||||
document = await client.request("GET", f"/documents/{document_id}")
|
||||
if response_format == "json":
|
||||
return clip(to_json(document))
|
||||
return _render_document(document)
|
||||
|
||||
@mcp.tool(
|
||||
name="surfsense_add_document", annotations=_WRITE, structured_output=False
|
||||
)
|
||||
async def add_document(
|
||||
title: str,
|
||||
content: str,
|
||||
source_url: str | None = None,
|
||||
workspace: str | None = None,
|
||||
) -> str:
|
||||
"""Add a text or markdown note to the workspace's knowledge base.
|
||||
|
||||
Use this to save notes, summaries, or snippets so they become
|
||||
searchable. The content is indexed asynchronously, so it may take a
|
||||
moment to appear in search. source_url optionally records where the text
|
||||
came from.
|
||||
"""
|
||||
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", annotations=_WRITE, structured_output=False
|
||||
)
|
||||
async def upload_file(
|
||||
file_path: str,
|
||||
use_vision_llm: bool = False,
|
||||
workspace: str | None = None,
|
||||
) -> str:
|
||||
"""Upload a local file (PDF, doc, etc.) into the knowledge base.
|
||||
|
||||
Use this to ingest a file from disk; it is parsed, chunked, and indexed
|
||||
asynchronously. Set use_vision_llm to read scanned or image-heavy files
|
||||
with a vision model (slower).
|
||||
"""
|
||||
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", annotations=_WRITE, structured_output=False
|
||||
)
|
||||
async def update_document(document_id: int, content: str) -> str:
|
||||
"""Replace a document's stored content by id.
|
||||
|
||||
Use this to correct or rewrite a document's text. Note: this updates the
|
||||
stored content; re-indexing of search chunks is not triggered 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", annotations=_DELETE, structured_output=False
|
||||
)
|
||||
async def delete_document(document_id: int) -> str:
|
||||
"""Delete a document from the knowledge base by id.
|
||||
|
||||
Use this to permanently remove a document. Deletion runs in the
|
||||
background; 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")
|
||||
|
||||
|
||||
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}"
|
||||
)
|
||||
|
|
@ -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}"
|
||||
Loading…
Add table
Add a link
Reference in a new issue