diff --git a/README.es.md b/README.es.md
index 82cfcae1d..70ccd5eb1 100644
--- a/README.es.md
+++ b/README.es.md
@@ -138,7 +138,7 @@ Agrega el servidor MCP de SurfSense a Claude, Cursor o tu propio framework de ag
{
"mcpServers": {
"surfsense": {
- "url": "https://mcp.surfsense.com",
+ "url": "https://mcp.surfsense.com/mcp",
"headers": { "Authorization": "Bearer ${SURFSENSE_API_KEY}" }
}
}
diff --git a/README.hi.md b/README.hi.md
index 1c9cfa4a7..cbf9ae8fa 100644
--- a/README.hi.md
+++ b/README.hi.md
@@ -138,7 +138,7 @@ SurfSense MCP सर्वर को Claude, Cursor या अपने एज
{
"mcpServers": {
"surfsense": {
- "url": "https://mcp.surfsense.com",
+ "url": "https://mcp.surfsense.com/mcp",
"headers": { "Authorization": "Bearer ${SURFSENSE_API_KEY}" }
}
}
diff --git a/README.md b/README.md
index a05548d66..8f2e65118 100644
--- a/README.md
+++ b/README.md
@@ -138,7 +138,7 @@ Add the SurfSense MCP server to Claude, Cursor, or your own agent framework:
{
"mcpServers": {
"surfsense": {
- "url": "https://mcp.surfsense.com",
+ "url": "https://mcp.surfsense.com/mcp",
"headers": { "Authorization": "Bearer ${SURFSENSE_API_KEY}" }
}
}
diff --git a/README.pt-BR.md b/README.pt-BR.md
index 4f2b945f4..076736bcf 100644
--- a/README.pt-BR.md
+++ b/README.pt-BR.md
@@ -138,7 +138,7 @@ Adicione o servidor MCP do SurfSense ao Claude, ao Cursor ou ao seu próprio fra
{
"mcpServers": {
"surfsense": {
- "url": "https://mcp.surfsense.com",
+ "url": "https://mcp.surfsense.com/mcp",
"headers": { "Authorization": "Bearer ${SURFSENSE_API_KEY}" }
}
}
diff --git a/README.zh-CN.md b/README.zh-CN.md
index 22410a727..7d2ae35c3 100644
--- a/README.zh-CN.md
+++ b/README.zh-CN.md
@@ -138,7 +138,7 @@ curl -X POST "$SURFSENSE_API_URL/workspaces/$WORKSPACE_ID/scrapers/reddit/scrape
{
"mcpServers": {
"surfsense": {
- "url": "https://mcp.surfsense.com",
+ "url": "https://mcp.surfsense.com/mcp",
"headers": { "Authorization": "Bearer ${SURFSENSE_API_KEY}" }
}
}
diff --git a/surfsense_backend/app/capabilities/core/access/rest.py b/surfsense_backend/app/capabilities/core/access/rest.py
index 328a7c5a1..2047b21ae 100644
--- a/surfsense_backend/app/capabilities/core/access/rest.py
+++ b/surfsense_backend/app/capabilities/core/access/rest.py
@@ -71,6 +71,7 @@ class CapabilitySummary(BaseModel):
name: str
description: str
+ docs_url: str | None = None
input_schema: dict
output_schema: dict
# Empty list = free (billing disabled or an unmetered verb).
@@ -145,6 +146,7 @@ def _register_capabilities_list(
CapabilitySummary(
name=capability.name,
description=capability.description,
+ docs_url=capability.docs_url,
input_schema=capability.input_schema.model_json_schema(),
output_schema=capability.output_schema.model_json_schema(),
),
diff --git a/surfsense_backend/app/capabilities/core/types.py b/surfsense_backend/app/capabilities/core/types.py
index ec44d29aa..c87601832 100644
--- a/surfsense_backend/app/capabilities/core/types.py
+++ b/surfsense_backend/app/capabilities/core/types.py
@@ -62,3 +62,4 @@ class Capability:
output_schema: type[BaseModel]
executor: Executor
billing_unit: BillingUnit | None
+ docs_url: str | None = None
diff --git a/surfsense_backend/app/capabilities/google_maps/reviews/definition.py b/surfsense_backend/app/capabilities/google_maps/reviews/definition.py
index e96c6f906..5366194d5 100644
--- a/surfsense_backend/app/capabilities/google_maps/reviews/definition.py
+++ b/surfsense_backend/app/capabilities/google_maps/reviews/definition.py
@@ -10,15 +10,14 @@ from app.capabilities.google_maps.reviews.schemas import ReviewsInput, ReviewsOu
GOOGLE_MAPS_REVIEWS = Capability(
name="google_maps.reviews",
description=(
- "Fetch public reviews for one or more Google Maps places. Give it place "
- "URLs or place IDs; returns structured review items with author, text, "
- "star rating, like count, owner response, and timestamps. Use it to "
- "gauge sentiment or pull recent feedback on specific places."
+ "Fetch public Google Maps reviews with authors, ratings, text, and "
+ "owner responses. Use urls or place IDs."
),
input_schema=ReviewsInput,
output_schema=ReviewsOutput,
executor=build_reviews_executor(),
billing_unit=BillingUnit.GOOGLE_MAPS_REVIEW,
+ docs_url="/docs/connectors/native/google-maps",
)
register_capability(GOOGLE_MAPS_REVIEWS)
diff --git a/surfsense_backend/app/capabilities/google_maps/scrape/definition.py b/surfsense_backend/app/capabilities/google_maps/scrape/definition.py
index 035b7e00d..db117a2ac 100644
--- a/surfsense_backend/app/capabilities/google_maps/scrape/definition.py
+++ b/surfsense_backend/app/capabilities/google_maps/scrape/definition.py
@@ -11,17 +11,14 @@ from app.capabilities.google_maps.scrape.schemas import ScrapeInput, ScrapeOutpu
GOOGLE_MAPS_SCRAPE = Capability(
name="google_maps.scrape",
description=(
- "Scrape public Google Maps places. Give it search queries (optionally "
- "scoped by location), Google Maps URLs, or place IDs, and it returns "
- "structured place items — name, address, category, phone, website, "
- "rating, review count, coordinates, and opening hours. Set "
- "include_details for richer detail-page fields, or max_reviews/"
- "max_images to attach reviews and photos per place."
+ "Scrape public Google Maps places, details, reviews, and photos. Use "
+ "search_queries, urls, or place IDs."
),
input_schema=ScrapeInput,
output_schema=ScrapeOutput,
executor=build_scrape_executor(),
billing_unit=BillingUnit.GOOGLE_MAPS_PLACE,
+ docs_url="/docs/connectors/native/google-maps",
)
register_capability(GOOGLE_MAPS_SCRAPE)
diff --git a/surfsense_backend/app/capabilities/google_search/scrape/definition.py b/surfsense_backend/app/capabilities/google_search/scrape/definition.py
index 2f2c9de03..2f62847d1 100644
--- a/surfsense_backend/app/capabilities/google_search/scrape/definition.py
+++ b/surfsense_backend/app/capabilities/google_search/scrape/definition.py
@@ -10,16 +10,14 @@ from app.capabilities.google_search.scrape.schemas import ScrapeInput, ScrapeOut
GOOGLE_SEARCH_SCRAPE = Capability(
name="google_search.scrape",
description=(
- "Search Google and return structured results. Give it search terms "
- "(optionally scoped by country/language or to a single site) or full "
- "Google Search URLs, and it returns SERP items — organic results "
- "(title, url, description), related queries, people-also-ask, and any "
- "AI overview. Use max_pages_per_query to page deeper."
+ "Search Google and return structured SERP results. Use search_queries "
+ "or Google Search URLs."
),
input_schema=ScrapeInput,
output_schema=ScrapeOutput,
executor=build_scrape_executor(),
billing_unit=BillingUnit.GOOGLE_SEARCH_SERP,
+ docs_url="/docs/connectors/native/google-search",
)
register_capability(GOOGLE_SEARCH_SCRAPE)
diff --git a/surfsense_backend/app/capabilities/reddit/scrape/definition.py b/surfsense_backend/app/capabilities/reddit/scrape/definition.py
index 01fb6db5f..fe00a77be 100644
--- a/surfsense_backend/app/capabilities/reddit/scrape/definition.py
+++ b/surfsense_backend/app/capabilities/reddit/scrape/definition.py
@@ -10,16 +10,14 @@ from app.capabilities.reddit.scrape.schemas import ScrapeInput, ScrapeOutput
REDDIT_SCRAPE = Capability(
name="reddit.scrape",
description=(
- "Scrape public Reddit data. Give it Reddit URLs (post, subreddit, or "
- "user) and/or search terms, and it returns structured items — posts "
- "(title, body, score, comment count, subreddit, author), their comments, "
- "and community/user metadata. Use search_queries (optionally scoped to a "
- "community) to discover posts, or urls to pull a known post/subreddit/user."
+ "Scrape public Reddit posts, comments, and metadata. Use urls or "
+ "search_queries."
),
input_schema=ScrapeInput,
output_schema=ScrapeOutput,
executor=build_scrape_executor(),
billing_unit=BillingUnit.REDDIT_ITEM,
+ docs_url="/docs/connectors/native/reddit",
)
register_capability(REDDIT_SCRAPE)
diff --git a/surfsense_backend/app/capabilities/web/crawl/definition.py b/surfsense_backend/app/capabilities/web/crawl/definition.py
index cd3db2306..362f4bcd0 100644
--- a/surfsense_backend/app/capabilities/web/crawl/definition.py
+++ b/surfsense_backend/app/capabilities/web/crawl/definition.py
@@ -9,29 +9,14 @@ from app.capabilities.web.crawl.schemas import CrawlInput, CrawlOutput
WEB_CRAWL = Capability(
name="web.crawl",
description=(
- "Scrape a single web page or crawl a whole website. Give it one or more "
- "startUrls. Set maxCrawlDepth=0 to fetch just those URLs, or higher to "
- "also follow the links on each page (depth 1 = the start pages plus the "
- "pages they link to, and so on) — staying on the same site and stopping "
- "at maxCrawlPages. On a deeper crawl, narrow which links are followed with "
- "includeUrlPatterns / excludeUrlPatterns (regexes). Returns one item per "
- "fetched page with clean markdown content, metadata (title, description), "
- "crawl provenance, every link with its anchor text and kind "
- "(internal/external/social/email/tel — use the text/context to tie a "
- "profile URL to a person or company), and contact signals (emails, phone "
- "numbers, social profiles). The site-wide contacts summary deduplicates "
- "them with provenance: siteWide=true marks footer/header values (the "
- "company's own contacts) vs page-local finds (e.g. team members' "
- "profiles). Useful for lead generation and competitive intelligence; "
- "contact details often live on about/contact/privacy pages, so crawl "
- "with maxCrawlDepth >= 1 to surface them. JS-rendered pages are loaded "
- "in a real browser and auto-scrolled, so lazy-loaded listings "
- "(directories, infinite-scroll feeds) are captured too."
+ "Scrape pages or crawl websites for clean markdown, links, metadata, "
+ "and contact signals. Use startUrls and crawl-depth controls."
),
input_schema=CrawlInput,
output_schema=CrawlOutput,
executor=build_crawl_executor(),
billing_unit=BillingUnit.WEB_CRAWL,
+ docs_url="/docs/connectors/native/web-crawl",
)
register_capability(WEB_CRAWL)
diff --git a/surfsense_backend/app/capabilities/youtube/comments/definition.py b/surfsense_backend/app/capabilities/youtube/comments/definition.py
index ec689a23c..c22145547 100644
--- a/surfsense_backend/app/capabilities/youtube/comments/definition.py
+++ b/surfsense_backend/app/capabilities/youtube/comments/definition.py
@@ -10,15 +10,14 @@ from app.capabilities.youtube.comments.schemas import CommentsInput, CommentsOut
YOUTUBE_COMMENTS = Capability(
name="youtube.comments",
description=(
- "Fetch public comments (and their replies) for one or more YouTube "
- "videos. Give it the video URLs; returns structured comment items with "
- "author, text, like count, reply relationships, and timestamps. Use it "
- "to gauge sentiment or pull discussion on specific videos."
+ "Fetch public YouTube comments and replies with authors, text, likes, "
+ "and timestamps. Use video URLs."
),
input_schema=CommentsInput,
output_schema=CommentsOutput,
executor=build_comments_executor(),
billing_unit=BillingUnit.YOUTUBE_COMMENT,
+ docs_url="/docs/connectors/native/youtube",
)
register_capability(YOUTUBE_COMMENTS)
diff --git a/surfsense_backend/app/capabilities/youtube/scrape/definition.py b/surfsense_backend/app/capabilities/youtube/scrape/definition.py
index 43874e254..d281ea7d6 100644
--- a/surfsense_backend/app/capabilities/youtube/scrape/definition.py
+++ b/surfsense_backend/app/capabilities/youtube/scrape/definition.py
@@ -10,16 +10,14 @@ from app.capabilities.youtube.scrape.schemas import ScrapeInput, ScrapeOutput
YOUTUBE_SCRAPE = Capability(
name="youtube.scrape",
description=(
- "Scrape public YouTube data. Give it YouTube URLs (video, channel, "
- "playlist, shorts, or hashtag) and/or search queries, and it returns "
- "structured video items — title, views, likes, publish date, channel "
- "info, description, and optionally subtitles. Use search_queries to "
- "discover videos, or urls to pull a known video/channel/playlist."
+ "Scrape public YouTube videos, channels, playlists, and subtitles. Use "
+ "urls or search_queries."
),
input_schema=ScrapeInput,
output_schema=ScrapeOutput,
executor=build_scrape_executor(),
billing_unit=BillingUnit.YOUTUBE_VIDEO,
+ docs_url="/docs/connectors/native/youtube",
)
register_capability(YOUTUBE_SCRAPE)
diff --git a/surfsense_mcp/.dockerignore b/surfsense_mcp/.dockerignore
new file mode 100644
index 000000000..28abd4591
--- /dev/null
+++ b/surfsense_mcp/.dockerignore
@@ -0,0 +1,11 @@
+.git
+.gitignore
+.venv
+__pycache__/
+*.py[cod]
+*$py.class
+*.so
+.pytest_cache/
+.ruff_cache/
+.env
+tests/
diff --git a/surfsense_mcp/Dockerfile b/surfsense_mcp/Dockerfile
new file mode 100644
index 000000000..673fb2c63
--- /dev/null
+++ b/surfsense_mcp/Dockerfile
@@ -0,0 +1,36 @@
+# syntax=docker.io/docker/dockerfile:1
+# SurfSense MCP Server — remote (streamable-http) image.
+# Serves /mcp (per-request API key, no baked secret) and a public /health probe.
+
+# Stage 1: deps frozen from uv.lock so rebuilds never drift.
+FROM python:3.12-slim AS deps
+
+WORKDIR /app
+
+# curl is used by the container healthcheck probe against /health.
+RUN apt-get update && apt-get install -y --no-install-recommends curl \
+ && rm -rf /var/lib/apt/lists/*
+
+COPY pyproject.toml uv.lock ./
+RUN pip install --no-cache-dir uv && \
+ uv export --frozen --no-dev --no-emit-project --no-hashes \
+ --format requirements-txt -o /tmp/requirements.txt && \
+ uv pip install --system --no-cache-dir -r /tmp/requirements.txt && \
+ rm /tmp/requirements.txt
+
+
+# Stage 2: project source; --no-deps since deps are already installed above.
+FROM deps AS production
+
+COPY . .
+RUN uv pip install --system --no-cache-dir --no-deps -e .
+
+ENV PYTHONUNBUFFERED=1 \
+ SURFSENSE_MCP_TRANSPORT=streamable-http \
+ SURFSENSE_MCP_HOST=0.0.0.0 \
+ SURFSENSE_MCP_PORT=8080 \
+ SURFSENSE_BASE_URL=https://api.surfsense.com
+
+EXPOSE 8080
+
+CMD ["python", "-m", "mcp_server"]
diff --git a/surfsense_mcp/README.md b/surfsense_mcp/README.md
index 47dabf55c..c18a857e1 100644
--- a/surfsense_mcp/README.md
+++ b/surfsense_mcp/README.md
@@ -2,9 +2,15 @@
A [Model Context Protocol](https://modelcontextprotocol.io/) server that exposes
SurfSense to MCP clients like **Claude Code**, **Cursor**, and **Claude Desktop**.
-It talks to a running SurfSense backend purely over its REST API using a SurfSense
-API key — it imports no backend code and can point at any instance (local or
-hosted) by changing two environment variables.
+It talks to a SurfSense backend purely over its REST API using a SurfSense API
+key — it imports no backend code.
+
+Connect it two ways:
+
+- **Hosted** (recommended) — point your client at `https://mcp.surfsense.com/mcp`
+ and pass your API key in a header. Nothing to install or keep running.
+- **Self-host (stdio)** — run the server yourself against any backend (cloud or
+ your own). Best for self-hosters and clients without remote-server support.
## Tools
@@ -29,34 +35,58 @@ Workspace-scoped tools default to the active workspace; pass `workspace` (a name
or id) to override for a single call. Ids never need to be typed by hand — the
model carries them between calls.
-## Prerequisites
+## Get an API key
-1. A running SurfSense backend (default `http://localhost:8000`).
-2. A **SurfSense API key**: SurfSense → Settings → API → create key (`ss_pat_…`).
-3. **API access enabled** on the workspace(s) you want to use (workspace settings).
+1. SurfSense → **API Playground → API Keys**: create a personal key (`ss_pat_…`).
+ It is shown only once.
+2. Toggle **API key access** on for the workspace(s) you want to use.
-## Setup
+## Connect (hosted)
-Uses [uv](https://github.com/astral-sh/uv):
+Point your client at the hosted server and send the key as a Bearer token. For
+clients that read an `mcpServers` map (Cursor, Claude Desktop, and others):
+
+```json
+{
+ "mcpServers": {
+ "surfsense": {
+ "url": "https://mcp.surfsense.com/mcp",
+ "headers": { "Authorization": "Bearer ss_pat_your_key_here" }
+ }
+ }
+}
+```
+
+Claude Code, from a terminal:
+
+```bash
+claude mcp add --transport http surfsense https://mcp.surfsense.com/mcp \
+ --header "Authorization: Bearer ss_pat_your_key_here"
+```
+
+Most MCP clients accept this `url` + `headers` form; check your client's docs for
+its exact remote-server field.
+
+## Self-host (stdio)
+
+Run the server yourself when you host your own backend or use a client without
+remote support. It uses [uv](https://github.com/astral-sh/uv):
```bash
cd surfsense_mcp
uv sync
-uv run python -m surfsense_mcp.selfcheck # verify tools register correctly
+uv run python -m mcp_server.selfcheck # verify tools register correctly
```
-## Connect it to a client
-
-### Cursor
-
-Add to `~/.cursor/mcp.json` (or a project `.cursor/mcp.json`):
+Then add it to your client. Cursor (`~/.cursor/mcp.json` or a project
+`.cursor/mcp.json`):
```json
{
"mcpServers": {
"surfsense": {
"command": "uv",
- "args": ["run", "--directory", "/absolute/path/to/SurfSense/surfsense_mcp", "python", "-m", "surfsense_mcp"],
+ "args": ["run", "--directory", "/absolute/path/to/SurfSense/surfsense_mcp", "python", "-m", "mcp_server"],
"env": {
"SURFSENSE_BASE_URL": "http://localhost:8000",
"SURFSENSE_API_KEY": "ss_pat_your_token_here"
@@ -66,24 +96,22 @@ Add to `~/.cursor/mcp.json` (or a project `.cursor/mcp.json`):
}
```
-### Claude Code
+Claude Code:
```bash
claude mcp add surfsense \
-e SURFSENSE_BASE_URL=http://localhost:8000 \
-e SURFSENSE_API_KEY=ss_pat_your_token_here \
- -- uv run --directory /absolute/path/to/SurfSense/surfsense_mcp python -m surfsense_mcp
+ -- uv run --directory /absolute/path/to/SurfSense/surfsense_mcp python -m mcp_server
```
-### Claude Desktop
-
-Add the same `mcpServers` block as Cursor to
+Claude Desktop: add the same `mcpServers` block as Cursor to
`claude_desktop_config.json` (Settings → Developer → Edit Config).
## Configuration
-See `.env.example`. Secrets are passed as environment variables by the client;
-never commit tokens.
+See `.env.example`. For self-host, secrets are passed as environment variables by
+the client; never commit tokens.
## Backend dependency
diff --git a/surfsense_mcp/src/surfsense_mcp/__init__.py b/surfsense_mcp/mcp_server/__init__.py
similarity index 100%
rename from surfsense_mcp/src/surfsense_mcp/__init__.py
rename to surfsense_mcp/mcp_server/__init__.py
diff --git a/surfsense_mcp/mcp_server/__main__.py b/surfsense_mcp/mcp_server/__main__.py
new file mode 100644
index 000000000..37a2a1785
--- /dev/null
+++ b/surfsense_mcp/mcp_server/__main__.py
@@ -0,0 +1,58 @@
+"""Entry point: load settings from the environment and run the MCP server.
+
+Two transports share one build:
+- ``stdio`` (default): Cursor/Claude launch one process per user; the key comes
+ from the environment, so it is required here.
+- ``streamable-http``: one process serves many users, each passing their own key
+ per request; the key is enforced by the transport's auth middleware instead.
+
+For stdio, stdout is the protocol channel, so every log line goes to stderr.
+"""
+
+from __future__ import annotations
+
+import logging
+import os
+import sys
+
+from mcp.server.fastmcp import FastMCP
+
+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()
+ transport = os.environ.get("SURFSENSE_MCP_TRANSPORT", "stdio").strip() or "stdio"
+ mcp, _client = build_server(settings)
+
+ if transport in ("streamable-http", "http"):
+ _run_http(mcp, settings)
+ return
+
+ if transport == "stdio" and not settings.api_key:
+ raise SystemExit(
+ "SURFSENSE_API_KEY is required for stdio transport. Create an API "
+ "key in SurfSense (Settings -> API) and pass it via the "
+ "SURFSENSE_API_KEY environment variable."
+ )
+ mcp.run(transport=transport)
+
+
+def _run_http(mcp: FastMCP, settings: Settings) -> None:
+ """Serve the streamable-http app directly, so the per-request identity
+ middleware wraps the SDK's MCP endpoint."""
+ import uvicorn
+
+ from .core.transport import build_http_app
+
+ uvicorn.run(build_http_app(mcp), host=settings.host, port=settings.port)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/surfsense_mcp/src/surfsense_mcp/config.py b/surfsense_mcp/mcp_server/config.py
similarity index 71%
rename from surfsense_mcp/src/surfsense_mcp/config.py
rename to surfsense_mcp/mcp_server/config.py
index 5d3646545..18d0f1514 100644
--- a/surfsense_mcp/src/surfsense_mcp/config.py
+++ b/surfsense_mcp/mcp_server/config.py
@@ -12,6 +12,8 @@ from dataclasses import dataclass
DEFAULT_BASE_URL = "http://localhost:8000"
DEFAULT_API_PREFIX = "/api/v1"
DEFAULT_TIMEOUT_SECONDS = 180.0
+DEFAULT_HTTP_HOST = "127.0.0.1"
+DEFAULT_HTTP_PORT = 8080
@dataclass(frozen=True)
@@ -19,10 +21,12 @@ class Settings:
"""Resolved configuration for a server process."""
base_url: str
- api_key: str
+ api_key: str | None
api_prefix: str
timeout: float
default_workspace: str | None
+ host: str
+ port: int
@property
def api_base(self) -> str:
@@ -30,13 +34,9 @@ class Settings:
@classmethod
def from_env(cls) -> Settings:
- api_key = os.environ.get("SURFSENSE_API_KEY", "").strip()
- if not api_key:
- raise SystemExit(
- "SURFSENSE_API_KEY is required. Create an API key in SurfSense "
- "(Settings -> API) and pass it via the SURFSENSE_API_KEY "
- "environment variable."
- )
+ # Optional here: remote (http) callers pass their own key per request in
+ # a header. ``__main__`` enforces it for stdio, its only source of a key.
+ api_key = os.environ.get("SURFSENSE_API_KEY", "").strip() or None
base_url = (
os.environ.get("SURFSENSE_BASE_URL", DEFAULT_BASE_URL).strip().rstrip("/")
@@ -53,10 +53,19 @@ class Settings:
default_workspace = os.environ.get("SURFSENSE_WORKSPACE", "").strip() or None
+ host = os.environ.get("SURFSENSE_MCP_HOST", "").strip() or DEFAULT_HTTP_HOST
+ raw_port = os.environ.get("SURFSENSE_MCP_PORT", "").strip()
+ try:
+ port = int(raw_port) if raw_port else DEFAULT_HTTP_PORT
+ except ValueError:
+ port = DEFAULT_HTTP_PORT
+
return cls(
base_url=base_url,
api_key=api_key,
api_prefix=api_prefix,
timeout=timeout,
default_workspace=default_workspace,
+ host=host,
+ port=port,
)
diff --git a/surfsense_mcp/src/surfsense_mcp/core/__init__.py b/surfsense_mcp/mcp_server/core/__init__.py
similarity index 100%
rename from surfsense_mcp/src/surfsense_mcp/core/__init__.py
rename to surfsense_mcp/mcp_server/core/__init__.py
diff --git a/surfsense_mcp/mcp_server/core/auth/__init__.py b/surfsense_mcp/mcp_server/core/auth/__init__.py
new file mode 100644
index 000000000..e837c6d61
--- /dev/null
+++ b/surfsense_mcp/mcp_server/core/auth/__init__.py
@@ -0,0 +1,7 @@
+"""Per-request caller identity: header parsing, request-scoped storage, and the
+ASGI middleware that binds them together for the remote transport."""
+
+from .identity import current_api_key, current_identity
+from .middleware import ApiKeyIdentityMiddleware
+
+__all__ = ["current_api_key", "current_identity", "ApiKeyIdentityMiddleware"]
diff --git a/surfsense_mcp/mcp_server/core/auth/headers.py b/surfsense_mcp/mcp_server/core/auth/headers.py
new file mode 100644
index 000000000..3bca45967
--- /dev/null
+++ b/surfsense_mcp/mcp_server/core/auth/headers.py
@@ -0,0 +1,24 @@
+"""Extract a SurfSense API key from request headers.
+
+Pure header parsing, kept separate from transport and state.
+"""
+
+from __future__ import annotations
+
+from starlette.datastructures import Headers
+
+_BEARER_PREFIX = "bearer "
+
+
+def extract_api_key(headers: Headers) -> str | None:
+ """Return the caller's key from the ``Authorization: Bearer`` slot the
+ backend already expects, falling back to ``X-API-Key`` for clients that can
+ only send custom headers."""
+ authorization = headers.get("authorization", "")
+ if authorization[: len(_BEARER_PREFIX)].lower() == _BEARER_PREFIX:
+ token = authorization[len(_BEARER_PREFIX) :].strip()
+ if token:
+ return token
+
+ fallback = headers.get("x-api-key", "").strip()
+ return fallback or None
diff --git a/surfsense_mcp/mcp_server/core/auth/identity.py b/surfsense_mcp/mcp_server/core/auth/identity.py
new file mode 100644
index 000000000..0c8f6b315
--- /dev/null
+++ b/surfsense_mcp/mcp_server/core/auth/identity.py
@@ -0,0 +1,34 @@
+"""Request-scoped caller identity.
+
+Over streamable-http one process serves many users, so the caller's key lives in
+a contextvar for the life of a request: the auth middleware binds it and the
+client reads it when calling the backend. Under stdio there is no request, so the
+contextvar is empty and the env key is used instead.
+"""
+
+from __future__ import annotations
+
+from contextvars import ContextVar, Token
+
+_LOCAL_IDENTITY = "__local__"
+
+_api_key: ContextVar[str | None] = ContextVar("surfsense_api_key", default=None)
+
+
+def bind_api_key(api_key: str | None) -> Token:
+ """Bind the caller's key to the current request; returns a reset token."""
+ return _api_key.set(api_key)
+
+
+def unbind_api_key(token: Token) -> None:
+ _api_key.reset(token)
+
+
+def current_api_key() -> str | None:
+ """The caller's key for the in-flight request, or ``None`` under stdio."""
+ return _api_key.get()
+
+
+def current_identity() -> str:
+ """Stable per-caller key for scoping request state; shared under stdio."""
+ return _api_key.get() or _LOCAL_IDENTITY
diff --git a/surfsense_mcp/mcp_server/core/auth/middleware.py b/surfsense_mcp/mcp_server/core/auth/middleware.py
new file mode 100644
index 000000000..8ba6ce939
--- /dev/null
+++ b/surfsense_mcp/mcp_server/core/auth/middleware.py
@@ -0,0 +1,58 @@
+"""ASGI middleware that establishes the caller's identity for each request.
+
+A pure ASGI middleware, deliberately not Starlette's ``BaseHTTPMiddleware``:
+the latter runs the endpoint in a separate task, so a contextvar set in it does
+not reach the tool handler. A pure middleware binds the key in the request's own
+task, from which the SDK's per-request handling inherits it.
+
+Requests without a key are rejected here so no tool ever runs unauthenticated.
+Paths in ``public_paths`` (e.g. the health probe) skip the check entirely.
+"""
+
+from __future__ import annotations
+
+from collections.abc import Iterable
+
+from starlette.datastructures import Headers
+from starlette.responses import JSONResponse
+from starlette.types import ASGIApp, Receive, Scope, Send
+
+from .headers import extract_api_key
+from .identity import bind_api_key, unbind_api_key
+
+
+class ApiKeyIdentityMiddleware:
+ """Binds the per-request API key into the identity contextvar, or 401s."""
+
+ def __init__(self, app: ASGIApp, public_paths: Iterable[str] = ()) -> None:
+ self._app = app
+ self._public_paths = frozenset(public_paths)
+
+ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
+ if scope["type"] != "http" or scope["path"] in self._public_paths:
+ await self._app(scope, receive, send)
+ return
+
+ api_key = extract_api_key(Headers(scope=scope))
+ if api_key is None:
+ await _unauthenticated()(scope, receive, send)
+ return
+
+ token = bind_api_key(api_key)
+ try:
+ await self._app(scope, receive, send)
+ finally:
+ unbind_api_key(token)
+
+
+def _unauthenticated() -> JSONResponse:
+ return JSONResponse(
+ {
+ "error": "unauthorized",
+ "message": (
+ "Missing SurfSense API key. Send 'Authorization: Bearer "
+ "ss_pat_...' (or an X-API-Key header)."
+ ),
+ },
+ status_code=401,
+ )
diff --git a/surfsense_mcp/src/surfsense_mcp/core/client.py b/surfsense_mcp/mcp_server/core/client.py
similarity index 72%
rename from surfsense_mcp/src/surfsense_mcp/core/client.py
rename to surfsense_mcp/mcp_server/core/client.py
index 55f9d6048..9ae446ae2 100644
--- a/surfsense_mcp/src/surfsense_mcp/core/client.py
+++ b/surfsense_mcp/mcp_server/core/client.py
@@ -10,10 +10,11 @@ from typing import Any
import httpx
+from .auth.identity import current_api_key
from .errors import ToolError
_FAILURE_HINTS: dict[int, str] = {
- 401: "Authentication failed — check that SURFSENSE_API_KEY is a valid, unexpired key.",
+ 401: "Authentication failed — the SurfSense API key is invalid or expired.",
402: "The workspace is out of credits for this operation.",
403: (
"Access denied — the token lacks permission, or API access is disabled "
@@ -27,17 +28,30 @@ _FAILURE_HINTS: dict[int, str] = {
class SurfSenseClient:
"""Issues authenticated requests against ``{base_url}{api_prefix}``."""
- def __init__(self, *, api_base: str, api_key: str, timeout: float) -> None:
+ def __init__(
+ self, *, api_base: str, timeout: float, fallback_api_key: str | None = None
+ ) -> None:
self._api_base = api_base
+ # Resolved per request, so no key is baked into the shared client. The
+ # fallback is the env key used under stdio, where there is no header.
+ self._fallback_api_key = fallback_api_key
self._http = httpx.AsyncClient(
base_url=api_base,
- headers={
- "Authorization": f"Bearer {api_key}",
- "Accept": "application/json",
- },
+ headers={"Accept": "application/json"},
timeout=timeout,
)
+ def _auth_headers(self) -> dict[str, str]:
+ """Resolve the caller's key: the per-request header, else the env key."""
+ api_key = current_api_key() or self._fallback_api_key
+ if not api_key:
+ raise ToolError(
+ "No SurfSense API key supplied. Send it as an 'Authorization: "
+ "Bearer ss_pat_...' header (remote server), or set the "
+ "SURFSENSE_API_KEY environment variable (stdio)."
+ )
+ return {"Authorization": f"Bearer {api_key}"}
+
async def request(
self,
method: str,
@@ -53,9 +67,16 @@ class SurfSenseClient:
# as a value (e.g. int("") on folder_id) and fail.
if params is not None:
params = {key: value for key, value in params.items() if value is not None}
+ headers = self._auth_headers()
try:
response = await self._http.request(
- method, path, params=params, json=json, data=data, files=files
+ method,
+ path,
+ params=params,
+ json=json,
+ data=data,
+ files=files,
+ headers=headers,
)
except httpx.RequestError as exc:
raise ToolError(
diff --git a/surfsense_mcp/src/surfsense_mcp/core/errors.py b/surfsense_mcp/mcp_server/core/errors.py
similarity index 100%
rename from surfsense_mcp/src/surfsense_mcp/core/errors.py
rename to surfsense_mcp/mcp_server/core/errors.py
diff --git a/surfsense_mcp/src/surfsense_mcp/core/rendering.py b/surfsense_mcp/mcp_server/core/rendering.py
similarity index 100%
rename from surfsense_mcp/src/surfsense_mcp/core/rendering.py
rename to surfsense_mcp/mcp_server/core/rendering.py
diff --git a/surfsense_mcp/mcp_server/core/transport/__init__.py b/surfsense_mcp/mcp_server/core/transport/__init__.py
new file mode 100644
index 000000000..53aaa6b8c
--- /dev/null
+++ b/surfsense_mcp/mcp_server/core/transport/__init__.py
@@ -0,0 +1,5 @@
+"""Transport wiring for the MCP server (streamable-http app assembly)."""
+
+from .http import build_http_app
+
+__all__ = ["build_http_app"]
diff --git a/surfsense_mcp/mcp_server/core/transport/http.py b/surfsense_mcp/mcp_server/core/transport/http.py
new file mode 100644
index 000000000..e375e8b98
--- /dev/null
+++ b/surfsense_mcp/mcp_server/core/transport/http.py
@@ -0,0 +1,36 @@
+"""Wrap the SDK's MCP endpoint with identity + CORS for the remote transport.
+
+CORS is outermost so keyless browser preflight is answered before the identity
+middleware. ``/health`` is a public path, exempt from the key check.
+"""
+
+from __future__ import annotations
+
+from mcp.server.fastmcp import FastMCP
+from starlette.requests import Request
+from starlette.responses import JSONResponse
+from starlette.middleware.cors import CORSMiddleware
+from starlette.types import ASGIApp
+
+from ..auth.middleware import ApiKeyIdentityMiddleware
+
+HEALTH_PATH = "/health"
+
+
+async def _health(_request: Request) -> JSONResponse:
+ return JSONResponse({"status": "ok"})
+
+
+def build_http_app(mcp: FastMCP) -> ASGIApp:
+ """Return the MCP streamable-http app wrapped with identity + CORS."""
+ mcp.custom_route(HEALTH_PATH, methods=["GET"])(_health)
+ app: ASGIApp = ApiKeyIdentityMiddleware(
+ mcp.streamable_http_app(), public_paths={HEALTH_PATH}
+ )
+ return CORSMiddleware(
+ app,
+ allow_origins=["*"],
+ allow_methods=["GET", "POST", "DELETE", "OPTIONS"],
+ allow_headers=["*"],
+ expose_headers=["Mcp-Session-Id"],
+ )
diff --git a/surfsense_mcp/src/surfsense_mcp/core/workspace_context.py b/surfsense_mcp/mcp_server/core/workspace_context.py
similarity index 68%
rename from surfsense_mcp/src/surfsense_mcp/core/workspace_context.py
rename to surfsense_mcp/mcp_server/core/workspace_context.py
index 6682d53b5..0c5423e6b 100644
--- a/surfsense_mcp/src/surfsense_mcp/core/workspace_context.py
+++ b/surfsense_mcp/mcp_server/core/workspace_context.py
@@ -7,13 +7,22 @@ speaks a name, we resolve it, and remember the choice for later calls.
from __future__ import annotations
+from collections import OrderedDict
from dataclasses import dataclass
from typing import Annotated
from pydantic import Field
+from .auth.identity import current_identity
from .client import SurfSenseClient
from .errors import ToolError
+from .workspace_matching import as_int, match_by_name, name_list
+
+# ponytail: one small entry per distinct caller (API token). Bounded so a flood
+# of keys can't grow memory without limit; an evicted caller just re-resolves
+# its default workspace on the next call. Upgrade path: a TTL/LRU store if
+# per-caller state ever grows past this one field.
+_MAX_TRACKED_IDENTITIES = 2048
# Shared parameter type for every workspace-scoped tool.
WorkspaceParam = Annotated[
@@ -44,15 +53,21 @@ class WorkspaceContext:
) -> None:
self._client = client
self._preferred_reference = preferred_reference
- self._active: Workspace | None = None
+ # Active selection is per caller: one shared slot would leak one user's
+ # choice to every other user on a shared server.
+ self._active_by_identity: OrderedDict[str, Workspace] = OrderedDict()
@property
def active(self) -> Workspace | None:
- return self._active
+ return self._active_by_identity.get(current_identity())
def remember(self, workspace: Workspace) -> Workspace:
- """Make ``workspace`` the default for later scoped calls."""
- self._active = workspace
+ """Make ``workspace`` the default for the current caller's later calls."""
+ identity = current_identity()
+ self._active_by_identity[identity] = workspace
+ self._active_by_identity.move_to_end(identity)
+ while len(self._active_by_identity) > _MAX_TRACKED_IDENTITIES:
+ self._active_by_identity.popitem(last=False)
return workspace
async def fetch_all(self) -> list[Workspace]:
@@ -67,8 +82,9 @@ class WorkspaceContext:
return self.remember(await self._match(reference))
async def _resolve_default(self) -> Workspace:
- if self._active is not None:
- return self._active
+ active = self.active
+ if active is not None:
+ return active
if self._preferred_reference:
return self.remember(await self._match(self._preferred_reference))
return self.remember(await self._only_workspace_or_prompt())
@@ -84,43 +100,20 @@ class WorkspaceContext:
)
raise ToolError(
"No workspace selected. Choose one first with surfsense_select_workspace, "
- f"or pass 'workspace'. Available: {_name_list(workspaces)}."
+ f"or pass 'workspace'. Available: {name_list(workspaces)}."
)
async def _match(self, reference: str | int) -> Workspace:
workspaces = await self.fetch_all()
- as_id = _as_int(reference)
+ as_id = as_int(reference)
if as_id is not None:
found = next((w for w in workspaces if w.id == as_id), None)
if found is None:
raise ToolError(
- f"No workspace with id {as_id}. Available: {_name_list(workspaces)}."
+ f"No workspace with id {as_id}. Available: {name_list(workspaces)}."
)
return found
- return _match_by_name(str(reference), workspaces)
-
-
-def _match_by_name(reference: str, workspaces: list[Workspace]) -> Workspace:
- """Match on name: exact, then case-insensitive, then unique substring."""
- needle = reference.strip()
- exact = [w for w in workspaces if w.name == needle]
- if exact:
- return exact[0]
- lowered = needle.casefold()
- insensitive = [w for w in workspaces if w.name.casefold() == lowered]
- if insensitive:
- return insensitive[0]
- partial = [w for w in workspaces if lowered in w.name.casefold()]
- if len(partial) == 1:
- return partial[0]
- if len(partial) > 1:
- raise ToolError(
- f"'{reference}' matches several workspaces: {_name_list(partial)}. "
- "Use a more specific name or the id."
- )
- raise ToolError(
- f"No workspace named '{reference}'. Available: {_name_list(workspaces)}."
- )
+ return match_by_name(str(reference), workspaces)
def _to_workspace(row: dict) -> Workspace:
@@ -131,14 +124,3 @@ def _to_workspace(row: dict) -> Workspace:
is_owner=row.get("is_owner", False),
member_count=row.get("member_count", 1),
)
-
-
-def _as_int(reference: str | int) -> int | None:
- if isinstance(reference, int):
- return reference
- text = reference.strip()
- return int(text) if text.isdigit() else None
-
-
-def _name_list(workspaces: list[Workspace]) -> str:
- return ", ".join(f"{w.name} (id {w.id})" for w in workspaces)
diff --git a/surfsense_mcp/mcp_server/core/workspace_matching.py b/surfsense_mcp/mcp_server/core/workspace_matching.py
new file mode 100644
index 000000000..33bfc50a5
--- /dev/null
+++ b/surfsense_mcp/mcp_server/core/workspace_matching.py
@@ -0,0 +1,51 @@
+"""Resolve a user-supplied workspace reference to a single workspace.
+
+Pure matching over an already-fetched list: name (exact, then case-insensitive,
+then unique substring) or numeric id. Kept apart from WorkspaceContext so the
+resolution rules can be read and tested without the network.
+"""
+
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+from .errors import ToolError
+
+if TYPE_CHECKING:
+ from .workspace_context import Workspace
+
+
+def match_by_name(reference: str, workspaces: list[Workspace]) -> Workspace:
+ """Match on name: exact, then case-insensitive, then unique substring."""
+ needle = reference.strip()
+ exact = [w for w in workspaces if w.name == needle]
+ if exact:
+ return exact[0]
+ lowered = needle.casefold()
+ insensitive = [w for w in workspaces if w.name.casefold() == lowered]
+ if insensitive:
+ return insensitive[0]
+ partial = [w for w in workspaces if lowered in w.name.casefold()]
+ if len(partial) == 1:
+ return partial[0]
+ if len(partial) > 1:
+ raise ToolError(
+ f"'{reference}' matches several workspaces: {name_list(partial)}. "
+ "Use a more specific name or the id."
+ )
+ raise ToolError(
+ f"No workspace named '{reference}'. Available: {name_list(workspaces)}."
+ )
+
+
+def as_int(reference: str | int) -> int | None:
+ """Return the reference as an id, or None when it isn't numeric."""
+ if isinstance(reference, int):
+ return reference
+ text = reference.strip()
+ return int(text) if text.isdigit() else None
+
+
+def name_list(workspaces: list[Workspace]) -> str:
+ """Render workspaces as a human-readable 'name (id N)' list."""
+ return ", ".join(f"{w.name} (id {w.id})" for w in workspaces)
diff --git a/surfsense_mcp/src/surfsense_mcp/features/__init__.py b/surfsense_mcp/mcp_server/features/__init__.py
similarity index 100%
rename from surfsense_mcp/src/surfsense_mcp/features/__init__.py
rename to surfsense_mcp/mcp_server/features/__init__.py
diff --git a/surfsense_mcp/mcp_server/features/knowledge_base/__init__.py b/surfsense_mcp/mcp_server/features/knowledge_base/__init__.py
new file mode 100644
index 000000000..1a971bfe4
--- /dev/null
+++ b/surfsense_mcp/mcp_server/features/knowledge_base/__init__.py
@@ -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)
diff --git a/surfsense_mcp/mcp_server/features/knowledge_base/annotations.py b/surfsense_mcp/mcp_server/features/knowledge_base/annotations.py
new file mode 100644
index 000000000..16322506c
--- /dev/null
+++ b/surfsense_mcp/mcp_server/features/knowledge_base/annotations.py
@@ -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."
+ ),
+]
diff --git a/surfsense_mcp/mcp_server/features/knowledge_base/document_tools.py b/surfsense_mcp/mcp_server/features/knowledge_base/document_tools.py
new file mode 100644
index 000000000..497a2526c
--- /dev/null
+++ b/surfsense_mcp/mcp_server/features/knowledge_base/document_tools.py
@@ -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")
diff --git a/surfsense_mcp/src/surfsense_mcp/features/knowledge_base/note_ingestion.py b/surfsense_mcp/mcp_server/features/knowledge_base/note_ingestion.py
similarity index 100%
rename from surfsense_mcp/src/surfsense_mcp/features/knowledge_base/note_ingestion.py
rename to surfsense_mcp/mcp_server/features/knowledge_base/note_ingestion.py
diff --git a/surfsense_mcp/mcp_server/features/knowledge_base/search_tools.py b/surfsense_mcp/mcp_server/features/knowledge_base/search_tools.py
new file mode 100644
index 000000000..a9e60810d
--- /dev/null
+++ b/surfsense_mcp/mcp_server/features/knowledge_base/search_tools.py
@@ -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}"
+ )
diff --git a/surfsense_mcp/mcp_server/features/scrapers/__init__.py b/surfsense_mcp/mcp_server/features/scrapers/__init__.py
new file mode 100644
index 000000000..dfa2f3ab2
--- /dev/null
+++ b/surfsense_mcp/mcp_server/features/scrapers/__init__.py
@@ -0,0 +1,26 @@
+"""Scraper tools: one MCP surface per SurfSense platform capability.
+
+Web crawl, Google Search, Reddit, YouTube, and Google Maps each get a tool that
+maps a natural-language request to the workspace's scraper. Two run-history tools
+list and fetch past runs, so a large result truncated inline can be retrieved in
+full later. Each platform lives in its own module under platforms/.
+"""
+
+from __future__ import annotations
+
+from mcp.server.fastmcp import FastMCP
+
+from ...core.client import SurfSenseClient
+from ...core.workspace_context import WorkspaceContext
+from . import run_history
+from .platforms import google_maps, google_search, reddit, web, youtube
+
+_REGISTRARS = (web, google_search, reddit, youtube, google_maps, run_history)
+
+
+def register(
+ mcp: FastMCP, client: SurfSenseClient, context: WorkspaceContext
+) -> None:
+ """Register every scraper and run-history tool on the server."""
+ for module in _REGISTRARS:
+ module.register(mcp, client, context)
diff --git a/surfsense_mcp/mcp_server/features/scrapers/annotations.py b/surfsense_mcp/mcp_server/features/scrapers/annotations.py
new file mode 100644
index 000000000..cb872cc90
--- /dev/null
+++ b/surfsense_mcp/mcp_server/features/scrapers/annotations.py
@@ -0,0 +1,13 @@
+"""Tool-call policy hints shared across scraper tools."""
+
+from __future__ import annotations
+
+from mcp.types import ToolAnnotations
+
+SCRAPE = ToolAnnotations(
+ readOnlyHint=False, destructiveHint=False, idempotentHint=False, openWorldHint=True
+)
+
+READ_RUNS = ToolAnnotations(
+ readOnlyHint=True, destructiveHint=False, idempotentHint=True, openWorldHint=False
+)
diff --git a/surfsense_mcp/src/surfsense_mcp/features/scrapers/capability.py b/surfsense_mcp/mcp_server/features/scrapers/capability.py
similarity index 100%
rename from surfsense_mcp/src/surfsense_mcp/features/scrapers/capability.py
rename to surfsense_mcp/mcp_server/features/scrapers/capability.py
diff --git a/surfsense_mcp/mcp_server/features/scrapers/platforms/__init__.py b/surfsense_mcp/mcp_server/features/scrapers/platforms/__init__.py
new file mode 100644
index 000000000..f61704380
--- /dev/null
+++ b/surfsense_mcp/mcp_server/features/scrapers/platforms/__init__.py
@@ -0,0 +1 @@
+"""One module per scraper platform; each exposes register(mcp, client, context)."""
diff --git a/surfsense_mcp/mcp_server/features/scrapers/platforms/google_maps.py b/surfsense_mcp/mcp_server/features/scrapers/platforms/google_maps.py
new file mode 100644
index 000000000..e1613ca4e
--- /dev/null
+++ b/surfsense_mcp/mcp_server/features/scrapers/platforms/google_maps.py
@@ -0,0 +1,151 @@
+"""Google Maps scraper tools: places and reviews."""
+
+from __future__ import annotations
+
+from typing import Annotated, Literal
+
+from mcp.server.fastmcp import FastMCP
+from pydantic import Field
+
+from ....core.client import SurfSenseClient
+from ....core.rendering import ResponseFormatParam
+from ....core.workspace_context import WorkspaceContext, WorkspaceParam
+from ..annotations import SCRAPE
+from ..capability import run_scraper
+
+ReviewSort = Literal["newest", "mostRelevant", "highestRanking", "lowestRanking"]
+
+
+def register(
+ mcp: FastMCP, client: SurfSenseClient, context: WorkspaceContext
+) -> None:
+ """Register the Google Maps place and review tools."""
+
+ @mcp.tool(
+ name="surfsense_google_maps_scrape",
+ title="Find places on Google Maps",
+ annotations=SCRAPE,
+ structured_output=False,
+ )
+ async def google_maps_scrape(
+ search_queries: Annotated[
+ list[str] | None,
+ Field(
+ description="Place searches, e.g. ['coffee shops']. Provide "
+ "search_queries OR urls OR place_ids."
+ ),
+ ] = None,
+ urls: Annotated[
+ list[str] | None,
+ Field(description="Google Maps URLs of specific places."),
+ ] = None,
+ place_ids: Annotated[
+ list[str] | None,
+ Field(description="Google place ids, e.g. ['ChIJj61dQgK6j4AR...']."),
+ ] = None,
+ location: Annotated[
+ str | None,
+ Field(
+ description="Geographic scope for a search, e.g. "
+ "'Seattle, USA'."
+ ),
+ ] = None,
+ max_places: Annotated[
+ int, Field(ge=1, description="Maximum places to return.")
+ ] = 10,
+ include_details: Annotated[
+ bool,
+ Field(
+ description="True adds opening hours and extra contact info "
+ "(slower)."
+ ),
+ ] = False,
+ workspace: WorkspaceParam = None,
+ response_format: ResponseFormatParam = "markdown",
+ ) -> str:
+ """Find places on Google Maps by search, URL, or place id.
+
+ Use this for local-business and location research: names, addresses,
+ ratings, categories, coordinates, place ids. For a place's customer
+ reviews use surfsense_google_maps_reviews instead.
+ Example: search_queries=['ramen'], location='Osaka, Japan', max_places=5.
+ """
+ return await run_scraper(
+ client,
+ context,
+ platform="google_maps",
+ verb="scrape",
+ payload={
+ "search_queries": search_queries,
+ "urls": urls,
+ "place_ids": place_ids,
+ "location": location,
+ "max_places": max_places,
+ "include_details": include_details,
+ },
+ workspace=workspace,
+ response_format=response_format,
+ )
+
+ @mcp.tool(
+ name="surfsense_google_maps_reviews",
+ title="Fetch Google Maps reviews",
+ annotations=SCRAPE,
+ structured_output=False,
+ )
+ async def google_maps_reviews(
+ urls: Annotated[
+ list[str] | None,
+ Field(
+ description="Google Maps URLs of places. Provide urls OR "
+ "place_ids."
+ ),
+ ] = None,
+ place_ids: Annotated[
+ list[str] | None,
+ Field(
+ description="Google place ids from surfsense_google_maps_scrape."
+ ),
+ ] = None,
+ max_reviews: Annotated[
+ int, Field(ge=1, description="Maximum reviews per place.")
+ ] = 20,
+ sort_by: Annotated[
+ ReviewSort, Field(description="Review ordering.")
+ ] = "newest",
+ language: Annotated[
+ str, Field(description="Reviews language code, e.g. 'en'.")
+ ] = "en",
+ start_date: Annotated[
+ str | None,
+ Field(
+ description="ISO date like '2026-01-01'; keeps only reviews on "
+ "or after that day."
+ ),
+ ] = None,
+ workspace: WorkspaceParam = None,
+ response_format: ResponseFormatParam = "markdown",
+ ) -> str:
+ """Fetch customer reviews for Google Maps places by URL or place id.
+
+ Use this to read feedback on specific places; get urls or place_ids
+ from surfsense_google_maps_scrape first if you only have a name.
+ Returns review text, rating, author, and date per review.
+ Example: place_ids=['ChIJj61dQgK6j4AR...'], sort_by='newest'.
+ """
+ return await run_scraper(
+ client,
+ context,
+ platform="google_maps",
+ verb="reviews",
+ payload={
+ "urls": urls,
+ "place_ids": place_ids,
+ "max_reviews": max_reviews,
+ "sort_by": sort_by,
+ "language": language,
+ "start_date": start_date,
+ },
+ workspace=workspace,
+ response_format=response_format,
+ )
diff --git a/surfsense_mcp/mcp_server/features/scrapers/platforms/google_search.py b/surfsense_mcp/mcp_server/features/scrapers/platforms/google_search.py
new file mode 100644
index 000000000..cc1a1f8ed
--- /dev/null
+++ b/surfsense_mcp/mcp_server/features/scrapers/platforms/google_search.py
@@ -0,0 +1,79 @@
+"""Google Search scraper tool."""
+
+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
+from ....core.workspace_context import WorkspaceContext, WorkspaceParam
+from ..annotations import SCRAPE
+from ..capability import run_scraper
+
+
+def register(
+ mcp: FastMCP, client: SurfSenseClient, context: WorkspaceContext
+) -> None:
+ """Register the Google Search tool."""
+
+ @mcp.tool(
+ name="surfsense_google_search",
+ title="Scrape Google Search",
+ annotations=SCRAPE,
+ structured_output=False,
+ )
+ async def google_search(
+ queries: Annotated[
+ list[str],
+ Field(
+ min_length=1,
+ description="Search terms or full Google Search URLs, e.g. "
+ "['best rss readers 2026'].",
+ ),
+ ],
+ max_pages_per_query: Annotated[
+ int, Field(ge=1, description="Result pages to fetch per query.")
+ ] = 1,
+ country_code: Annotated[
+ str | None,
+ Field(description="Two-letter country to search from, e.g. 'us'."),
+ ] = None,
+ language_code: Annotated[
+ str, Field(description="Results language, e.g. 'en'. Empty for default.")
+ ] = "",
+ site: Annotated[
+ str | None,
+ Field(
+ description="Restrict results to one domain, e.g. 'example.com'."
+ ),
+ ] = None,
+ workspace: WorkspaceParam = None,
+ response_format: ResponseFormatParam = "markdown",
+ ) -> str:
+ """Scrape Google Search result pages for one or more queries.
+
+ Use this to discover pages on the open web by topic; follow up with
+ surfsense_web_crawl to read a result in full. Do NOT use it for
+ Reddit, YouTube, or Google Maps research — the dedicated tools return
+ richer data. Returns each query's parsed results: title, url, and
+ snippet per organic result.
+ Example: queries=['notebooklm review'], site='news.ycombinator.com'.
+ """
+ return await run_scraper(
+ client,
+ context,
+ platform="google_search",
+ verb="scrape",
+ payload={
+ "queries": queries,
+ "max_pages_per_query": max_pages_per_query,
+ "country_code": country_code,
+ "language_code": language_code,
+ "site": site,
+ },
+ workspace=workspace,
+ response_format=response_format,
+ )
diff --git a/surfsense_mcp/mcp_server/features/scrapers/platforms/reddit.py b/surfsense_mcp/mcp_server/features/scrapers/platforms/reddit.py
new file mode 100644
index 000000000..035193ebc
--- /dev/null
+++ b/surfsense_mcp/mcp_server/features/scrapers/platforms/reddit.py
@@ -0,0 +1,98 @@
+"""Reddit scraper tool."""
+
+from __future__ import annotations
+
+from typing import Annotated, Literal
+
+from mcp.server.fastmcp import FastMCP
+from pydantic import Field
+
+from ....core.client import SurfSenseClient
+from ....core.rendering import ResponseFormatParam
+from ....core.workspace_context import WorkspaceContext, WorkspaceParam
+from ..annotations import SCRAPE
+from ..capability import run_scraper
+
+RedditSort = Literal["relevance", "hot", "top", "new", "rising", "comments"]
+RedditTime = Literal["hour", "day", "week", "month", "year", "all"]
+
+
+def register(
+ mcp: FastMCP, client: SurfSenseClient, context: WorkspaceContext
+) -> None:
+ """Register the Reddit tool."""
+
+ @mcp.tool(
+ name="surfsense_reddit_scrape",
+ title="Search or scrape Reddit",
+ annotations=SCRAPE,
+ structured_output=False,
+ )
+ async def reddit_scrape(
+ urls: Annotated[
+ list[str] | None,
+ Field(
+ description="Reddit URLs: a post, a subreddit like "
+ "'https://reddit.com/r/LocalLLaMA', a user page, or a search "
+ "URL. Provide urls OR search_queries."
+ ),
+ ] = None,
+ search_queries: Annotated[
+ list[str] | None,
+ Field(
+ description="Terms to search Reddit for, e.g. "
+ "['NotebookLM alternatives']. Provide search_queries OR urls."
+ ),
+ ] = None,
+ community: Annotated[
+ str | None,
+ Field(
+ description="Restrict a search to one subreddit, name without "
+ "'r/', e.g. 'ArtificialInteligence'."
+ ),
+ ] = None,
+ sort: Annotated[RedditSort, Field(description="Post ordering.")] = "new",
+ time_filter: Annotated[
+ RedditTime | None,
+ Field(description="Time window; only valid with sort='top'."),
+ ] = None,
+ max_items: Annotated[
+ int, Field(ge=1, description="Maximum posts to return.")
+ ] = 10,
+ skip_comments: Annotated[
+ bool,
+ Field(
+ description="True fetches posts only (faster); False also "
+ "fetches each post's comment thread."
+ ),
+ ] = False,
+ workspace: WorkspaceParam = None,
+ response_format: ResponseFormatParam = "markdown",
+ ) -> str:
+ """Search or scrape Reddit: posts, comments, subreddits, and users.
+
+ Use this for ANY Reddit research — finding relevant subreddits or
+ communities for a topic, top posts, or discussions — instead of a
+ generic web search. Returns posts (title, text, score, subreddit, url)
+ with comment threads unless skip_comments is set. Every post carries
+ its subreddit, so to find communities for a topic, search posts and
+ aggregate their subreddits.
+ Example: search_queries=['NotebookLM'], sort='top', time_filter='month'.
+ """
+ return await run_scraper(
+ client,
+ context,
+ platform="reddit",
+ verb="scrape",
+ payload={
+ "urls": urls,
+ "search_queries": search_queries,
+ "community": community,
+ "sort": sort,
+ "time_filter": time_filter,
+ "max_items": max_items,
+ "skip_comments": skip_comments,
+ },
+ workspace=workspace,
+ response_format=response_format,
+ )
diff --git a/surfsense_mcp/mcp_server/features/scrapers/platforms/web.py b/surfsense_mcp/mcp_server/features/scrapers/platforms/web.py
new file mode 100644
index 000000000..9c24a4352
--- /dev/null
+++ b/surfsense_mcp/mcp_server/features/scrapers/platforms/web.py
@@ -0,0 +1,89 @@
+"""Web crawl scraper tool."""
+
+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
+from ....core.workspace_context import WorkspaceContext, WorkspaceParam
+from ..annotations import SCRAPE
+from ..capability import run_scraper
+
+
+def register(
+ mcp: FastMCP, client: SurfSenseClient, context: WorkspaceContext
+) -> None:
+ """Register the web crawl tool."""
+
+ @mcp.tool(
+ name="surfsense_web_crawl",
+ title="Crawl web pages",
+ annotations=SCRAPE,
+ structured_output=False,
+ )
+ async def web_crawl(
+ start_urls: Annotated[
+ list[str],
+ Field(
+ min_length=1,
+ description="Full URLs to fetch, e.g. "
+ "['https://example.com/blog/post'].",
+ ),
+ ],
+ max_crawl_depth: Annotated[
+ int,
+ Field(
+ ge=0,
+ description="Link-hops to follow from start_urls within the "
+ "same site. 0 fetches only start_urls.",
+ ),
+ ] = 0,
+ max_crawl_pages: Annotated[
+ int, Field(ge=1, description="Stop after this many pages in total.")
+ ] = 10,
+ max_length: Annotated[
+ int, Field(ge=1, description="Max characters kept per page.")
+ ] = 50_000,
+ include_url_patterns: Annotated[
+ list[str] | None,
+ Field(
+ description="Regexes; only discovered links matching one are "
+ "followed, e.g. ['/docs/.*']."
+ ),
+ ] = None,
+ exclude_url_patterns: Annotated[
+ list[str] | None,
+ Field(description="Regexes; discovered links matching one are skipped."),
+ ] = None,
+ workspace: WorkspaceParam = None,
+ response_format: ResponseFormatParam = "markdown",
+ ) -> str:
+ """Fetch specific web pages and return their cleaned content as markdown.
+
+ Use this to read a page the user names, or to spider a site from a
+ starting URL. Do NOT use it to find pages on a topic — use
+ surfsense_google_search for discovery. Returns one item per crawled
+ page: url, title, and the page text as markdown.
+ Example: start_urls=['https://blog.example.com'], max_crawl_depth=1,
+ include_url_patterns=['/2026/'].
+ """
+ return await run_scraper(
+ client,
+ context,
+ platform="web",
+ verb="crawl",
+ payload={
+ "startUrls": start_urls,
+ "maxCrawlDepth": max_crawl_depth,
+ "maxCrawlPages": max_crawl_pages,
+ "maxLength": max_length,
+ "includeUrlPatterns": include_url_patterns,
+ "excludeUrlPatterns": exclude_url_patterns,
+ },
+ workspace=workspace,
+ response_format=response_format,
+ )
diff --git a/surfsense_mcp/mcp_server/features/scrapers/platforms/youtube.py b/surfsense_mcp/mcp_server/features/scrapers/platforms/youtube.py
new file mode 100644
index 000000000..5582c82bb
--- /dev/null
+++ b/surfsense_mcp/mcp_server/features/scrapers/platforms/youtube.py
@@ -0,0 +1,131 @@
+"""YouTube scraper tools: videos and comments."""
+
+from __future__ import annotations
+
+from typing import Annotated, Literal
+
+from mcp.server.fastmcp import FastMCP
+from pydantic import Field
+
+from ....core.client import SurfSenseClient
+from ....core.rendering import ResponseFormatParam
+from ....core.workspace_context import WorkspaceContext, WorkspaceParam
+from ..annotations import SCRAPE
+from ..capability import run_scraper
+
+CommentSort = Literal["TOP_COMMENTS", "NEWEST_FIRST"]
+
+
+def register(
+ mcp: FastMCP, client: SurfSenseClient, context: WorkspaceContext
+) -> None:
+ """Register the YouTube video and comment tools."""
+
+ @mcp.tool(
+ name="surfsense_youtube_scrape",
+ title="Search or scrape YouTube",
+ annotations=SCRAPE,
+ structured_output=False,
+ )
+ async def youtube_scrape(
+ urls: Annotated[
+ list[str] | None,
+ Field(
+ description="YouTube URLs: video, channel, playlist, shorts, "
+ "or hashtag pages. Provide urls OR search_queries."
+ ),
+ ] = None,
+ search_queries: Annotated[
+ list[str] | None,
+ Field(
+ description="Terms to search YouTube for, e.g. "
+ "['NotebookLM tutorial']. Provide search_queries OR urls."
+ ),
+ ] = None,
+ max_results: Annotated[
+ int, Field(ge=1, description="Maximum videos to return.")
+ ] = 10,
+ download_subtitles: Annotated[
+ bool,
+ Field(description="True also fetches each video's transcript."),
+ ] = False,
+ subtitles_language: Annotated[
+ str, Field(description="Transcript language code, e.g. 'en'.")
+ ] = "en",
+ workspace: WorkspaceParam = None,
+ response_format: ResponseFormatParam = "markdown",
+ ) -> str:
+ """Search or scrape YouTube videos, optionally with transcripts.
+
+ Use this for YouTube research: finding videos on a topic, or reading a
+ video's details or transcript. For a video's comment section use
+ surfsense_youtube_comments instead. Returns per-video metadata (title,
+ channel, views, description, url) and, if requested, the transcript.
+ Example: search_queries=['NotebookLM tutorial'], download_subtitles=True.
+ """
+ return await run_scraper(
+ client,
+ context,
+ platform="youtube",
+ verb="scrape",
+ payload={
+ "urls": urls,
+ "search_queries": search_queries,
+ "max_results": max_results,
+ "download_subtitles": download_subtitles,
+ "subtitles_language": subtitles_language,
+ },
+ workspace=workspace,
+ response_format=response_format,
+ )
+
+ @mcp.tool(
+ name="surfsense_youtube_comments",
+ title="Fetch YouTube comments",
+ annotations=SCRAPE,
+ structured_output=False,
+ )
+ async def youtube_comments(
+ urls: Annotated[
+ list[str],
+ Field(
+ min_length=1,
+ description="YouTube video URLs, e.g. "
+ "['https://www.youtube.com/watch?v=abc123'].",
+ ),
+ ],
+ max_comments: Annotated[
+ int,
+ Field(
+ ge=1,
+ description="Maximum comments per video, counting top-level "
+ "comments and replies together.",
+ ),
+ ] = 20,
+ sort_by: Annotated[
+ CommentSort, Field(description="Comment ordering.")
+ ] = "NEWEST_FIRST",
+ workspace: WorkspaceParam = None,
+ response_format: ResponseFormatParam = "markdown",
+ ) -> str:
+ """Fetch the comments (and replies) on one or more YouTube videos.
+
+ Use this when the user wants a video's discussion or audience reaction
+ rather than the video itself; get video URLs from
+ surfsense_youtube_scrape if you only have a topic. Returns comment
+ text, author, likes, and replies.
+ Example: urls=['https://www.youtube.com/watch?v=abc123'], max_comments=50.
+ """
+ return await run_scraper(
+ client,
+ context,
+ platform="youtube",
+ verb="comments",
+ payload={
+ "urls": urls,
+ "max_comments": max_comments,
+ "sort_by": sort_by,
+ },
+ workspace=workspace,
+ response_format=response_format,
+ )
diff --git a/surfsense_mcp/mcp_server/features/scrapers/run_history.py b/surfsense_mcp/mcp_server/features/scrapers/run_history.py
new file mode 100644
index 000000000..9274a1a69
--- /dev/null
+++ b/surfsense_mcp/mcp_server/features/scrapers/run_history.py
@@ -0,0 +1,112 @@
+"""Scraper run history: list past runs and fetch one in full.
+
+A scrape whose inline result was truncated is retrievable here by run id, so the
+model never re-runs a scraper just to recover output.
+"""
+
+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_RUNS
+
+
+def register(
+ mcp: FastMCP, client: SurfSenseClient, context: WorkspaceContext
+) -> None:
+ """Register the run-history tools."""
+
+ @mcp.tool(
+ name="surfsense_list_scraper_runs",
+ title="List past scraper runs",
+ annotations=READ_RUNS,
+ structured_output=False,
+ )
+ async def list_scraper_runs(
+ limit: Annotated[
+ int, Field(ge=1, description="Maximum runs to list.")
+ ] = 20,
+ capability: Annotated[
+ str | None,
+ Field(
+ description="Filter by capability slug, e.g. 'web.crawl' or "
+ "'reddit.scrape'."
+ ),
+ ] = None,
+ status: Annotated[
+ str | None,
+ Field(description="Filter by run status: 'success' or 'error'."),
+ ] = None,
+ workspace: WorkspaceParam = None,
+ response_format: ResponseFormatParam = "markdown",
+ ) -> str:
+ """List recent scraper runs in the workspace, newest first.
+
+ Use this to find the run_id of an earlier scrape — for example when an
+ inline result was truncated — then fetch it in full with
+ surfsense_get_scraper_run. Returns each run's id, capability, status,
+ item count, and creation time.
+ Example: capability='reddit.scrape', status='success'.
+ """
+ resolved = await context.resolve(workspace)
+ runs = await client.request(
+ "GET",
+ f"/workspaces/{resolved.id}/scrapers/runs",
+ params={
+ "limit": limit,
+ "capability": capability,
+ "status": status,
+ },
+ )
+ if response_format == "json":
+ return to_json(runs)
+ return _render_runs(runs)
+
+ @mcp.tool(
+ name="surfsense_get_scraper_run",
+ title="Fetch one scraper run in full",
+ annotations=READ_RUNS,
+ structured_output=False,
+ )
+ async def get_scraper_run(
+ run_id: Annotated[
+ str,
+ Field(
+ description="Run id from surfsense_list_scraper_runs or a "
+ "prior scrape's output."
+ ),
+ ],
+ workspace: WorkspaceParam = None,
+ response_format: ResponseFormatParam = "markdown",
+ ) -> str:
+ """Fetch a single scraper run in full, including its stored output.
+
+ Use this to retrieve the complete, untruncated result of an earlier
+ scrape. Do NOT re-run a scraper just to recover a truncated result —
+ fetch the stored run instead.
+ """
+ resolved = await context.resolve(workspace)
+ run = await client.request(
+ "GET", f"/workspaces/{resolved.id}/scrapers/runs/{run_id}"
+ )
+ if response_format == "json":
+ return clip(to_json(run))
+ return f"# Run {run.get('id', run_id)}\n\n```json\n{clip(to_json(run))}\n```"
+
+
+def _render_runs(runs: list[dict] | None) -> str:
+ if not runs:
+ return "No scraper runs found."
+ lines = ["# Scraper runs", ""]
+ for run in runs:
+ lines.append(
+ f"- **{run.get('id')}** — {run.get('capability')} · {run.get('status')} · "
+ f"{run.get('item_count', 0)} item(s) · {run.get('created_at')}"
+ )
+ return "\n".join(lines)
diff --git a/surfsense_mcp/src/surfsense_mcp/features/workspaces/__init__.py b/surfsense_mcp/mcp_server/features/workspaces/__init__.py
similarity index 100%
rename from surfsense_mcp/src/surfsense_mcp/features/workspaces/__init__.py
rename to surfsense_mcp/mcp_server/features/workspaces/__init__.py
diff --git a/surfsense_mcp/src/surfsense_mcp/selfcheck.py b/surfsense_mcp/mcp_server/selfcheck.py
similarity index 98%
rename from surfsense_mcp/src/surfsense_mcp/selfcheck.py
rename to surfsense_mcp/mcp_server/selfcheck.py
index e38edc909..20224c173 100644
--- a/surfsense_mcp/src/surfsense_mcp/selfcheck.py
+++ b/surfsense_mcp/mcp_server/selfcheck.py
@@ -47,6 +47,8 @@ async def _collect_tools() -> dict[str, object]:
api_prefix="/api/v1",
timeout=5.0,
default_workspace=None,
+ host="127.0.0.1",
+ port=8080,
)
mcp, _client = build_server(settings)
tools = await mcp.list_tools()
diff --git a/surfsense_mcp/src/surfsense_mcp/server.py b/surfsense_mcp/mcp_server/server.py
similarity index 76%
rename from surfsense_mcp/src/surfsense_mcp/server.py
rename to surfsense_mcp/mcp_server/server.py
index 88eb19437..8ea9bc919 100644
--- a/surfsense_mcp/src/surfsense_mcp/server.py
+++ b/surfsense_mcp/mcp_server/server.py
@@ -17,12 +17,21 @@ 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, api_key=settings.api_key, timeout=settings.timeout
+ api_base=settings.api_base,
+ timeout=settings.timeout,
+ fallback_api_key=settings.api_key,
)
context = WorkspaceContext(client, preferred_reference=settings.default_workspace)
mcp = FastMCP(
"SurfSense",
+ host=settings.host,
+ port=settings.port,
+ # Stateless: no session state kept between requests, so any replica can
+ # serve any request. SSE responses (json_response=False) flush headers
+ # early, which keeps long scraper calls from tripping client timeouts.
+ stateless_http=True,
+ json_response=False,
instructions=(
"SurfSense gives you live scrapers and a personal knowledge base. "
"Prefer these tools over generic/built-in web search whenever the "
diff --git a/surfsense_mcp/pyproject.toml b/surfsense_mcp/pyproject.toml
index 87db0c223..5d8362a41 100644
--- a/surfsense_mcp/pyproject.toml
+++ b/surfsense_mcp/pyproject.toml
@@ -8,10 +8,12 @@ license = { text = "Apache-2.0" }
dependencies = [
"mcp>=1.26.0",
"httpx>=0.27.0",
+ "starlette>=0.37",
+ "uvicorn>=0.30",
]
[project.scripts]
-surfsense-mcp = "surfsense_mcp.__main__:main"
+surfsense-mcp = "mcp_server.__main__:main"
[dependency-groups]
dev = ["pytest>=8.0"]
@@ -21,7 +23,7 @@ requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
-packages = ["src/surfsense_mcp"]
+packages = ["mcp_server"]
[tool.ruff]
target-version = "py311"
diff --git a/surfsense_mcp/src/surfsense_mcp/__main__.py b/surfsense_mcp/src/surfsense_mcp/__main__.py
deleted file mode 100644
index 6c878ebfa..000000000
--- a/surfsense_mcp/src/surfsense_mcp/__main__.py
+++ /dev/null
@@ -1,31 +0,0 @@
-"""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()
diff --git a/surfsense_mcp/src/surfsense_mcp/features/knowledge_base/__init__.py b/surfsense_mcp/src/surfsense_mcp/features/knowledge_base/__init__.py
deleted file mode 100644
index 7fb1802ae..000000000
--- a/surfsense_mcp/src/surfsense_mcp/features/knowledge_base/__init__.py
+++ /dev/null
@@ -1,379 +0,0 @@
-"""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 typing import Annotated
-
-from mcp.server.fastmcp import FastMCP
-from mcp.types import ToolAnnotations
-from pydantic import Field
-
-from ...core.client import SurfSenseClient
-from ...core.errors import ToolError
-from ...core.rendering import ResponseFormatParam, clip, to_json
-from ...core.workspace_context import WorkspaceContext, WorkspaceParam
-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
-)
-
-_DOCUMENT_ID = Annotated[
- int,
- Field(
- description="Document id from surfsense_search_knowledge_base or "
- "surfsense_list_documents results."
- ),
-]
-
-_DOCUMENT_TYPES = Annotated[
- list[str] | None,
- Field(
- description="Restrict to these document types, e.g. "
- "['FILE', 'CRAWLED_URL', 'YOUTUBE_VIDEO']. Omit for all types."
- ),
-]
-
-
-def register(
- mcp: FastMCP, client: SurfSenseClient, context: WorkspaceContext
-) -> None:
- """Register the knowledge-base tools on the server."""
-
- @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: _DOCUMENT_TYPES = 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: _DOCUMENT_TYPES = 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: _DOCUMENT_ID,
- 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)
-
- @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: _DOCUMENT_ID,
- 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: _DOCUMENT_ID) -> 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")
-
-
-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}"
- )
diff --git a/surfsense_mcp/src/surfsense_mcp/features/scrapers/__init__.py b/surfsense_mcp/src/surfsense_mcp/features/scrapers/__init__.py
deleted file mode 100644
index aa8265148..000000000
--- a/surfsense_mcp/src/surfsense_mcp/features/scrapers/__init__.py
+++ /dev/null
@@ -1,570 +0,0 @@
-"""Scraper tools: one MCP surface per SurfSense platform capability.
-
-Web crawl, Google Search, Reddit, YouTube, and Google Maps each get a tool that
-maps a natural-language request to the workspace's scraper door. Two more tools
-list and fetch past runs, so a large result truncated inline can be retrieved in
-full later.
-"""
-
-from __future__ import annotations
-
-from typing import Annotated, Literal
-
-from mcp.server.fastmcp import FastMCP
-from mcp.types import ToolAnnotations
-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 .capability import run_scraper
-
-# Scrapers reach the open web and record a billable run; they are neither
-# read-only nor idempotent, but they do not mutate the knowledge base.
-_SCRAPE = ToolAnnotations(
- readOnlyHint=False, destructiveHint=False, idempotentHint=False, openWorldHint=True
-)
-_READ_RUNS = ToolAnnotations(
- readOnlyHint=True, destructiveHint=False, idempotentHint=True, openWorldHint=False
-)
-
-RedditSort = Literal["relevance", "hot", "top", "new", "rising", "comments"]
-RedditTime = Literal["hour", "day", "week", "month", "year", "all"]
-CommentSort = Literal["TOP_COMMENTS", "NEWEST_FIRST"]
-ReviewSort = Literal["newest", "mostRelevant", "highestRanking", "lowestRanking"]
-
-
-def register(
- mcp: FastMCP, client: SurfSenseClient, context: WorkspaceContext
-) -> None:
- """Register the scraper and run-history tools on the server."""
-
- @mcp.tool(
- name="surfsense_web_crawl",
- title="Crawl web pages",
- annotations=_SCRAPE,
- structured_output=False,
- )
- async def web_crawl(
- start_urls: Annotated[
- list[str],
- Field(
- min_length=1,
- description="Full URLs to fetch, e.g. "
- "['https://example.com/blog/post'].",
- ),
- ],
- max_crawl_depth: Annotated[
- int,
- Field(
- ge=0,
- description="Link-hops to follow from start_urls within the "
- "same site. 0 fetches only start_urls.",
- ),
- ] = 0,
- max_crawl_pages: Annotated[
- int, Field(ge=1, description="Stop after this many pages in total.")
- ] = 10,
- max_length: Annotated[
- int, Field(ge=1, description="Max characters kept per page.")
- ] = 50_000,
- include_url_patterns: Annotated[
- list[str] | None,
- Field(
- description="Regexes; only discovered links matching one are "
- "followed, e.g. ['/docs/.*']."
- ),
- ] = None,
- exclude_url_patterns: Annotated[
- list[str] | None,
- Field(description="Regexes; discovered links matching one are skipped."),
- ] = None,
- workspace: WorkspaceParam = None,
- response_format: ResponseFormatParam = "markdown",
- ) -> str:
- """Fetch specific web pages and return their cleaned content as markdown.
-
- Use this to read a page the user names, or to spider a site from a
- starting URL. Do NOT use it to find pages on a topic — use
- surfsense_google_search for discovery. Returns one item per crawled
- page: url, title, and the page text as markdown.
- Example: start_urls=['https://blog.example.com'], max_crawl_depth=1,
- include_url_patterns=['/2026/'].
- """
- return await run_scraper(
- client,
- context,
- platform="web",
- verb="crawl",
- payload={
- "startUrls": start_urls,
- "maxCrawlDepth": max_crawl_depth,
- "maxCrawlPages": max_crawl_pages,
- "maxLength": max_length,
- "includeUrlPatterns": include_url_patterns,
- "excludeUrlPatterns": exclude_url_patterns,
- },
- workspace=workspace,
- response_format=response_format,
- )
-
- @mcp.tool(
- name="surfsense_google_search",
- title="Scrape Google Search",
- annotations=_SCRAPE,
- structured_output=False,
- )
- async def google_search(
- queries: Annotated[
- list[str],
- Field(
- min_length=1,
- description="Search terms or full Google Search URLs, e.g. "
- "['best rss readers 2026'].",
- ),
- ],
- max_pages_per_query: Annotated[
- int, Field(ge=1, description="Result pages to fetch per query.")
- ] = 1,
- country_code: Annotated[
- str | None,
- Field(description="Two-letter country to search from, e.g. 'us'."),
- ] = None,
- language_code: Annotated[
- str, Field(description="Results language, e.g. 'en'. Empty for default.")
- ] = "",
- site: Annotated[
- str | None,
- Field(
- description="Restrict results to one domain, e.g. 'example.com'."
- ),
- ] = None,
- workspace: WorkspaceParam = None,
- response_format: ResponseFormatParam = "markdown",
- ) -> str:
- """Scrape Google Search result pages for one or more queries.
-
- Use this to discover pages on the open web by topic; follow up with
- surfsense_web_crawl to read a result in full. Do NOT use it for
- Reddit, YouTube, or Google Maps research — the dedicated tools return
- richer data. Returns each query's parsed results: title, url, and
- snippet per organic result.
- Example: queries=['notebooklm review'], site='news.ycombinator.com'.
- """
- return await run_scraper(
- client,
- context,
- platform="google_search",
- verb="scrape",
- payload={
- "queries": queries,
- "max_pages_per_query": max_pages_per_query,
- "country_code": country_code,
- "language_code": language_code,
- "site": site,
- },
- workspace=workspace,
- response_format=response_format,
- )
-
- @mcp.tool(
- name="surfsense_reddit_scrape",
- title="Search or scrape Reddit",
- annotations=_SCRAPE,
- structured_output=False,
- )
- async def reddit_scrape(
- urls: Annotated[
- list[str] | None,
- Field(
- description="Reddit URLs: a post, a subreddit like "
- "'https://reddit.com/r/LocalLLaMA', a user page, or a search "
- "URL. Provide urls OR search_queries."
- ),
- ] = None,
- search_queries: Annotated[
- list[str] | None,
- Field(
- description="Terms to search Reddit for, e.g. "
- "['NotebookLM alternatives']. Provide search_queries OR urls."
- ),
- ] = None,
- community: Annotated[
- str | None,
- Field(
- description="Restrict a search to one subreddit, name without "
- "'r/', e.g. 'ArtificialInteligence'."
- ),
- ] = None,
- sort: Annotated[RedditSort, Field(description="Post ordering.")] = "new",
- time_filter: Annotated[
- RedditTime | None,
- Field(description="Time window; only valid with sort='top'."),
- ] = None,
- max_items: Annotated[
- int, Field(ge=1, description="Maximum posts to return.")
- ] = 10,
- skip_comments: Annotated[
- bool,
- Field(
- description="True fetches posts only (faster); False also "
- "fetches each post's comment thread."
- ),
- ] = False,
- workspace: WorkspaceParam = None,
- response_format: ResponseFormatParam = "markdown",
- ) -> str:
- """Search or scrape Reddit: posts, comments, subreddits, and users.
-
- Use this for ANY Reddit research — finding relevant subreddits or
- communities for a topic, top posts, or discussions — instead of a
- generic web search. Returns posts (title, text, score, subreddit, url)
- with comment threads unless skip_comments is set. Every post carries
- its subreddit, so to find communities for a topic, search posts and
- aggregate their subreddits.
- Example: search_queries=['NotebookLM'], sort='top', time_filter='month'.
- """
- return await run_scraper(
- client,
- context,
- platform="reddit",
- verb="scrape",
- payload={
- "urls": urls,
- "search_queries": search_queries,
- "community": community,
- "sort": sort,
- "time_filter": time_filter,
- "max_items": max_items,
- "skip_comments": skip_comments,
- },
- workspace=workspace,
- response_format=response_format,
- )
-
- @mcp.tool(
- name="surfsense_youtube_scrape",
- title="Search or scrape YouTube",
- annotations=_SCRAPE,
- structured_output=False,
- )
- async def youtube_scrape(
- urls: Annotated[
- list[str] | None,
- Field(
- description="YouTube URLs: video, channel, playlist, shorts, "
- "or hashtag pages. Provide urls OR search_queries."
- ),
- ] = None,
- search_queries: Annotated[
- list[str] | None,
- Field(
- description="Terms to search YouTube for, e.g. "
- "['NotebookLM tutorial']. Provide search_queries OR urls."
- ),
- ] = None,
- max_results: Annotated[
- int, Field(ge=1, description="Maximum videos to return.")
- ] = 10,
- download_subtitles: Annotated[
- bool,
- Field(description="True also fetches each video's transcript."),
- ] = False,
- subtitles_language: Annotated[
- str, Field(description="Transcript language code, e.g. 'en'.")
- ] = "en",
- workspace: WorkspaceParam = None,
- response_format: ResponseFormatParam = "markdown",
- ) -> str:
- """Search or scrape YouTube videos, optionally with transcripts.
-
- Use this for YouTube research: finding videos on a topic, or reading a
- video's details or transcript. For a video's comment section use
- surfsense_youtube_comments instead. Returns per-video metadata (title,
- channel, views, description, url) and, if requested, the transcript.
- Example: search_queries=['NotebookLM tutorial'], download_subtitles=True.
- """
- return await run_scraper(
- client,
- context,
- platform="youtube",
- verb="scrape",
- payload={
- "urls": urls,
- "search_queries": search_queries,
- "max_results": max_results,
- "download_subtitles": download_subtitles,
- "subtitles_language": subtitles_language,
- },
- workspace=workspace,
- response_format=response_format,
- )
-
- @mcp.tool(
- name="surfsense_youtube_comments",
- title="Fetch YouTube comments",
- annotations=_SCRAPE,
- structured_output=False,
- )
- async def youtube_comments(
- urls: Annotated[
- list[str],
- Field(
- min_length=1,
- description="YouTube video URLs, e.g. "
- "['https://www.youtube.com/watch?v=abc123'].",
- ),
- ],
- max_comments: Annotated[
- int,
- Field(
- ge=1,
- description="Maximum comments per video, counting top-level "
- "comments and replies together.",
- ),
- ] = 20,
- sort_by: Annotated[
- CommentSort, Field(description="Comment ordering.")
- ] = "NEWEST_FIRST",
- workspace: WorkspaceParam = None,
- response_format: ResponseFormatParam = "markdown",
- ) -> str:
- """Fetch the comments (and replies) on one or more YouTube videos.
-
- Use this when the user wants a video's discussion or audience reaction
- rather than the video itself; get video URLs from
- surfsense_youtube_scrape if you only have a topic. Returns comment
- text, author, likes, and replies.
- Example: urls=['https://www.youtube.com/watch?v=abc123'], max_comments=50.
- """
- return await run_scraper(
- client,
- context,
- platform="youtube",
- verb="comments",
- payload={
- "urls": urls,
- "max_comments": max_comments,
- "sort_by": sort_by,
- },
- workspace=workspace,
- response_format=response_format,
- )
-
- @mcp.tool(
- name="surfsense_google_maps_scrape",
- title="Find places on Google Maps",
- annotations=_SCRAPE,
- structured_output=False,
- )
- async def google_maps_scrape(
- search_queries: Annotated[
- list[str] | None,
- Field(
- description="Place searches, e.g. ['coffee shops']. Provide "
- "search_queries OR urls OR place_ids."
- ),
- ] = None,
- urls: Annotated[
- list[str] | None,
- Field(description="Google Maps URLs of specific places."),
- ] = None,
- place_ids: Annotated[
- list[str] | None,
- Field(description="Google place ids, e.g. ['ChIJj61dQgK6j4AR...']."),
- ] = None,
- location: Annotated[
- str | None,
- Field(
- description="Geographic scope for a search, e.g. "
- "'Seattle, USA'."
- ),
- ] = None,
- max_places: Annotated[
- int, Field(ge=1, description="Maximum places to return.")
- ] = 10,
- include_details: Annotated[
- bool,
- Field(
- description="True adds opening hours and extra contact info "
- "(slower)."
- ),
- ] = False,
- workspace: WorkspaceParam = None,
- response_format: ResponseFormatParam = "markdown",
- ) -> str:
- """Find places on Google Maps by search, URL, or place id.
-
- Use this for local-business and location research: names, addresses,
- ratings, categories, coordinates, place ids. For a place's customer
- reviews use surfsense_google_maps_reviews instead.
- Example: search_queries=['ramen'], location='Osaka, Japan', max_places=5.
- """
- return await run_scraper(
- client,
- context,
- platform="google_maps",
- verb="scrape",
- payload={
- "search_queries": search_queries,
- "urls": urls,
- "place_ids": place_ids,
- "location": location,
- "max_places": max_places,
- "include_details": include_details,
- },
- workspace=workspace,
- response_format=response_format,
- )
-
- @mcp.tool(
- name="surfsense_google_maps_reviews",
- title="Fetch Google Maps reviews",
- annotations=_SCRAPE,
- structured_output=False,
- )
- async def google_maps_reviews(
- urls: Annotated[
- list[str] | None,
- Field(
- description="Google Maps URLs of places. Provide urls OR "
- "place_ids."
- ),
- ] = None,
- place_ids: Annotated[
- list[str] | None,
- Field(
- description="Google place ids from surfsense_google_maps_scrape."
- ),
- ] = None,
- max_reviews: Annotated[
- int, Field(ge=1, description="Maximum reviews per place.")
- ] = 20,
- sort_by: Annotated[
- ReviewSort, Field(description="Review ordering.")
- ] = "newest",
- language: Annotated[
- str, Field(description="Reviews language code, e.g. 'en'.")
- ] = "en",
- start_date: Annotated[
- str | None,
- Field(
- description="ISO date like '2026-01-01'; keeps only reviews on "
- "or after that day."
- ),
- ] = None,
- workspace: WorkspaceParam = None,
- response_format: ResponseFormatParam = "markdown",
- ) -> str:
- """Fetch customer reviews for Google Maps places by URL or place id.
-
- Use this to read feedback on specific places; get urls or place_ids
- from surfsense_google_maps_scrape first if you only have a name.
- Returns review text, rating, author, and date per review.
- Example: place_ids=['ChIJj61dQgK6j4AR...'], sort_by='newest'.
- """
- return await run_scraper(
- client,
- context,
- platform="google_maps",
- verb="reviews",
- payload={
- "urls": urls,
- "place_ids": place_ids,
- "max_reviews": max_reviews,
- "sort_by": sort_by,
- "language": language,
- "start_date": start_date,
- },
- workspace=workspace,
- response_format=response_format,
- )
-
- @mcp.tool(
- name="surfsense_list_scraper_runs",
- title="List past scraper runs",
- annotations=_READ_RUNS,
- structured_output=False,
- )
- async def list_scraper_runs(
- limit: Annotated[
- int, Field(ge=1, description="Maximum runs to list.")
- ] = 20,
- capability: Annotated[
- str | None,
- Field(
- description="Filter by capability slug, e.g. 'web.crawl' or "
- "'reddit.scrape'."
- ),
- ] = None,
- status: Annotated[
- str | None,
- Field(description="Filter by run status: 'success' or 'error'."),
- ] = None,
- workspace: WorkspaceParam = None,
- response_format: ResponseFormatParam = "markdown",
- ) -> str:
- """List recent scraper runs in the workspace, newest first.
-
- Use this to find the run_id of an earlier scrape — for example when an
- inline result was truncated — then fetch it in full with
- surfsense_get_scraper_run. Returns each run's id, capability, status,
- item count, and creation time.
- Example: capability='reddit.scrape', status='success'.
- """
- resolved = await context.resolve(workspace)
- runs = await client.request(
- "GET",
- f"/workspaces/{resolved.id}/scrapers/runs",
- params={
- "limit": limit,
- "capability": capability,
- "status": status,
- },
- )
- if response_format == "json":
- return to_json(runs)
- return _render_runs(runs)
-
- @mcp.tool(
- name="surfsense_get_scraper_run",
- title="Fetch one scraper run in full",
- annotations=_READ_RUNS,
- structured_output=False,
- )
- async def get_scraper_run(
- run_id: Annotated[
- str,
- Field(
- description="Run id from surfsense_list_scraper_runs or a "
- "prior scrape's output."
- ),
- ],
- workspace: WorkspaceParam = None,
- response_format: ResponseFormatParam = "markdown",
- ) -> str:
- """Fetch a single scraper run in full, including its stored output.
-
- Use this to retrieve the complete, untruncated result of an earlier
- scrape. Do NOT re-run a scraper just to recover a truncated result —
- fetch the stored run instead.
- """
- resolved = await context.resolve(workspace)
- run = await client.request(
- "GET", f"/workspaces/{resolved.id}/scrapers/runs/{run_id}"
- )
- if response_format == "json":
- return clip(to_json(run))
- return f"# Run {run.get('id', run_id)}\n\n```json\n{clip(to_json(run))}\n```"
-
-
-def _render_runs(runs: list[dict] | None) -> str:
- if not runs:
- return "No scraper runs found."
- lines = ["# Scraper runs", ""]
- for run in runs:
- lines.append(
- f"- **{run.get('id')}** — {run.get('capability')} · {run.get('status')} · "
- f"{run.get('item_count', 0)} item(s) · {run.get('created_at')}"
- )
- return "\n".join(lines)
diff --git a/surfsense_mcp/tests/test_auth_headers.py b/surfsense_mcp/tests/test_auth_headers.py
new file mode 100644
index 000000000..feb21a9e1
--- /dev/null
+++ b/surfsense_mcp/tests/test_auth_headers.py
@@ -0,0 +1,40 @@
+"""API key extraction from request headers: Bearer, fallback, and rejection."""
+
+from __future__ import annotations
+
+from starlette.datastructures import Headers
+
+from mcp_server.core.auth.headers import extract_api_key
+
+
+def _headers(**pairs: str) -> Headers:
+ return Headers(pairs)
+
+
+def test_reads_bearer_token():
+ assert extract_api_key(_headers(authorization="Bearer ss_pat_abc")) == "ss_pat_abc"
+
+
+def test_bearer_scheme_is_case_insensitive():
+ assert extract_api_key(_headers(authorization="bearer ss_pat_abc")) == "ss_pat_abc"
+
+
+def test_falls_back_to_x_api_key():
+ assert extract_api_key(Headers({"x-api-key": "ss_pat_xyz"})) == "ss_pat_xyz"
+
+
+def test_bearer_wins_over_fallback():
+ headers = Headers({"authorization": "Bearer primary", "x-api-key": "secondary"})
+ assert extract_api_key(headers) == "primary"
+
+
+def test_missing_headers_return_none():
+ assert extract_api_key(_headers()) is None
+
+
+def test_empty_bearer_is_rejected():
+ assert extract_api_key(_headers(authorization="Bearer ")) is None
+
+
+def test_non_bearer_authorization_is_ignored():
+ assert extract_api_key(_headers(authorization="Basic abc123")) is None
diff --git a/surfsense_mcp/tests/test_client_errors.py b/surfsense_mcp/tests/test_client_errors.py
index 648bfde3e..3245f39b1 100644
--- a/surfsense_mcp/tests/test_client_errors.py
+++ b/surfsense_mcp/tests/test_client_errors.py
@@ -4,7 +4,7 @@ from __future__ import annotations
import httpx
-from surfsense_mcp.core.client import SurfSenseClient
+from mcp_server.core.client import SurfSenseClient
_REQUEST = httpx.Request("GET", "http://localhost:8000/api/v1/documents")
@@ -15,7 +15,7 @@ def _response(status: int, **kwargs) -> httpx.Response:
def test_explains_401_with_token_hint():
message = SurfSenseClient._explain_failure(_response(401, json={"detail": "bad"}))
- assert "SURFSENSE_API_KEY" in message
+ assert "API key" in message
assert "bad" in message
diff --git a/surfsense_mcp/tests/test_client_params.py b/surfsense_mcp/tests/test_client_params.py
index 4840ad488..c6b013d88 100644
--- a/surfsense_mcp/tests/test_client_params.py
+++ b/surfsense_mcp/tests/test_client_params.py
@@ -6,7 +6,7 @@ import asyncio
import httpx
-from surfsense_mcp.core.client import SurfSenseClient
+from mcp_server.core.client import SurfSenseClient
def _capture(client: SurfSenseClient) -> dict:
@@ -25,7 +25,9 @@ def _capture(client: SurfSenseClient) -> dict:
def test_none_params_are_dropped():
- client = SurfSenseClient(api_base="http://test/api/v1", api_key="ss_pat_x", timeout=5)
+ client = SurfSenseClient(
+ api_base="http://test/api/v1", timeout=5, fallback_api_key="ss_pat_x"
+ )
seen = _capture(client)
asyncio.run(
client.request(
diff --git a/surfsense_mcp/tests/test_note_ingestion.py b/surfsense_mcp/tests/test_note_ingestion.py
index 6ab16acfc..da980e5db 100644
--- a/surfsense_mcp/tests/test_note_ingestion.py
+++ b/surfsense_mcp/tests/test_note_ingestion.py
@@ -2,7 +2,7 @@
from __future__ import annotations
-from surfsense_mcp.features.knowledge_base.note_ingestion import build_note_document
+from mcp_server.features.knowledge_base.note_ingestion import build_note_document
def test_builds_extension_document_with_content():
diff --git a/surfsense_mcp/tests/test_rendering.py b/surfsense_mcp/tests/test_rendering.py
index 5b254e39a..fd5024f86 100644
--- a/surfsense_mcp/tests/test_rendering.py
+++ b/surfsense_mcp/tests/test_rendering.py
@@ -2,7 +2,7 @@
from __future__ import annotations
-from surfsense_mcp.core.rendering import clip, compact_items, to_json
+from mcp_server.core.rendering import clip, compact_items, to_json
def test_clip_leaves_short_text_untouched():
diff --git a/surfsense_mcp/tests/test_request_auth.py b/surfsense_mcp/tests/test_request_auth.py
new file mode 100644
index 000000000..088946196
--- /dev/null
+++ b/surfsense_mcp/tests/test_request_auth.py
@@ -0,0 +1,100 @@
+"""Per-request key resolution and the Authorization header the backend receives.
+
+Covers the security-critical behaviors: the per-request key wins over the env
+fallback, the fallback covers stdio, a missing key is refused, and concurrent
+callers never see each other's key.
+"""
+
+from __future__ import annotations
+
+import asyncio
+
+import httpx
+import pytest
+
+from mcp_server.core.auth import identity
+from mcp_server.core.client import SurfSenseClient
+from mcp_server.core.errors import ToolError
+
+
+def _client_recording_auth(seen: dict, *, fallback: str | None) -> SurfSenseClient:
+ async def handler(request: httpx.Request) -> httpx.Response:
+ seen["authorization"] = request.headers.get("authorization")
+ return httpx.Response(200, json={"ok": True})
+
+ client = SurfSenseClient(
+ api_base="http://test/api/v1", timeout=5, fallback_api_key=fallback
+ )
+ client._http = httpx.AsyncClient(
+ base_url="http://test/api/v1", transport=httpx.MockTransport(handler)
+ )
+ return client
+
+
+async def _get(client: SurfSenseClient) -> None:
+ await client.request("GET", "/workspaces")
+
+
+def test_request_key_is_sent_as_bearer():
+ seen: dict = {}
+ client = _client_recording_auth(seen, fallback=None)
+
+ async def run() -> None:
+ token = identity.bind_api_key("ss_pat_request")
+ try:
+ await _get(client)
+ finally:
+ identity.unbind_api_key(token)
+
+ asyncio.run(run())
+ assert seen["authorization"] == "Bearer ss_pat_request"
+
+
+def test_request_key_overrides_env_fallback():
+ seen: dict = {}
+ client = _client_recording_auth(seen, fallback="ss_pat_env")
+
+ async def run() -> None:
+ token = identity.bind_api_key("ss_pat_request")
+ try:
+ await _get(client)
+ finally:
+ identity.unbind_api_key(token)
+
+ asyncio.run(run())
+ assert seen["authorization"] == "Bearer ss_pat_request"
+
+
+def test_env_fallback_used_without_request_key():
+ seen: dict = {}
+ client = _client_recording_auth(seen, fallback="ss_pat_env")
+ asyncio.run(_get(client))
+ assert seen["authorization"] == "Bearer ss_pat_env"
+
+
+def test_missing_key_is_refused():
+ client = _client_recording_auth({}, fallback=None)
+ with pytest.raises(ToolError):
+ asyncio.run(_get(client))
+
+
+def test_concurrent_callers_do_not_leak_keys():
+ seen_by_caller: dict[str, str | None] = {}
+
+ async def call_as(key: str) -> None:
+ # Each caller runs in its own task, so the contextvar is isolated.
+ recorded: dict = {}
+ client = _client_recording_auth(recorded, fallback=None)
+ token = identity.bind_api_key(key)
+ try:
+ await _get(client)
+ finally:
+ identity.unbind_api_key(token)
+ seen_by_caller[key] = recorded["authorization"]
+
+ async def run() -> None:
+ await asyncio.gather(call_as("ss_pat_A"), call_as("ss_pat_B"))
+
+ asyncio.run(run())
+ assert seen_by_caller["ss_pat_A"] == "Bearer ss_pat_A"
+ assert seen_by_caller["ss_pat_B"] == "Bearer ss_pat_B"
diff --git a/surfsense_mcp/tests/test_workspace_context.py b/surfsense_mcp/tests/test_workspace_context.py
index 9ba9c2ca3..6d5eace8f 100644
--- a/surfsense_mcp/tests/test_workspace_context.py
+++ b/surfsense_mcp/tests/test_workspace_context.py
@@ -6,8 +6,9 @@ import asyncio
import pytest
-from surfsense_mcp.core.errors import ToolError
-from surfsense_mcp.core.workspace_context import WorkspaceContext
+from mcp_server.core.auth import identity
+from mcp_server.core.errors import ToolError
+from mcp_server.core.workspace_context import WorkspaceContext
class FakeClient:
@@ -96,3 +97,30 @@ def test_resolution_is_remembered_as_active():
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
+
+
+def test_active_workspace_is_isolated_per_identity():
+ ctx = _context(_rows(("A", 1), ("B", 2)))
+
+ async def select_as(key: str, reference: str) -> None:
+ token = identity.bind_api_key(key)
+ try:
+ await ctx.resolve(reference)
+ finally:
+ identity.unbind_api_key(token)
+
+ async def active_for(key: str) -> int | None:
+ token = identity.bind_api_key(key)
+ try:
+ return ctx.active.id if ctx.active else None
+ finally:
+ identity.unbind_api_key(token)
+
+ asyncio.run(select_as("ss_pat_A", "A"))
+ asyncio.run(select_as("ss_pat_B", "B"))
+
+ # Each caller keeps its own selection; no bleed across identities.
+ assert asyncio.run(active_for("ss_pat_A")) == 1
+ assert asyncio.run(active_for("ss_pat_B")) == 2
+ # An unknown caller has no active selection.
+ assert asyncio.run(active_for("ss_pat_C")) is None
diff --git a/surfsense_mcp/uv.lock b/surfsense_mcp/uv.lock
index bc97a9b2a..d1bfab702 100644
--- a/surfsense_mcp/uv.lock
+++ b/surfsense_mcp/uv.lock
@@ -718,6 +718,8 @@ source = { editable = "." }
dependencies = [
{ name = "httpx" },
{ name = "mcp" },
+ { name = "starlette" },
+ { name = "uvicorn" },
]
[package.dev-dependencies]
@@ -729,6 +731,8 @@ dev = [
requires-dist = [
{ name = "httpx", specifier = ">=0.27.0" },
{ name = "mcp", specifier = ">=1.26.0" },
+ { name = "starlette", specifier = ">=0.37" },
+ { name = "uvicorn", specifier = ">=0.30" },
]
[package.metadata.requires-dev]
diff --git a/surfsense_web/app/(home)/mcp-server/page.tsx b/surfsense_web/app/(home)/mcp-server/page.tsx
index 6dfc69622..5e4961b1e 100644
--- a/surfsense_web/app/(home)/mcp-server/page.tsx
+++ b/surfsense_web/app/(home)/mcp-server/page.tsx
@@ -50,16 +50,13 @@ export const metadata: Metadata = {
},
};
-/* Mirrors surfsense_mcp/README.md: the real Cursor config. */
+/* The hosted Cursor config; mirrors lib/mcp/clients.ts. */
const CURSOR_CONFIG = `{
"mcpServers": {
"surfsense": {
- "command": "uv",
- "args": ["run", "--directory", ".../surfsense_mcp",
- "python", "-m", "surfsense_mcp"],
- "env": {
- "SURFSENSE_BASE_URL": "https://api.surfsense.com",
- "SURFSENSE_API_KEY": "ss_pat_..."
+ "url": "https://mcp.surfsense.com/mcp",
+ "headers": {
+ "Authorization": "Bearer ss_pat_..."
}
}
}
@@ -76,7 +73,7 @@ const STEPS = [
icon: TerminalSquare,
title: "Add the server to your client",
description:
- "Drop the config into Cursor's mcp.json, run claude mcp add for Claude Code, or paste it into Claude Desktop. Point it at the cloud or your own self-hosted instance.",
+ "Point your client at https://mcp.surfsense.com/mcp with your key in an Authorization header — the hosted config for Cursor, Claude Code, and others is one paste. Prefer stdio? Switch to Self-host and run it against your own backend.",
},
{
icon: Server,
@@ -135,7 +132,7 @@ const FAQ: FaqItem[] = [
{
question: "Which MCP clients does it work with?",
answer:
- "Any MCP client that supports stdio servers. Claude Code, Codex, OpenCode, Cursor, Claude Desktop, VS Code, Windsurf, and Gemini CLI are documented with copy-paste configs on this page, and the same command works in custom agent harnesses built on the MCP SDK.",
+ "Any MCP client that speaks remote (streamable HTTP) or stdio. Claude Code, Codex, OpenCode, Cursor, Claude Desktop, VS Code, Windsurf, and Gemini CLI all have copy-paste configs on this page — Hosted for the one-paste https://mcp.surfsense.com/mcp endpoint, or Self-host for stdio against your own backend.",
},
{
question: "How is usage billed?",
@@ -278,9 +275,9 @@ export default function McpServerPage() {
Step-by-step setup for every agent
- Pick your client, follow its two steps, and paste the config. Replace the placeholder
- path with your surfsense_mcp checkout and the key with one from API Playground → API
- Keys — or grab a pre-filled config from the playground itself.
+ Pick your client, choose Hosted or Self-host, and
+ paste the config. Replace the key with one from API Playground → API Keys — or grab a
+ pre-filled config from the playground itself.
diff --git a/surfsense_web/app/dashboard/[workspace_id]/playground/api-keys/page.tsx b/surfsense_web/app/dashboard/[workspace_id]/playground/api-keys/page.tsx
index 0dbd12818..28a532e92 100644
--- a/surfsense_web/app/dashboard/[workspace_id]/playground/api-keys/page.tsx
+++ b/surfsense_web/app/dashboard/[workspace_id]/playground/api-keys/page.tsx
@@ -1,4 +1,8 @@
-import { ApiKeysSection } from "../components/api-keys-section";
+import { Info } from "lucide-react";
+import { WorkspaceApiAccessControl } from "@/components/settings/workspace-api-access-control";
+import { Alert, AlertDescription } from "@/components/ui/alert";
+import { Separator } from "@/components/ui/separator";
+import { ApiKeyContent } from "../../user-settings/components/ApiKeyContent";
export default async function PlaygroundApiKeysPage({
params,
@@ -6,10 +10,33 @@ export default async function PlaygroundApiKeysPage({
params: Promise<{ workspace_id: string }>;
}) {
const { workspace_id } = await params;
+ const workspaceId = Number(workspace_id);
return (
-
-
+
+
+
API keys
+
+ Create user API keys and choose whether they can access this workspace.
+
+
+
+
+
+
+ External API calls need both a user API key and workspace API key access enabled.
+
+
+
+
+
+
+
+
+
+
+
+
);
}
diff --git a/surfsense_web/app/dashboard/[workspace_id]/playground/components/api-keys-section.tsx b/surfsense_web/app/dashboard/[workspace_id]/playground/components/api-keys-section.tsx
deleted file mode 100644
index e3df313f0..000000000
--- a/surfsense_web/app/dashboard/[workspace_id]/playground/components/api-keys-section.tsx
+++ /dev/null
@@ -1,82 +0,0 @@
-"use client";
-
-import { useQuery } from "@tanstack/react-query";
-import { useAtomValue } from "jotai";
-import { useState } from "react";
-import { toast } from "sonner";
-import { updateWorkspaceApiAccessMutationAtom } from "@/atoms/workspaces/workspace-mutation.atoms";
-import { Label } from "@/components/ui/label";
-import { Skeleton } from "@/components/ui/skeleton";
-import { Switch } from "@/components/ui/switch";
-import { workspacesApiService } from "@/lib/apis/workspaces-api.service";
-import { cacheKeys } from "@/lib/query-client/cache-keys";
-import { ApiKeyContent } from "../../user-settings/components/ApiKeyContent";
-
-/**
- * One-stop API key management for the playground: the workspace API-access
- * toggle (otherwise buried in workspace settings) plus the personal API key
- * manager (otherwise buried in user settings).
- */
-export function ApiKeysSection({ workspaceId }: { workspaceId: number }) {
- const {
- data: workspace,
- isLoading,
- refetch,
- } = useQuery({
- queryKey: cacheKeys.workspaces.detail(workspaceId.toString()),
- queryFn: () => workspacesApiService.getWorkspace({ id: workspaceId }),
- enabled: !!workspaceId,
- });
- const { mutateAsync: updateWorkspaceApiAccess } = useAtomValue(
- updateWorkspaceApiAccessMutationAtom
- );
- const [saving, setSaving] = useState(false);
-
- const handleToggle = async (enabled: boolean) => {
- try {
- setSaving(true);
- await updateWorkspaceApiAccess({ id: workspaceId, api_access_enabled: enabled });
- await refetch();
- } catch (error) {
- console.error("Error updating API access:", error);
- toast.error(error instanceof Error ? error.message : "Failed to update API access");
- } finally {
- setSaving(false);
- }
- };
-
- const apiAccessEnabled = !!workspace?.api_access_enabled;
-
- return (
-
-
-
API Keys
-
- Enable API access for this workspace and manage the keys that use it.
-
-
-
-
-
-
-
- Allow API keys to access this workspace.
- {!isLoading && !apiAccessEnabled && " Currently disabled — keys won't work here."}
-
@@ -90,16 +91,13 @@ export function ApiReference({
API reference
- Call this API from your own project. Create a key under{" "}
- API Keys (and enable API access for
- this workspace), then send it as a{" "}
- Authorization: Bearer{" "}
- header.
+ Create an API key, enable API access for this workspace, then use the examples below to
+ call this endpoint.
- Showing first {MAX_TABLE_ROWS} of {items.length} items. Use Copy JSON for the full
+ Showing first {MAX_TABLE_ROWS} of {items.length} items. Switch to JSON for the full
output.
)}
diff --git a/surfsense_web/app/dashboard/[workspace_id]/playground/components/playground-index.tsx b/surfsense_web/app/dashboard/[workspace_id]/playground/components/playground-index.tsx
index 741ed291f..58d56e146 100644
--- a/surfsense_web/app/dashboard/[workspace_id]/playground/components/playground-index.tsx
+++ b/surfsense_web/app/dashboard/[workspace_id]/playground/components/playground-index.tsx
@@ -1,8 +1,9 @@
"use client";
-import { ArrowRight, History, KeyRound } from "lucide-react";
+import { ArrowRight, History, Info, KeyRound } from "lucide-react";
import Link from "next/link";
import { useMemo } from "react";
+import { Alert, AlertDescription } from "@/components/ui/alert";
import { useScraperCapabilities } from "@/hooks/use-scraper-capabilities";
import { PLAYGROUND_PLATFORMS } from "@/lib/playground/catalog";
import { formatPricing } from "@/lib/playground/format";
@@ -20,13 +21,21 @@ export function PlaygroundIndex({ workspaceId }: { workspaceId: number }) {
return (
-
-
API Playground
-
- Manually run SurfSense's platform-native APIs and inspect their output. Every run is
- captured under Runs.
-
-
+
+
+
+
+ Manually run SurfSense's platform-native APIs and inspect their output. To use these APIs outside SurfSense,{" "}
+
+ create an API key
+
+ .
+
+
+
-
Runs
+
API Runs
See every API run in this workspace
@@ -50,9 +59,7 @@ export function PlaygroundIndex({ workspaceId }: { workspaceId: number }) {
API Keys
-
- Enable workspace access and manage keys
-
+
Manage keys and workspace API access
diff --git a/surfsense_web/app/dashboard/[workspace_id]/playground/components/playground-runner.tsx b/surfsense_web/app/dashboard/[workspace_id]/playground/components/playground-runner.tsx
index 3b76a7d09..6b426b196 100644
--- a/surfsense_web/app/dashboard/[workspace_id]/playground/components/playground-runner.tsx
+++ b/surfsense_web/app/dashboard/[workspace_id]/playground/components/playground-runner.tsx
@@ -1,14 +1,16 @@
"use client";
-import { AlertTriangle, Coins, Hash, Loader2, Play, Timer, X } from "lucide-react";
+import { Check, Coins, Copy, Hash, Info, Timer } from "lucide-react";
import Link from "next/link";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+import { toast } from "sonner";
+import { Alert, AlertDescription } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { Spinner } from "@/components/ui/spinner";
import { useRunStream } from "@/hooks/use-run-stream";
import { useScraperCapabilities } from "@/hooks/use-scraper-capabilities";
import { scrapersApiService } from "@/lib/apis/scrapers-api.service";
-import { AbortedError, AppError } from "@/lib/error";
+import { AppError } from "@/lib/error";
import { findVerb } from "@/lib/playground/catalog";
import { formatCost, formatDuration, formatPricing } from "@/lib/playground/format";
import { buildPayload, initialFormValues, parseSchemaFields } from "@/lib/playground/json-schema";
@@ -58,36 +60,44 @@ function RunStat({
);
}
-function ErrorPanel({ error, workspaceId }: { error: unknown; workspaceId: number }) {
- if (error instanceof AbortedError) {
- return null;
- }
+function getRunErrorMessage(error: unknown): string {
const status = error instanceof AppError ? error.status : undefined;
if (status === 402) {
- return (
-
-
+
+ {children}
+
);
}
diff --git a/surfsense_web/atoms/layout/playground.atom.ts b/surfsense_web/atoms/layout/playground.atom.ts
deleted file mode 100644
index 6cbe8d276..000000000
--- a/surfsense_web/atoms/layout/playground.atom.ts
+++ /dev/null
@@ -1,10 +0,0 @@
-import { atom } from "jotai";
-
-/**
- * Whether the second-level API Playground sidebar is open. Toggled by the
- * Playground nav button and kept in memory for the session, so it survives
- * in-app navigation (opening a new chat won't close it) and only closes when
- * the user clicks the toggle. It defaults to open, so a fresh app load — a new
- * signup or a relogin — always starts with the playground visible.
- */
-export const playgroundSidebarOpenAtom = atom(true);
diff --git a/surfsense_web/components/assistant-ui/thread.tsx b/surfsense_web/components/assistant-ui/thread.tsx
index 1b8548e3d..0cf6ef296 100644
--- a/surfsense_web/components/assistant-ui/thread.tsx
+++ b/surfsense_web/components/assistant-ui/thread.tsx
@@ -22,7 +22,7 @@ import {
Wrench,
X,
} from "lucide-react";
-import { AnimatePresence, motion, useReducedMotion } from "motion/react";
+import { AnimatePresence, motion } from "motion/react";
import Image from "next/image";
import { useParams } from "next/navigation";
import { type FC, useCallback, useEffect, useMemo, useRef, useState } from "react";
@@ -119,7 +119,7 @@ import {
} from "../new-chat/document-mention-picker";
const COMPOSER_PLACEHOLDER =
- "Track competitors, scrape platforms, automate briefs — / for prompts, @ for docs";
+ "Track competitors, scrape platforms, automate briefs. Use / for prompts, @ for docs";
type ComposerSuggestionAnchorPoint = {
left: number;
@@ -986,14 +986,12 @@ const Composer: FC = () => {
* Full-color brand marks for the platform-native scraper APIs (web, Google
* Search, Google Maps, Reddit, YouTube) available in this workspace, shown beside the
* composer "+" so the user can see these native endpoints are connected. Laid
- * out as a clean row (not stacked) after a hairline divider that separates them
+ * out as the same overlapping avatar group used by the connect-tools tray
* from the composer actions. The capability registry is the source of truth;
- * icons are display-only with a status tooltip. One-time staggered entrance,
- * reduced-motion aware.
+ * icons are display-only with a status tooltip.
*/
const ConnectedScraperIcons: FC<{ workspaceId: number }> = ({ workspaceId }) => {
const { data: capabilities } = useScraperCapabilities(workspaceId);
- const reduceMotion = useReducedMotion();
const platforms = useMemo(() => {
if (!capabilities?.length) return [];
@@ -1014,30 +1012,26 @@ const ConnectedScraperIcons: FC<{ workspaceId: number }> = ({ workspaceId }) =>
return (
- {/* Playground second-level sidebar — contextual, desktop only. Sits
- between the icon rail and the main sidebar. On Mac it becomes the
- leftmost panel, so it takes the rounded-corner/left-border treatment. */}
- {showPlaygroundSidebar && activeWorkspaceId != null && (
-
-
-
- )}
-
{/* Sidebar + slide-out panels share one container; overflow visible so panels can overlay main content. Negative right margin closes the flex gap so the sidebar sits flush against the main panel, separated only by a border. */}
);
}
diff --git a/surfsense_web/components/mcp/connect-agent-dialog.tsx b/surfsense_web/components/mcp/connect-agent-dialog.tsx
index b48b849c6..fdbf08d71 100644
--- a/surfsense_web/components/mcp/connect-agent-dialog.tsx
+++ b/surfsense_web/components/mcp/connect-agent-dialog.tsx
@@ -1,6 +1,6 @@
"use client";
-import { Cable } from "lucide-react";
+import { SidebarButtonBadge } from "@/components/layout/ui/sidebar/SidebarButton";
import { AgentSetupTabs } from "@/components/mcp/agent-setup-tabs";
import {
Dialog,
@@ -28,8 +28,18 @@ export function ConnectAgentDialog({ className }: { className?: string }) {
className
)}
>
-
- Connect your AI Agent
+
+
+ Connect your agent
+ New
+
diff --git a/surfsense_web/components/pricing/pricing-section.tsx b/surfsense_web/components/pricing/pricing-section.tsx
index cc7d81307..74c76ad36 100644
--- a/surfsense_web/components/pricing/pricing-section.tsx
+++ b/surfsense_web/components/pricing/pricing-section.tsx
@@ -41,7 +41,7 @@ const demoPlans = [
"Premium models like GPT-5.5, Claude Sonnet 5, Gemini 3.1 Pro billed at provider cost",
"Scheduled and event-triggered agents for briefs, alerts, and monitoring",
"Write results back to Notion, Slack, Linear, and Jira",
- "Top up any amount, $1 buys $1 of credit, optional auto-reload",
+ "Add credit any time. $1 buys $1 of credit, with optional automatic refills",
"Priority support on Discord",
],
description: "",
@@ -95,7 +95,7 @@ const faqData: FAQSection[] = [
{
question: "How does Pay As You Go work?",
answer:
- "There is no monthly subscription. Start with $5 of free credit, and when you need more, top up any amount. $1 buys exactly $1 of credit, added to your balance immediately. You can enable auto-reload to top up automatically when your balance runs low, and turn it off any time.",
+ "There is no monthly subscription. Start with $5 of free credit, and when you need more, add any amount. $1 buys exactly $1 of credit, added to your balance immediately. You can enable automatic refills when your balance runs low, and turn them off any time."
},
{
question: "What happens if I run out of credit?",
diff --git a/surfsense_web/components/settings/auto-reload-settings.tsx b/surfsense_web/components/settings/auto-reload-settings.tsx
index b11a64353..ede826dd0 100644
--- a/surfsense_web/components/settings/auto-reload-settings.tsx
+++ b/surfsense_web/components/settings/auto-reload-settings.tsx
@@ -2,15 +2,15 @@
import { useQuery as useZeroQuery } from "@rocicorp/zero/react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
-import { AlertTriangle, CreditCard, RefreshCw } from "lucide-react";
+import { AlertTriangle, Info } from "lucide-react";
import { useParams, usePathname, useRouter, useSearchParams } from "next/navigation";
import { useEffect, useRef, useState } from "react";
import { toast } from "sonner";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
-import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
+import { Separator } from "@/components/ui/separator";
import { Spinner } from "@/components/ui/spinner";
import { Switch } from "@/components/ui/switch";
import { stripeApiService } from "@/lib/apis/stripe-api.service";
@@ -69,7 +69,7 @@ export function AutoReloadSettings() {
const setupResult = searchParams.get("auto_reload_setup");
if (!setupResult) return;
if (setupResult === "success") {
- toast.success("Card saved. You can now enable auto-reload.");
+ toast.success("Card saved. You can now enable top-ups.");
queryClient.invalidateQueries({ queryKey: ["auto-reload-settings"] });
} else if (setupResult === "cancel") {
toast.info("Card setup canceled.");
@@ -92,19 +92,19 @@ export function AutoReloadSettings() {
mutationFn: stripeApiService.updateAutoReloadSettings,
onSuccess: (updated) => {
queryClient.setQueryData(["auto-reload-settings"], updated);
- toast.success(updated.enabled ? "Auto-reload is on." : "Auto-reload settings saved.");
+ toast.success(updated.enabled ? "Top-ups are on." : "Top-up settings saved.");
},
onError: (error) => {
if (error instanceof AppError && error.message) {
toast.error(error.message);
return;
}
- toast.error("Couldn't save auto-reload settings. Please try again.");
+ toast.error("Couldn't save top-up settings. Please try again.");
},
});
// Render nothing while loading (avoids a spinner flash on pages where the
- // feature flag turns out to be off) and when auto-reload is disabled
+ // feature flag turns out to be off) and when top-ups are disabled
// server-side.
if (isLoading || !settings || !settings.feature_enabled) {
return null;
@@ -131,7 +131,7 @@ export function AutoReloadSettings() {
return;
}
if (amountMicros == null || amountMicros < settings.min_amount_micros) {
- toast.error(`Reload amount must be at least $${minAmountDollars}.`);
+ toast.error(`Top-up amount must be at least $${minAmountDollars}.`);
return;
}
@@ -142,135 +142,168 @@ export function AutoReloadSettings() {
});
};
- return (
-
-
-
-
- Auto-reload
-
-
- Automatically top up your credit balance when it drops below a threshold, using a saved
- card. Current balance:{" "}
- {formatUsd(balanceMicros)}.
-
-
-
+ const addCardButton = (
+ setupMutation.mutate()}
+ disabled={setupMutation.isPending}
+ >
+ {setupMutation.isPending ? (
+ <>
+
+ Redirecting
+ >
+ ) : (
+ "Add a card"
+ )}
+
+ );
+
+ if (!hasCard) {
+ return (
+
{settings.failed_at && (
- Last auto-reload failed
+ Last top-up failed
- Your saved card was declined and auto-reload was turned off. Update your card and
- re-enable it below to keep topping up automatically.
+ Your saved card was declined and top-ups were turned off. Update your card and
+ re-enable top-ups below.
)}
- {!hasCard ? (
-
-
-
- Add a card to enable automatic top-ups.
+
+
+
+
+
+ Automatically top up your credit balance when it drops below a threshold, using a
+ saved card. Current balance:{" "}
+ {formatUsd(balanceMicros)}.
+
+ );
+}
diff --git a/surfsense_web/components/ui/tabs.tsx b/surfsense_web/components/ui/tabs.tsx
index 5dbfc4bc5..693e246f4 100644
--- a/surfsense_web/components/ui/tabs.tsx
+++ b/surfsense_web/components/ui/tabs.tsx
@@ -29,7 +29,7 @@ const TabsTrigger = React.forwardRef<
-
+You need a SurfSense API key either way. In SurfSense, open **API Playground → API Keys** in your workspace sidebar:
-### Install uv
+1. Toggle **API key access** on for the workspace.
+2. Create a personal API key (`ss_pat_…`) and copy it — it is shown only once.
-The server runs with [uv](https://github.com/astral-sh/uv). Install it once, then from the SurfSense repository run:
+## Connect (hosted)
+
+The hosted server runs at `https://mcp.surfsense.com/mcp`. Point your client at it and send the key as a Bearer token — there is nothing to install and no backend to run. For clients that read an `mcpServers` map (Cursor, and others):
+
+```json
+{
+ "mcpServers": {
+ "surfsense": {
+ "url": "https://mcp.surfsense.com/mcp",
+ "headers": { "Authorization": "Bearer ss_pat_your_key_here" }
+ }
+ }
+}
+```
+
+Claude Code, from a terminal:
+
+```bash
+claude mcp add --transport http surfsense https://mcp.surfsense.com/mcp \
+ --header "Authorization: Bearer ss_pat_your_key_here"
+```
+
+Most MCP clients accept this `url` + `headers` form; check your client's docs for its exact remote-server field.
+
+## Self-host (stdio)
+
+Run the server yourself when you host your own backend or use a client without remote support. It runs with [uv](https://github.com/astral-sh/uv) — install it once, then from the SurfSense repository run:
```bash
cd surfsense_mcp
uv sync
```
-
-
-
-### Create an API key
-
-In SurfSense, open **API Playground → API Keys** in your workspace sidebar:
-
-1. Toggle **API key access** on for the workspace.
-2. Create a personal API key (`ss_pat_…`) and copy it — it is shown only once.
-
-
-
-
-### Know your base URL
+Point the server at your backend with `SURFSENSE_BASE_URL`:
- **SurfSense Cloud**: `https://api.surfsense.com`
- **Self-hosted**: wherever your backend runs, e.g. `http://localhost:8000`
-
-
-
-## Connect your agent
-
-Every client below launches the same command — `uv run --directory /surfsense_mcp python -m surfsense_mcp` — and passes `SURFSENSE_BASE_URL` and `SURFSENSE_API_KEY` as environment variables. Replace the placeholder paths and key with yours.
+Every client below launches the same command — `uv run --directory /surfsense_mcp python -m mcp_server` — and passes `SURFSENSE_BASE_URL` and `SURFSENSE_API_KEY` as environment variables. Replace the placeholder paths and key with yours.
@@ -60,7 +67,7 @@ Run one command in a terminal:
claude mcp add surfsense \
-e SURFSENSE_BASE_URL=https://api.surfsense.com \
-e SURFSENSE_API_KEY=ss_pat_your_key_here \
- -- uv run --directory /path/to/SurfSense/surfsense_mcp python -m surfsense_mcp
+ -- uv run --directory /path/to/SurfSense/surfsense_mcp python -m mcp_server
```
Start Claude Code and run `/mcp` — `surfsense` should be listed as connected. Add `--scope project` to share the server (without the key) via a checked-in `.mcp.json`.
@@ -73,14 +80,14 @@ Add to `~/.codex/config.toml` (or a project's `.codex/config.toml`):
```toml
[mcp_servers.surfsense]
command = "uv"
-args = ["run", "--directory", "/path/to/SurfSense/surfsense_mcp", "python", "-m", "surfsense_mcp"]
+args = ["run", "--directory", "/path/to/SurfSense/surfsense_mcp", "python", "-m", "mcp_server"]
[mcp_servers.surfsense.env]
SURFSENSE_BASE_URL = "https://api.surfsense.com"
SURFSENSE_API_KEY = "ss_pat_your_key_here"
```
-Or use the CLI: `codex mcp add surfsense -e SURFSENSE_API_KEY=... -- uv run --directory ... python -m surfsense_mcp`. Verify with `codex mcp list`.
+Or use the CLI: `codex mcp add surfsense -e SURFSENSE_API_KEY=... -- uv run --directory ... python -m mcp_server`. Verify with `codex mcp list`.
@@ -93,7 +100,7 @@ Add to `opencode.json` in your project root (or `~/.config/opencode/opencode.jso
"mcp": {
"surfsense": {
"type": "local",
- "command": ["uv", "run", "--directory", "/path/to/SurfSense/surfsense_mcp", "python", "-m", "surfsense_mcp"],
+ "command": ["uv", "run", "--directory", "/path/to/SurfSense/surfsense_mcp", "python", "-m", "mcp_server"],
"enabled": true,
"environment": {
"SURFSENSE_BASE_URL": "https://api.surfsense.com",
@@ -116,7 +123,7 @@ Add to `~/.cursor/mcp.json` (global — keeps the key out of your repo) or a pro
"mcpServers": {
"surfsense": {
"command": "uv",
- "args": ["run", "--directory", "/path/to/SurfSense/surfsense_mcp", "python", "-m", "surfsense_mcp"],
+ "args": ["run", "--directory", "/path/to/SurfSense/surfsense_mcp", "python", "-m", "mcp_server"],
"env": {
"SURFSENSE_BASE_URL": "https://api.surfsense.com",
"SURFSENSE_API_KEY": "ss_pat_your_key_here"
@@ -138,7 +145,7 @@ Open **Settings → Developer → Edit Config** to reach `claude_desktop_config.
"mcpServers": {
"surfsense": {
"command": "uv",
- "args": ["run", "--directory", "/path/to/SurfSense/surfsense_mcp", "python", "-m", "surfsense_mcp"],
+ "args": ["run", "--directory", "/path/to/SurfSense/surfsense_mcp", "python", "-m", "mcp_server"],
"env": {
"SURFSENSE_BASE_URL": "https://api.surfsense.com",
"SURFSENSE_API_KEY": "ss_pat_your_key_here"
@@ -161,7 +168,7 @@ Add to `.vscode/mcp.json` in your workspace (or run the **MCP: Add Server** comm
"surfsense": {
"type": "stdio",
"command": "uv",
- "args": ["run", "--directory", "/path/to/SurfSense/surfsense_mcp", "python", "-m", "surfsense_mcp"],
+ "args": ["run", "--directory", "/path/to/SurfSense/surfsense_mcp", "python", "-m", "mcp_server"],
"env": {
"SURFSENSE_BASE_URL": "https://api.surfsense.com",
"SURFSENSE_API_KEY": "ss_pat_your_key_here"
@@ -183,7 +190,7 @@ Add the standard `mcpServers` block to `~/.codeium/windsurf/mcp_config.json` (or
"mcpServers": {
"surfsense": {
"command": "uv",
- "args": ["run", "--directory", "/path/to/SurfSense/surfsense_mcp", "python", "-m", "surfsense_mcp"],
+ "args": ["run", "--directory", "/path/to/SurfSense/surfsense_mcp", "python", "-m", "mcp_server"],
"env": {
"SURFSENSE_BASE_URL": "https://api.surfsense.com",
"SURFSENSE_API_KEY": "ss_pat_your_key_here"
@@ -205,7 +212,7 @@ Add the standard `mcpServers` block to `~/.gemini/settings.json` (or `.gemini/se
"mcpServers": {
"surfsense": {
"command": "uv",
- "args": ["run", "--directory", "/path/to/SurfSense/surfsense_mcp", "python", "-m", "surfsense_mcp"],
+ "args": ["run", "--directory", "/path/to/SurfSense/surfsense_mcp", "python", "-m", "mcp_server"],
"env": {
"SURFSENSE_BASE_URL": "https://api.surfsense.com",
"SURFSENSE_API_KEY": "ss_pat_your_key_here"
@@ -221,7 +228,7 @@ Run `/mcp` inside Gemini CLI to confirm the server and its tools.
-The server uses stdio transport: your client launches the process on demand and shuts it down with the session. There is no daemon to manage — only your SurfSense backend needs to be up.
+In this mode your client launches the process on demand and shuts it down with the session. There is no daemon to manage — only your SurfSense backend needs to be up. (The hosted server above needs none of this.)
## Test it
@@ -236,7 +243,7 @@ That calls `surfsense_list_workspaces` — the simplest end-to-end check of the
## Configuration reference
-All settings are environment variables passed by the client:
+For self-host (stdio), all settings are environment variables passed by the client. The hosted server needs only your API key in the `Authorization` header:
| Variable | Required | Default | Purpose |
|----------|----------|---------|---------|
@@ -250,7 +257,7 @@ All settings are environment variables passed by the client:
- **401 errors** — the API key is wrong or expired; create a new one.
- **403 errors** — API access is disabled for the workspace; toggle **API key access** on under **API Playground → API Keys**.
- **"Could not reach SurfSense"** — the backend isn't running or `SURFSENSE_BASE_URL` is wrong.
-- **Server won't start** — run `uv run python -m surfsense_mcp.selfcheck` inside `surfsense_mcp`; it verifies all 18 tools register without needing a backend.
+- **Server won't start** — run `uv run python -m mcp_server.selfcheck` inside `surfsense_mcp`; it verifies all 18 tools register without needing a backend.
## Tools reference
diff --git a/surfsense_web/contracts/types/scraper.types.ts b/surfsense_web/contracts/types/scraper.types.ts
index d31e9d4fb..63c043fa7 100644
--- a/surfsense_web/contracts/types/scraper.types.ts
+++ b/surfsense_web/contracts/types/scraper.types.ts
@@ -15,6 +15,7 @@ export const scraperPricingMeter = z.object({
export const scraperCapability = z.object({
name: z.string(),
description: z.string(),
+ docs_url: z.string().nullable().optional(),
input_schema: z.record(z.string(), z.unknown()),
// Optional so a backend that predates output schemas degrades to just not
// showing the output-schema block instead of failing the whole fetch.
diff --git a/surfsense_web/features/artifacts-library/ui/artifacts-library.tsx b/surfsense_web/features/artifacts-library/ui/artifacts-library.tsx
index 53010fba2..642fed1c1 100644
--- a/surfsense_web/features/artifacts-library/ui/artifacts-library.tsx
+++ b/surfsense_web/features/artifacts-library/ui/artifacts-library.tsx
@@ -1,7 +1,7 @@
"use client";
import { useSetAtom } from "jotai";
-import { RefreshCw, TriangleAlert } from "lucide-react";
+import { Boxes, RefreshCw, TriangleAlert } from "lucide-react";
import { useMemo, useState } from "react";
import { openReportPanelAtom } from "@/atoms/chat/report-panel.atom";
import { MobileReportPanel } from "@/components/report-panel/report-panel";
@@ -46,8 +46,15 @@ function ErrorState({ onRetry }: { onRetry: () => void }) {
function EmptyState() {
return (
-
-
No artifacts yet
+
+
+
+
+
No artifacts yet
+
+ Artifacts collect the reports, resumes, podcasts, presentations, and images SurfSense
+ creates for this workspace. Generated deliverables from your chats will appear here.
+
);
}
diff --git a/surfsense_web/hooks/use-inbox.ts b/surfsense_web/hooks/use-inbox.ts
index 1caf73b1d..eb33f84f7 100644
--- a/surfsense_web/hooks/use-inbox.ts
+++ b/surfsense_web/hooks/use-inbox.ts
@@ -54,6 +54,7 @@ export function useInbox(
const [hasMore, setHasMore] = useState(false);
const [error, setError] = useState(null);
const [unreadCount, setUnreadCount] = useState(0);
+ const [totalCount, setTotalCount] = useState(0);
const initialLoadDoneRef = useRef(false);
const olderUnreadOffsetRef = useRef(null);
@@ -71,6 +72,7 @@ export function useInbox(
setLoading(true);
setInboxItems([]);
setHasMore(false);
+ setTotalCount(0);
initialLoadDoneRef.current = false;
olderUnreadOffsetRef.current = null;
apiUnreadTotalRef.current = 0;
@@ -97,6 +99,7 @@ export function useInbox(
if (cancelled) return;
setInboxItems(notificationsResponse.items);
+ setTotalCount(notificationsResponse.total);
setHasMore(notificationsResponse.has_more);
setUnreadCount(unreadResponse.total_unread);
apiUnreadTotalRef.current = unreadResponse.total_unread;
@@ -222,6 +225,7 @@ export function useInbox(
const deduped = newItems.filter((d) => !existingIds.has(d.id));
return [...prev, ...deduped];
});
+ setTotalCount(response.total);
setHasMore(response.has_more);
} catch (err) {
console.error(`[useInbox:${category}] Load more failed:`, err);
@@ -299,6 +303,7 @@ export function useInbox(
return {
inboxItems,
unreadCount,
+ totalCount,
markAsRead,
markAllAsRead,
loading,
diff --git a/surfsense_web/lib/mcp/clients.ts b/surfsense_web/lib/mcp/clients.ts
index 923e99049..258357ca5 100644
--- a/surfsense_web/lib/mcp/clients.ts
+++ b/surfsense_web/lib/mcp/clients.ts
@@ -1,42 +1,75 @@
/**
- * MCP client setup catalog: one entry per popular agent, with the exact
- * config file, steps, and snippet needed to connect the SurfSense MCP server.
- * Shared by the marketing /mcp-server page and the API playground so the
- * instructions can never drift apart.
+ * MCP client setup catalog: one entry per popular agent, each with a hosted
+ * (remote) snippet and a self-host (stdio) snippet, plus the exact config file
+ * and steps. Shared by the marketing /mcp-server page and the API playground so
+ * the instructions can never drift apart.
+ *
+ * Remote snippets point at the hosted server and pass the key as a Bearer token;
+ * every client's exact remote field is verified against its own docs (Windsurf
+ * uses `serverUrl`, Gemini CLI `httpUrl`, VS Code needs `type: "http"`, OpenCode
+ * `type: "remote"` + `oauth: false`, Codex needs the rmcp flag, and Claude
+ * Desktop has no config-file remote support so it uses the `mcp-remote` bridge).
*/
+export type McpTransport = "remote" | "stdio";
+
export interface McpSnippetOptions {
- /** SurfSense backend URL the server should call. */
- baseUrl: string;
+ /** Hosted MCP endpoint (Bearer-authenticated). */
+ remoteUrl: string;
/** API key value or placeholder to show in the snippet. */
apiKey: string;
- /** Absolute path to the surfsense_mcp directory. */
+ /** SurfSense backend URL a self-hosted server should call. */
+ baseUrl: string;
+ /** Absolute path to the surfsense_mcp directory (self-host). */
serverDir: string;
}
+export interface McpSnippet {
+ /** Where the snippet goes: a file path or "Terminal". */
+ configFile: string;
+ language: "json" | "toml" | "bash";
+ steps: string[];
+ build: (options: McpSnippetOptions) => string;
+}
+
export interface McpClient {
id: string;
label: string;
- /** Where the snippet goes: a file path or "Terminal". */
- configFile: string;
- language: "json" | "toml" | "bash";
- steps: string[];
- buildConfig: (options: McpSnippetOptions) => string;
+ remote: McpSnippet;
+ stdio: McpSnippet;
}
+export const REMOTE_URL = "https://mcp.surfsense.com/mcp";
export const DEFAULT_SERVER_DIR = "/path/to/SurfSense/surfsense_mcp";
export const API_KEY_PLACEHOLDER = "ss_pat_your_key_here";
-function serverArgs(serverDir: string): string[] {
- return ["run", "--directory", serverDir, "python", "-m", "surfsense_mcp"];
-}
-
function json(value: unknown): string {
return JSON.stringify(value, null, 2);
}
-/** The `mcpServers` JSON shape shared by Cursor, Claude Desktop, Windsurf, and Gemini CLI. */
-function standardJson({ baseUrl, apiKey, serverDir }: McpSnippetOptions): string {
+function bearer(apiKey: string): string {
+ return `Bearer ${apiKey}`;
+}
+
+function serverArgs(serverDir: string): string[] {
+ return ["run", "--directory", serverDir, "python", "-m", "mcp_server"];
+}
+
+/** The `mcpServers` remote shape shared by Cursor, Windsurf, and Gemini CLI. */
+function remoteMcpServers(urlField: "url" | "serverUrl" | "httpUrl") {
+ return ({ remoteUrl, apiKey }: McpSnippetOptions): string =>
+ json({
+ mcpServers: {
+ surfsense: {
+ [urlField]: remoteUrl,
+ headers: { Authorization: bearer(apiKey) },
+ },
+ },
+ });
+}
+
+/** The `mcpServers` stdio shape shared by Cursor, Claude Desktop, Windsurf, Gemini CLI. */
+function stdioMcpServers({ baseUrl, apiKey, serverDir }: McpSnippetOptions): string {
return json({
mcpServers: {
surfsense: {
@@ -52,125 +85,255 @@ export const MCP_CLIENTS: McpClient[] = [
{
id: "claude-code",
label: "Claude Code",
- configFile: "Terminal",
- language: "bash",
- steps: [
- "Run this command in a terminal (any directory).",
- "Start Claude Code and run /mcp — surfsense should be listed as connected.",
- ],
- buildConfig: ({ baseUrl, apiKey, serverDir }) =>
- [
- "claude mcp add surfsense \\",
- ` -e SURFSENSE_BASE_URL=${baseUrl} \\`,
- ` -e SURFSENSE_API_KEY=${apiKey} \\`,
- ` -- uv run --directory ${serverDir} python -m surfsense_mcp`,
- ].join("\n"),
+ remote: {
+ configFile: "Terminal",
+ language: "bash",
+ steps: [
+ "Run this command in a terminal (any directory).",
+ "Start Claude Code and run /mcp — surfsense should be listed as connected.",
+ ],
+ build: ({ remoteUrl, apiKey }) =>
+ [
+ `claude mcp add --transport http surfsense ${remoteUrl} \\`,
+ ` --header "Authorization: ${bearer(apiKey)}"`,
+ ].join("\n"),
+ },
+ stdio: {
+ configFile: "Terminal",
+ language: "bash",
+ steps: [
+ "Run this command in a terminal (any directory).",
+ "Start Claude Code and run /mcp — surfsense should be listed as connected.",
+ ],
+ build: ({ baseUrl, apiKey, serverDir }) =>
+ [
+ "claude mcp add surfsense \\",
+ ` -e SURFSENSE_BASE_URL=${baseUrl} \\`,
+ ` -e SURFSENSE_API_KEY=${apiKey} \\`,
+ ` -- uv run --directory ${serverDir} python -m mcp_server`,
+ ].join("\n"),
+ },
},
{
id: "codex",
label: "Codex",
- configFile: "~/.codex/config.toml",
- language: "toml",
- steps: [
- "Add this to ~/.codex/config.toml (or run `codex mcp add surfsense -- uv run --directory python -m surfsense_mcp`).",
- "Restart Codex; `codex mcp list` should show surfsense.",
- ],
- buildConfig: ({ baseUrl, apiKey, serverDir }) =>
- [
- "[mcp_servers.surfsense]",
- 'command = "uv"',
- `args = ${JSON.stringify(serverArgs(serverDir))}`,
- "",
- "[mcp_servers.surfsense.env]",
- `SURFSENSE_BASE_URL = "${baseUrl}"`,
- `SURFSENSE_API_KEY = "${apiKey}"`,
- ].join("\n"),
+ remote: {
+ configFile: "~/.codex/config.toml",
+ language: "toml",
+ steps: [
+ "Add this to ~/.codex/config.toml. The rmcp flag must sit above every [mcp_servers.*] table.",
+ "Restart Codex; `codex mcp list` should show surfsense.",
+ ],
+ build: ({ remoteUrl, apiKey }) =>
+ [
+ "experimental_use_rmcp_client = true",
+ "",
+ "[mcp_servers.surfsense]",
+ `url = "${remoteUrl}"`,
+ "",
+ "[mcp_servers.surfsense.http_headers]",
+ `Authorization = "${bearer(apiKey)}"`,
+ ].join("\n"),
+ },
+ stdio: {
+ configFile: "~/.codex/config.toml",
+ language: "toml",
+ steps: [
+ "Add this to ~/.codex/config.toml (or run `codex mcp add surfsense -- uv run --directory python -m mcp_server`).",
+ "Restart Codex; `codex mcp list` should show surfsense.",
+ ],
+ build: ({ baseUrl, apiKey, serverDir }) =>
+ [
+ "[mcp_servers.surfsense]",
+ 'command = "uv"',
+ `args = ${JSON.stringify(serverArgs(serverDir))}`,
+ "",
+ "[mcp_servers.surfsense.env]",
+ `SURFSENSE_BASE_URL = "${baseUrl}"`,
+ `SURFSENSE_API_KEY = "${apiKey}"`,
+ ].join("\n"),
+ },
},
{
id: "opencode",
label: "OpenCode",
- configFile: "opencode.json",
- language: "json",
- steps: [
- "Add this to opencode.json in your project root (or ~/.config/opencode/opencode.json for all projects).",
- "Note OpenCode's format: the key is `mcp`, the command is one array, and env vars go under `environment`.",
- ],
- buildConfig: ({ baseUrl, apiKey, serverDir }) =>
- json({
- $schema: "https://opencode.ai/config.json",
- mcp: {
- surfsense: {
- type: "local",
- command: ["uv", ...serverArgs(serverDir)],
- enabled: true,
- environment: { SURFSENSE_BASE_URL: baseUrl, SURFSENSE_API_KEY: apiKey },
+ remote: {
+ configFile: "opencode.json",
+ language: "json",
+ steps: [
+ "Add this to opencode.json in your project root (or ~/.config/opencode/opencode.json for all projects).",
+ "`oauth: false` tells OpenCode to use the Bearer key instead of starting an OAuth flow.",
+ ],
+ build: ({ remoteUrl, apiKey }) =>
+ json({
+ $schema: "https://opencode.ai/config.json",
+ mcp: {
+ surfsense: {
+ type: "remote",
+ url: remoteUrl,
+ enabled: true,
+ oauth: false,
+ headers: { Authorization: bearer(apiKey) },
+ },
},
- },
- }),
+ }),
+ },
+ stdio: {
+ configFile: "opencode.json",
+ language: "json",
+ steps: [
+ "Add this to opencode.json in your project root (or ~/.config/opencode/opencode.json for all projects).",
+ "Note OpenCode's format: the key is `mcp`, the command is one array, and env vars go under `environment`.",
+ ],
+ build: ({ baseUrl, apiKey, serverDir }) =>
+ json({
+ $schema: "https://opencode.ai/config.json",
+ mcp: {
+ surfsense: {
+ type: "local",
+ command: ["uv", ...serverArgs(serverDir)],
+ enabled: true,
+ environment: { SURFSENSE_BASE_URL: baseUrl, SURFSENSE_API_KEY: apiKey },
+ },
+ },
+ }),
+ },
},
{
id: "cursor",
label: "Cursor",
- configFile: "~/.cursor/mcp.json",
- language: "json",
- steps: [
- "Add this to ~/.cursor/mcp.json (global, keeps the key out of your repo) or a project's .cursor/mcp.json.",
- "Refresh the server in Cursor Settings → MCP; its 18 tools should appear.",
- ],
- buildConfig: standardJson,
+ remote: {
+ configFile: "~/.cursor/mcp.json",
+ language: "json",
+ steps: [
+ "Add this to ~/.cursor/mcp.json (global, keeps the key out of your repo) or a project's .cursor/mcp.json.",
+ "Refresh the server in Cursor Settings → MCP; its 18 tools should appear.",
+ ],
+ build: remoteMcpServers("url"),
+ },
+ stdio: {
+ configFile: "~/.cursor/mcp.json",
+ language: "json",
+ steps: [
+ "Add this to ~/.cursor/mcp.json (global, keeps the key out of your repo) or a project's .cursor/mcp.json.",
+ "Refresh the server in Cursor Settings → MCP; its 18 tools should appear.",
+ ],
+ build: stdioMcpServers,
+ },
},
{
id: "claude-desktop",
label: "Claude Desktop",
- configFile: "claude_desktop_config.json",
- language: "json",
- steps: [
- "Open Settings → Developer → Edit Config to reach claude_desktop_config.json and add this.",
- "Restart Claude Desktop; surfsense appears under the tools icon.",
- ],
- buildConfig: standardJson,
+ remote: {
+ configFile: "claude_desktop_config.json",
+ language: "json",
+ steps: [
+ "Claude Desktop can't take a remote URL directly, so this uses the mcp-remote bridge (needs Node 18+).",
+ "Open Settings → Developer → Edit Config, add this, and restart Claude Desktop.",
+ ],
+ build: ({ remoteUrl, apiKey }) =>
+ json({
+ mcpServers: {
+ surfsense: {
+ command: "npx",
+ args: ["-y", "mcp-remote", remoteUrl, "--header", `Authorization: ${bearer(apiKey)}`],
+ },
+ },
+ }),
+ },
+ stdio: {
+ configFile: "claude_desktop_config.json",
+ language: "json",
+ steps: [
+ "Open Settings → Developer → Edit Config to reach claude_desktop_config.json and add this.",
+ "Restart Claude Desktop; surfsense appears under the tools icon.",
+ ],
+ build: stdioMcpServers,
+ },
},
{
id: "vscode",
label: "VS Code",
- configFile: ".vscode/mcp.json",
- language: "json",
- steps: [
- "Add this to .vscode/mcp.json in your workspace (or run the MCP: Add Server command).",
- "Open Copilot Chat in agent mode and click the tools icon to confirm surfsense is loaded.",
- ],
- buildConfig: ({ baseUrl, apiKey, serverDir }) =>
- json({
- servers: {
- surfsense: {
- type: "stdio",
- command: "uv",
- args: serverArgs(serverDir),
- env: { SURFSENSE_BASE_URL: baseUrl, SURFSENSE_API_KEY: apiKey },
+ remote: {
+ configFile: ".vscode/mcp.json",
+ language: "json",
+ steps: [
+ "Add this to .vscode/mcp.json in your workspace (or run the MCP: Add Server command).",
+ "VS Code requires an explicit `type` field — `http` for the hosted server.",
+ ],
+ build: ({ remoteUrl, apiKey }) =>
+ json({
+ servers: {
+ surfsense: {
+ type: "http",
+ url: remoteUrl,
+ headers: { Authorization: bearer(apiKey) },
+ },
},
- },
- }),
+ }),
+ },
+ stdio: {
+ configFile: ".vscode/mcp.json",
+ language: "json",
+ steps: [
+ "Add this to .vscode/mcp.json in your workspace (or run the MCP: Add Server command).",
+ "Open Copilot Chat in agent mode and click the tools icon to confirm surfsense is loaded.",
+ ],
+ build: ({ baseUrl, apiKey, serverDir }) =>
+ json({
+ servers: {
+ surfsense: {
+ type: "stdio",
+ command: "uv",
+ args: serverArgs(serverDir),
+ env: { SURFSENSE_BASE_URL: baseUrl, SURFSENSE_API_KEY: apiKey },
+ },
+ },
+ }),
+ },
},
{
id: "windsurf",
label: "Windsurf",
- configFile: "~/.codeium/windsurf/mcp_config.json",
- language: "json",
- steps: [
- "Add this to ~/.codeium/windsurf/mcp_config.json (or Windsurf Settings → Cascade → MCP Servers).",
- "Press the refresh button in the MCP panel to pick up the server.",
- ],
- buildConfig: standardJson,
+ remote: {
+ configFile: "~/.codeium/windsurf/mcp_config.json",
+ language: "json",
+ steps: [
+ "Add this to ~/.codeium/windsurf/mcp_config.json (or Windsurf Settings → Cascade → MCP Servers).",
+ "Windsurf uses `serverUrl` (not `url`) for remote servers; press refresh in the MCP panel.",
+ ],
+ build: remoteMcpServers("serverUrl"),
+ },
+ stdio: {
+ configFile: "~/.codeium/windsurf/mcp_config.json",
+ language: "json",
+ steps: [
+ "Add this to ~/.codeium/windsurf/mcp_config.json (or Windsurf Settings → Cascade → MCP Servers).",
+ "Press the refresh button in the MCP panel to pick up the server.",
+ ],
+ build: stdioMcpServers,
+ },
},
{
id: "gemini-cli",
label: "Gemini CLI",
- configFile: "~/.gemini/settings.json",
- language: "json",
- steps: [
- "Add this to ~/.gemini/settings.json (or .gemini/settings.json in a project).",
- "Run /mcp inside Gemini CLI to confirm the surfsense server and its tools.",
- ],
- buildConfig: standardJson,
+ remote: {
+ configFile: "~/.gemini/settings.json",
+ language: "json",
+ steps: [
+ "Add this to ~/.gemini/settings.json (or .gemini/settings.json in a project).",
+ "Gemini CLI uses `httpUrl` for streamable-HTTP servers; run /mcp to confirm surfsense.",
+ ],
+ build: remoteMcpServers("httpUrl"),
+ },
+ stdio: {
+ configFile: "~/.gemini/settings.json",
+ language: "json",
+ steps: [
+ "Add this to ~/.gemini/settings.json (or .gemini/settings.json in a project).",
+ "Run /mcp inside Gemini CLI to confirm the surfsense server and its tools.",
+ ],
+ build: stdioMcpServers,
+ },
},
];
diff --git a/surfsense_web/public/connectors/reddit.svg b/surfsense_web/public/connectors/reddit.svg
index 97b05bc57..488596ce1 100644
--- a/surfsense_web/public/connectors/reddit.svg
+++ b/surfsense_web/public/connectors/reddit.svg
@@ -1 +1,57 @@
-
\ No newline at end of file
+
\ No newline at end of file
diff --git a/surfsense_web/public/connectors/web.svg b/surfsense_web/public/connectors/web.svg
index 15a38b8c1..699027f7c 100644
--- a/surfsense_web/public/connectors/web.svg
+++ b/surfsense_web/public/connectors/web.svg
@@ -1 +1,8 @@
-
\ No newline at end of file
+