feat: update environment variables and enhance scraping capabilities

- Adjusted Google Maps and YouTube micro pricing in the .env.example file for better cost management.
- Introduced new environment variables for captcha solving and stealth browser hardening to improve scraping resilience.
- Removed outdated smoke test for scraper API endpoints to streamline testing.
- Enhanced anonymous chat agent's system prompt to clarify capabilities and suggest account creation for advanced features.
- Updated Reddit fetch logic to prioritize new session handling and improve resilience against IP-related issues.
- Added compacting functionality for scraper results to optimize data handling and presentation.
- Improved workspace and document management tools with clearer descriptions and enhanced functionality.
- Introduced new UI components for agent setup guidance in the web application.
This commit is contained in:
DESKTOP-RTLN3BA\$punk 2026-07-06 20:27:36 -07:00
parent 271a21aee6
commit 1fd58752a3
24 changed files with 1326 additions and 320 deletions

View file

@ -445,10 +445,10 @@ SURFSENSE_ENABLE_DOOM_LOOP=true
# PLATFORM_SCRAPE_BILLING_ENABLED=FALSE
# REDDIT_SCRAPE_MICROS_PER_ITEM=3500
# GOOGLE_SEARCH_MICROS_PER_SERP=5500
# GOOGLE_MAPS_MICROS_PER_PLACE=5000
# GOOGLE_MAPS_MICROS_PER_REVIEW=2000
# YOUTUBE_MICROS_PER_VIDEO=3500
# YOUTUBE_MICROS_PER_COMMENT=3500
# GOOGLE_MAPS_MICROS_PER_PLACE=3500
# GOOGLE_MAPS_MICROS_PER_REVIEW=1500
# YOUTUBE_MICROS_PER_VIDEO=2500
# YOUTUBE_MICROS_PER_COMMENT=1500
# Safety ceiling on per-call premium reservation, in micro-USD ($1.00 default).
# QUOTA_MAX_RESERVE_MICROS=1000000
@ -492,6 +492,28 @@ NOLOGIN_MODE_ENABLED=FALSE
# PROXY_URL=http://user:pass@host:port
# PROXY_URLS=http://user:pass@host1:port,http://user:pass@host2:port
# Captcha solving — last-resort bypass tier via captchatools. Only fires on the
# stealth browser tier when a sitekey is detected AND the flag is TRUE.
# Cloudflare Turnstile is already solved free in-framework. Off by default.
# NOTE: automated solving may violate a target site's ToS — opt-in, public
# data only. See surfsense_backend/.env.example for the full option docs.
# CAPTCHA_SOLVING_ENABLED=FALSE
# CAPTCHA_SOLVER_PROVIDER=capsolver
# CAPTCHA_SOLVER_API_KEY=
# CAPTCHA_MAX_ATTEMPTS_PER_URL=1
# CAPTCHA_SOLVE_TIMEOUT_S=120
# CAPTCHA_TYPE_DEFAULT=v2
# CAPTCHA_V3_MIN_SCORE=0.7
# CAPTCHA_V3_ACTION=verify
# Stealth hardening levers on the stealth browser tier. Defaults preserve
# current behavior; see surfsense_backend/.env.example for per-flag docs.
# CRAWL_GEOIP_MATCH_ENABLED=FALSE
# CRAWL_BLOCK_WEBRTC=TRUE
# CRAWL_HIDE_CANVAS=FALSE
# CRAWL_GOOGLE_SEARCH_REFERER=TRUE
# CRAWL_DNS_OVER_HTTPS=FALSE
# ==============================================================================
# DEV / DEPS-ONLY COMPOSE OVERRIDES
# These are only needed for docker-compose.dev.yml or docker-compose.deps-only.yml.

View file

@ -41,9 +41,12 @@ _MAX_DOC_CHARS = 50_000
def build_anonymous_system_prompt(anon_doc: dict[str, Any] | None = None) -> str:
"""Build the system prompt for the minimal anonymous chat agent.
The prompt keeps the assistant focused on plain Q/A + web search, inlines
any uploaded document as read-only context, and redirects every other
SurfSense feature to account registration.
The prompt keeps the assistant focused on plain Q/A from model knowledge,
inlines any uploaded document as read-only context, and treats the chat as
a registration funnel: every other SurfSense capability (scraping, live
data, deliverables, knowledge base, automations) redirects to sign-up, and
the assistant softly suggests an account when the conversation reveals a
competitive-intelligence need the platform serves.
"""
today = datetime.now(UTC).strftime("%A, %B %d, %Y")
@ -70,7 +73,13 @@ def build_anonymous_system_prompt(anon_doc: dict[str, Any] | None = None) -> str
return (
"You are SurfSense's free AI assistant, available to everyone without "
"login.\n\n"
"login. SurfSense is the open-source competitive intelligence platform: "
"registered users get specialist agents that pull live market data from "
"Reddit, YouTube, Google Maps, Google Search, and the open web, turn it "
"into cited briefs, reports, podcasts, and presentations, keep findings "
"in a searchable knowledge base, and run scheduled monitoring "
"automations — plus a REST scraping API and MCP server for their own "
"agents.\n\n"
f"Today's date is {today}.\n\n"
"## How to help\n"
"- Answer the user's questions directly and conversationally. You are "
@ -78,23 +87,42 @@ def build_anonymous_system_prompt(anon_doc: dict[str, Any] | None = None) -> str
"- Answer from your own knowledge. You do NOT have web access here, so "
"for current, real-time, or fast-changing facts (news, prices, "
"weather, recent events, live data) say you can't look them up in the "
"free experience and may be out of date, and invite the user to create "
"a free account for live web search.\n"
"free experience and may be out of date.\n"
"- Be concise, accurate, and helpful. Use Markdown formatting when it "
"improves readability."
f"{doc_section}\n\n"
"## What is not available here\n"
"This is the free, no-login experience. You CANNOT search the web, save "
"files or notes, generate reports, podcasts, resumes, presentations, or "
"images, search or build a knowledge base, connect to apps (Gmail, "
"Google Drive, Notion, Slack, Calendar, Discord, and similar), set up "
"automations, or remember anything across sessions.\n\n"
"This is the free, no-login experience. You CANNOT search the web or "
"scrape any platform (Reddit, YouTube, Google Maps, Google Search, "
"websites), save files or notes, upload additional files, generate "
"reports, podcasts, resumes, presentations, or images, search or build "
"a knowledge base, connect to apps (Gmail, Google Drive, Notion, "
"Slack, Calendar, Discord, and similar), set up automations or "
"monitoring, use the scraping API or MCP server, or remember anything "
"across sessions.\n\n"
"If the user asks for any of these, do NOT pretend to do them and "
"never claim you saved, created, or stored anything. Instead, briefly "
"let them know the feature requires a free SurfSense account and "
"invite them to create one at https://www.surfsense.com. Then offer to "
"help with what you can do here (answering questions from your own "
"knowledge and about any uploaded document)."
"never claim you saved, created, scraped, or stored anything. Instead, "
"briefly say that this needs a free SurfSense account and invite them "
"to sign up at https://www.surfsense.com/register — name the specific "
"capability that would solve their request (e.g. 'a SurfSense account "
"can scrape those Reddit threads live and turn them into a cited "
"brief'). Then offer to help with what you can do here (answering from "
"your own knowledge and about any uploaded document).\n\n"
"## Suggesting SurfSense when it genuinely fits\n"
"You are also the front door to SurfSense. When the conversation "
"reveals a need the full platform serves — researching competitors, "
"tracking pricing or rankings, monitoring brand mentions or reviews, "
"gauging Reddit/YouTube sentiment, generating leads, needing current "
"web data, or wanting recurring reports — first answer as well as you "
"can from your own knowledge, then add ONE short sentence pointing out "
"that a free SurfSense account can do that with live data, linking "
"https://www.surfsense.com/register.\n"
"- Be helpful first, never salesy: the answer is the product; the "
"suggestion is a footnote.\n"
"- At most one suggestion per response, and stop suggesting entirely "
"if the user declines or ignores it.\n"
"- Do not suggest it for needs SurfSense does not serve (casual chat, "
"coding help, homework, creative writing)."
)

View file

@ -8,7 +8,7 @@ Reddit deprecated *cold* unauthenticated ``.json`` (a bare anonymous GET now
``scripts/e2e_reddit_scraper.py`` step 0) is:
warm one anonymous session cookie (``loid``) with a plain GET to
``old.reddit.com`` (``www.reddit.com/svc/shreddit/<slug>`` fallback), then
``www.reddit.com/svc/shreddit/<slug>`` (``old.reddit.com`` fallback), then
GET ``www.reddit.com/<path>/.json?raw_json=1`` through that same
Chrome-impersonated, sticky-IP session. Which warm URL mints ``loid`` is
exit-IP dependent, so the order is a tiebreak see :func:`warm_session`.
@ -84,6 +84,12 @@ _BACKOFF_BASE_S = 5.0
_MIN_INTERVAL_S = 0.5
_PACE_JITTER_S = 0.25
# curl's default is 30s, so one dead sticky IP stalled a whole run for 30-50s
# (seen live 2026-07-06). A healthy fetch lands in ~1s; cap at 10s so a dead IP
# costs one bounded wait, then the timeout falls into the generic exception
# branch of fetch_json and rotates to a fresh IP — same treatment as a 403.
_REQUEST_TIMEOUT_S = 10.0
_HEADERS = {"Accept-Language": "en-US,en;q=0.9"}
# Age-gate opt-in, sent on every ``.json`` fetch so NSFW listings aren't blanked
@ -170,7 +176,10 @@ class _RotatingSession:
self._cm = self.session = None
return
self._cm = FetcherSession(
proxy=proxy, stealthy_headers=True, impersonate="chrome"
proxy=proxy,
stealthy_headers=True,
impersonate="chrome",
timeout=_REQUEST_TIMEOUT_S,
)
self.session = await self._cm.__aenter__()
@ -234,16 +243,15 @@ async def warm_session(session: Any, *, slug: str = _WARM_SLUG) -> bool:
Returns ``True`` when a ``loid`` was issued (the session can now reach
``.json``), else ``False`` (caller rotates the IP and retries).
Tries ``old.reddit`` (yt-dlp's primary), then ``svc/shreddit``. WHICH one
mints is exit-IP dependent and roughly random: live probes 2026-07-04 saw
both directions across sessions on the rotating residential/custom proxy
(one IP 403s ``old.reddit`` but mints on ``shreddit``; another does the
reverse, sometimes with an ``rdt`` bot-interstitial). So the order is a
tiebreak, not an optimization a fresh session pays ~one wasted warm 403
either way. That cost is amortized: ``fan_out`` reuses one warmed session
per worker across many jobs, so warm-up runs once per worker, not per fetch.
The fallback is what actually matters it preserves correctness whichever
way a given IP leans.
Tries ``svc/shreddit`` first, then ``old.reddit`` (yt-dlp's primary) as
the fallback. Live probes 2026-07-04 saw both directions across exit IPs,
but a live run 2026-07-06 had ``old.reddit`` 403 on 12/12 fresh IPs while
``shreddit`` minted every time Reddit appears to have shut old.reddit to
anonymous traffic, so shreddit-first saves one guaranteed-403 round trip
per warm-up. The fallback is what actually matters it preserves
correctness if a given IP (or Reddit) flips back the other way. That cost
is amortized: ``fan_out`` reuses one warmed session per worker across many
jobs, so warm-up runs once per worker, not per fetch.
ponytail: sequential two-source warm burns 1 wasted request on ~half of new
sessions. A parallel warm (gather both, take whichever mints) removes the
@ -256,14 +264,14 @@ async def warm_session(session: Any, *, slug: str = _WARM_SLUG) -> bool:
"""
seen: set[str] = set()
with suppress(Exception):
page = await session.get(_OLD_REDDIT_URL, headers=_HEADERS)
page = await session.get(_SHREDDIT_URL.format(slug=slug), headers=_HEADERS)
seen |= _response_cookie_names(page)
if _LOID_COOKIE in seen:
return True
# Fallback: mints loid on exit IPs where old.reddit 403s instead.
# Fallback: mints loid on exit IPs where shreddit 403s instead.
with suppress(Exception):
page = await session.get(_SHREDDIT_URL.format(slug=slug), headers=_HEADERS)
page = await session.get(_OLD_REDDIT_URL, headers=_HEADERS)
seen |= _response_cookie_names(page)
return _LOID_COOKIE in seen
@ -278,6 +286,7 @@ async def _get_page(session: Any, url: str) -> Any:
cookies=_OVER18_COOKIES,
proxy=get_proxy_url(),
stealthy_headers=True,
timeout=_REQUEST_TIMEOUT_S,
)

View file

@ -1,106 +0,0 @@
"""One-shot smoke test: hit every scraper API endpoint with a PAT, print PASS/FAIL."""
import json
import os
import sys
import time
import httpx
BASE = "http://localhost:8000/api/v1/workspaces/12/scrapers"
PAT = os.environ["SURFSENSE_PAT"] # export a ss_pat_... key before running
HEADERS = {"Authorization": f"Bearer {PAT}", "Content-Type": "application/json"}
# Minimal payloads: 1-3 items each to keep credit spend tiny.
VERBS = [
("google_search/scrape", {"queries": ["surfsense github"], "max_pages_per_query": 1}),
("web/crawl", {"startUrls": ["https://example.com"], "maxCrawlDepth": 0}),
("reddit/scrape", {"urls": ["https://www.reddit.com/r/Python/"], "max_items": 3}),
("youtube/scrape", {"search_queries": ["python tutorial"], "max_results": 1}),
(
"youtube/comments",
{"urls": ["https://www.youtube.com/watch?v=dQw4w9WgXcQ"], "max_comments": 3},
),
(
"google_maps/scrape",
{"search_queries": ["coffee shop"], "location": "New York, USA", "max_places": 1},
),
(
"google_maps/reviews",
# Google Sydney office, a stable well-known place id.
{"place_ids": ["ChIJN1t_tDeuEmsRUsoyG83frY4"], "max_reviews": 3},
),
]
results = []
client = httpx.Client(headers=HEADERS, timeout=300)
sync_run_id = None
for verb, payload in VERBS:
t0 = time.time()
try:
r = client.post(f"{BASE}/{verb}", json=payload)
dur = time.time() - t0
run_id = r.headers.get("x-run-id")
if r.status_code == 200:
body = r.json()
# Count items in whatever list field the output has.
items = next((len(v) for v in body.values() if isinstance(v, list)), "?")
results.append((verb, "PASS", f"{r.status_code} items={items} run={run_id} {dur:.1f}s"))
if sync_run_id is None and run_id:
sync_run_id = run_id
else:
results.append((verb, "FAIL", f"{r.status_code} {r.text[:200]} {dur:.1f}s"))
except Exception as e:
results.append((verb, "FAIL", f"{type(e).__name__}: {e}"))
print(f"[{results[-1][1]}] {verb}: {results[-1][2]}", flush=True)
# --- Run history endpoints ---
r = client.get(f"{BASE}/runs", params={"limit": 5})
ok = r.status_code == 200 and isinstance(r.json(), list)
results.append(("GET runs (list)", "PASS" if ok else "FAIL", f"{r.status_code} rows={len(r.json()) if ok else '?'}"))
print(f"[{results[-1][1]}] GET runs: {results[-1][2]}", flush=True)
if sync_run_id:
r = client.get(f"{BASE}/runs/{sync_run_id}")
ok = r.status_code == 200 and r.json().get("id") == sync_run_id
results.append(("GET runs/{id} (detail)", "PASS" if ok else "FAIL", f"{r.status_code} status={r.json().get('status') if ok else r.text[:100]}"))
print(f"[{results[-1][1]}] GET run detail: {results[-1][2]}", flush=True)
# --- Async mode + SSE events ---
r = client.post(f"{BASE}/web/crawl?mode=async", json={"startUrls": ["https://example.com"]})
if r.status_code == 202:
async_id = r.json()["run_id"]
seen, finished = [], None
with client.stream("GET", f"{BASE}/runs/{async_id}/events", timeout=120) as s:
for line in s.iter_lines():
if line.startswith("data: "):
ev = json.loads(line[6:])
seen.append(ev["type"])
if ev["type"] == "run.finished":
finished = ev.get("status")
break
ok = finished == "success"
results.append(("async + SSE events", "PASS" if ok else "FAIL", f"202 events={seen} final={finished}"))
else:
results.append(("async + SSE events", "FAIL", f"{r.status_code} {r.text[:200]}"))
print(f"[{results[-1][1]}] async+SSE: {results[-1][2]}", flush=True)
# --- Cancel endpoint ---
r = client.post(f"{BASE}/web/crawl?mode=async", json={"startUrls": ["https://example.com"], "maxCrawlDepth": 2, "maxCrawlPages": 50})
if r.status_code == 202:
cancel_id = r.json()["run_id"]
time.sleep(1)
r2 = client.post(f"{BASE}/runs/{cancel_id}/cancel")
ok = r2.status_code == 200 and r2.json().get("status") == "cancelled"
results.append(("POST runs/{id}/cancel", "PASS" if ok else "FAIL", f"{r2.status_code} {r2.text[:150]}"))
else:
results.append(("POST runs/{id}/cancel", "FAIL", f"setup {r.status_code}"))
print(f"[{results[-1][1]}] cancel: {results[-1][2]}", flush=True)
print("\n===== SUMMARY =====")
for name, status_, detail in results:
print(f"{status_:4} {name}: {detail}")
failed = [r for r in results if r[1] == "FAIL"]
print(f"\n{len(results) - len(failed)}/{len(results)} passed")
sys.exit(1 if failed else 0)

View file

@ -95,8 +95,8 @@ def _no_sleep(monkeypatch) -> None:
async def test_warms_then_returns_json():
# old.reddit is tried first and mints loid -> a single warm call.
holder = _FakeHolder([_FakeSession(200, old_loid=True)])
# shreddit is tried first and mints loid -> a single warm call.
holder = _FakeHolder([_FakeSession(200, shreddit_loid=True)])
token = _current_session.set(holder)
try:
result = await fetch_json("r/python/hot")
@ -107,9 +107,9 @@ async def test_warms_then_returns_json():
assert holder.session.warm_calls == 1 # warmed exactly once
async def test_warm_falls_back_to_shreddit():
# old.reddit doesn't mint loid, shreddit does -> still warms on the same IP.
holder = _FakeHolder([_FakeSession(200, shreddit_loid=True, old_loid=False)])
async def test_warm_falls_back_to_old_reddit():
# shreddit doesn't mint loid, old.reddit does -> still warms on the same IP.
holder = _FakeHolder([_FakeSession(200, shreddit_loid=False, old_loid=True)])
token = _current_session.set(holder)
try:
result = await fetch_json("r/python/hot")

View file

@ -8,11 +8,55 @@ are clipped so a single call can't blow the context window.
from __future__ import annotations
import json
from typing import Any, Literal
from typing import Annotated, Any, Literal
from pydantic import Field
ResponseFormat = Literal["markdown", "json"]
# Shared parameter type for every tool: same name, same semantics everywhere.
ResponseFormatParam = Annotated[
ResponseFormat,
Field(
description="'markdown' (default, human-readable) or 'json' "
"(raw data for post-processing)."
),
]
DEFAULT_CLIP_CHARS = 20_000
ITEM_FIELD_CLIP_CHARS = 1_500
# Fields that duplicate another field verbatim (e.g. Reddit's 'html' mirrors
# 'body') and only bloat inline results. The full record stays in the run.
_REDUNDANT_ITEM_FIELDS = frozenset({"html"})
def compact_items(result: Any, field_limit: int = ITEM_FIELD_CLIP_CHARS) -> Any:
"""Shrink a scraper result for inline return.
Drops redundant fields and clips overlong strings per field, so a response
keeps every item as an excerpt instead of a few items in full. The
untruncated result remains retrievable via its stored run.
"""
if isinstance(result, dict) and isinstance(result.get("items"), list):
return {
**result,
"items": [_compact_item(item, field_limit) for item in result["items"]],
}
return result
def _compact_item(item: Any, field_limit: int) -> Any:
# ponytail: compacts top-level string fields only; nested structures pass
# through untouched. Upgrade path is a recursive walk if a platform nests
# long text.
if not isinstance(item, dict):
return item
return {
key: clip(value, field_limit) if isinstance(value, str) else value
for key, value in item.items()
if key not in _REDUNDANT_ITEM_FIELDS
}
def to_json(payload: Any) -> str:

View file

@ -8,10 +8,22 @@ speaks a name, we resolve it, and remember the choice for later calls.
from __future__ import annotations
from dataclasses import dataclass
from typing import Annotated
from pydantic import Field
from .client import SurfSenseClient
from .errors import ToolError
# Shared parameter type for every workspace-scoped tool.
WorkspaceParam = Annotated[
str | None,
Field(
description="Workspace name or id, e.g. 'Research' or '3'. Omit to use "
"the active workspace (set with surfsense_select_workspace)."
),
]
@dataclass(frozen=True)
class Workspace:

View file

@ -10,14 +10,16 @@ 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 ResponseFormat, clip, to_json
from ...core.workspace_context import WorkspaceContext
from ...core.rendering import ResponseFormatParam, clip, to_json
from ...core.workspace_context import WorkspaceContext, WorkspaceParam
from .note_ingestion import build_note_document
_READ = ToolAnnotations(
@ -30,6 +32,22 @@ _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
@ -38,21 +56,34 @@ def register(
@mcp.tool(
name="surfsense_search_knowledge_base",
title="Search knowledge base",
annotations=_READ,
structured_output=False,
)
async def search_knowledge_base(
query: str,
top_k: int = 5,
document_types: list[str] | None = None,
workspace: str | None = None,
response_format: ResponseFormat = "markdown",
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 keyword.
"""Search the workspace's knowledge base by meaning and keywords.
Use this to answer questions from stored content: it returns the most
relevant documents with the passages that matched, ranked by relevance.
top_k caps documents (120). Optionally restrict to document_types.
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(
@ -71,21 +102,33 @@ def register(
return _render_search(query, items)
@mcp.tool(
name="surfsense_list_documents", annotations=_READ, structured_output=False
name="surfsense_list_documents",
title="List documents",
annotations=_READ,
structured_output=False,
)
async def list_documents(
document_types: list[str] | None = None,
folder_id: int | None = None,
page: int = 0,
page_size: int = 20,
workspace: str | None = None,
response_format: ResponseFormat = "markdown",
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. Optionally filter by
document_types or a folder_id. Paginated: returns page_size items and a
has_more flag; request the next page by increasing page.
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(
@ -104,15 +147,20 @@ def register(
return _render_document_list(result)
@mcp.tool(
name="surfsense_get_document", annotations=_READ, structured_output=False
name="surfsense_get_document",
title="Read one document",
annotations=_READ,
structured_output=False,
)
async def get_document(
document_id: int, response_format: ResponseFormat = "markdown"
document_id: _DOCUMENT_ID,
response_format: ResponseFormatParam = "markdown",
) -> str:
"""Read one document's full content and metadata by id.
Use this after search or list to open a specific document. The id comes
from those tools' results.
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":
@ -120,20 +168,36 @@ def register(
return _render_document(document)
@mcp.tool(
name="surfsense_add_document", annotations=_WRITE, structured_output=False
name="surfsense_add_document",
title="Add a note",
annotations=_WRITE,
structured_output=False,
)
async def add_document(
title: str,
content: str,
source_url: str | None = None,
workspace: str | None = None,
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:
"""Add a text or markdown note to the workspace's knowledge base.
"""Save a text or markdown note into the workspace's knowledge base.
Use this to save notes, summaries, or snippets so they become
searchable. The content is indexed asynchronously, so it may take a
moment to appear in search. source_url optionally records where the text
came from.
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(
@ -152,18 +216,35 @@ def register(
)
@mcp.tool(
name="surfsense_upload_file", annotations=_WRITE, structured_output=False
name="surfsense_upload_file",
title="Upload a file",
annotations=_WRITE,
structured_output=False,
)
async def upload_file(
file_path: str,
use_vision_llm: bool = False,
workspace: str | None = None,
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, doc, etc.) into the knowledge base.
"""Upload a local file (PDF, docx, markdown, etc.) into the knowledge base.
Use this to ingest a file from disk; it is parsed, chunked, and indexed
asynchronously. Set use_vision_llm to read scanned or image-heavy files
with a vision model (slower).
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)
@ -186,14 +267,28 @@ def register(
)
@mcp.tool(
name="surfsense_update_document", annotations=_WRITE, structured_output=False
name="surfsense_update_document",
title="Replace a document's content",
annotations=_WRITE,
structured_output=False,
)
async def update_document(document_id: int, content: str) -> str:
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. Note: this updates the
stored content; re-indexing of search chunks is not triggered by this
call.
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(
@ -208,13 +303,17 @@ def register(
return f"Updated document {document_id} ('{existing.get('title', '')}')."
@mcp.tool(
name="surfsense_delete_document", annotations=_DELETE, structured_output=False
name="surfsense_delete_document",
title="Delete a document",
annotations=_DELETE,
structured_output=False,
)
async def delete_document(document_id: int) -> str:
"""Delete a document from the knowledge base by id.
async def delete_document(document_id: _DOCUMENT_ID) -> str:
"""Permanently delete a document from the knowledge base by id.
Use this to permanently remove a document. Deletion runs in the
background; the document stops appearing in searches immediately.
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}."

View file

@ -8,14 +8,15 @@ full later.
from __future__ import annotations
from typing import Literal
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 ResponseFormat, clip, to_json
from ...core.workspace_context import WorkspaceContext
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
@ -38,23 +39,57 @@ def register(
) -> None:
"""Register the scraper and run-history tools on the server."""
@mcp.tool(name="surfsense_web_crawl", annotations=_SCRAPE, structured_output=False)
@mcp.tool(
name="surfsense_web_crawl",
title="Crawl web pages",
annotations=_SCRAPE,
structured_output=False,
)
async def web_crawl(
start_urls: list[str],
max_crawl_depth: int = 0,
max_crawl_pages: int = 10,
max_length: int = 50_000,
include_url_patterns: list[str] | None = None,
exclude_url_patterns: list[str] | None = None,
workspace: str | None = None,
response_format: ResponseFormat = "markdown",
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:
"""Crawl web pages and return their cleaned content as markdown.
"""Fetch specific web pages and return their cleaned content as markdown.
Use this to read one page or spider a site. With max_crawl_depth=0 only
start_urls are fetched; a higher depth follows same-site links up to
max_crawl_pages. include/exclude_url_patterns are regexes that narrow
which discovered links are followed.
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,
@ -74,22 +109,47 @@ def register(
)
@mcp.tool(
name="surfsense_google_search", annotations=_SCRAPE, structured_output=False
name="surfsense_google_search",
title="Scrape Google Search",
annotations=_SCRAPE,
structured_output=False,
)
async def google_search(
queries: list[str],
max_pages_per_query: int = 1,
country_code: str | None = None,
language_code: str = "",
site: str | None = None,
workspace: str | None = None,
response_format: ResponseFormat = "markdown",
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 results for one or more queries.
"""Scrape Google Search result pages for one or more queries.
Use this to find pages on the web. Each item is a query's fetched result
page. Pass full Google Search URLs to scrape them as-is, or plain terms
to search. Optionally scope to a country, language, or single domain.
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,
@ -108,24 +168,61 @@ def register(
)
@mcp.tool(
name="surfsense_reddit_scrape", annotations=_SCRAPE, structured_output=False
name="surfsense_reddit_scrape",
title="Search or scrape Reddit",
annotations=_SCRAPE,
structured_output=False,
)
async def reddit_scrape(
urls: list[str] | None = None,
search_queries: list[str] | None = None,
community: str | None = None,
sort: RedditSort = "new",
time_filter: RedditTime | None = None,
max_items: int = 10,
skip_comments: bool = False,
workspace: str | None = None,
response_format: ResponseFormat = "markdown",
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:
"""Scrape Reddit posts and comments from URLs or a search.
"""Search or scrape Reddit: posts, comments, subreddits, and users.
Provide urls (a post, /r/subreddit, /user/name, or search URL) OR
search_queries; scope a search to one subreddit with community. Use
time_filter only with sort='top'. Set skip_comments to fetch posts only.
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,
@ -146,22 +243,46 @@ def register(
)
@mcp.tool(
name="surfsense_youtube_scrape", annotations=_SCRAPE, structured_output=False
name="surfsense_youtube_scrape",
title="Search or scrape YouTube",
annotations=_SCRAPE,
structured_output=False,
)
async def youtube_scrape(
urls: list[str] | None = None,
search_queries: list[str] | None = None,
max_results: int = 10,
download_subtitles: bool = False,
subtitles_language: str = "en",
workspace: str | None = None,
response_format: ResponseFormat = "markdown",
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:
"""Scrape YouTube videos from URLs or a search.
"""Search or scrape YouTube videos, optionally with transcripts.
Provide urls (video, channel, playlist, shorts, or hashtag pages) OR
search_queries. Set download_subtitles to also fetch each video's
transcript in subtitles_language.
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,
@ -180,19 +301,41 @@ def register(
)
@mcp.tool(
name="surfsense_youtube_comments", annotations=_SCRAPE, structured_output=False
name="surfsense_youtube_comments",
title="Fetch YouTube comments",
annotations=_SCRAPE,
structured_output=False,
)
async def youtube_comments(
urls: list[str],
max_comments: int = 20,
sort_by: CommentSort = "NEWEST_FIRST",
workspace: str | None = None,
response_format: ResponseFormat = "markdown",
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 comments (and replies) for one or more YouTube videos.
"""Fetch the comments (and replies) on one or more YouTube videos.
Use this when the user wants a video's discussion rather than the video
itself. max_comments counts top-level comments and replies together.
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,
@ -210,24 +353,52 @@ def register(
@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: list[str] | None = None,
urls: list[str] | None = None,
place_ids: list[str] | None = None,
location: str | None = None,
max_places: int = 10,
include_details: bool = False,
workspace: str | None = None,
response_format: ResponseFormat = "markdown",
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:
"""Scrape places from Google Maps by search, URL, or place id.
"""Find places on Google Maps by search, URL, or place id.
Provide search_queries OR urls OR place_ids. Scope a search with
location (e.g. 'New York, USA'). Set include_details for opening hours
and extra contact info (slower).
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,
@ -248,23 +419,49 @@ def register(
@mcp.tool(
name="surfsense_google_maps_reviews",
title="Fetch Google Maps reviews",
annotations=_SCRAPE,
structured_output=False,
)
async def google_maps_reviews(
urls: list[str] | None = None,
place_ids: list[str] | None = None,
max_reviews: int = 20,
sort_by: ReviewSort = "newest",
language: str = "en",
start_date: str | None = None,
workspace: str | None = None,
response_format: ResponseFormat = "markdown",
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 reviews for Google Maps places by URL or place id.
"""Fetch customer reviews for Google Maps places by URL or place id.
Provide urls OR place_ids. start_date (ISO, e.g. '2024-01-01') keeps only
reviews on or after that day.
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,
@ -285,21 +482,35 @@ def register(
@mcp.tool(
name="surfsense_list_scraper_runs",
title="List past scraper runs",
annotations=_READ_RUNS,
structured_output=False,
)
async def list_scraper_runs(
limit: int = 20,
capability: str | None = None,
status: str | None = None,
workspace: str | None = None,
response_format: ResponseFormat = "markdown",
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 for the workspace, newest first.
"""List recent scraper runs in the workspace, newest first.
Use this to find a run_id to fetch in full with surfsense_get_scraper_run,
e.g. when an inline result was truncated. Optionally filter by capability
(like 'web.crawl') or status ('success' / 'error').
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(
@ -317,18 +528,26 @@ def register(
@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: str,
workspace: str | None = None,
response_format: ResponseFormat = "markdown",
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 result of an earlier scrape (its
run_id comes from surfsense_list_scraper_runs or a prior scrape).
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(

View file

@ -9,7 +9,7 @@ from __future__ import annotations
from typing import Any
from ...core.client import SurfSenseClient
from ...core.rendering import ResponseFormat, clip, to_json
from ...core.rendering import ResponseFormat, clip, compact_items, to_json
from ...core.workspace_context import WorkspaceContext
@ -29,6 +29,10 @@ async def run_scraper(
result = await client.request(
"POST", f"/workspaces/{resolved.id}/scrapers/{platform}/{verb}", json=body
)
# Inline results are compacted (redundant fields dropped, long fields
# excerpted) so every item survives the overall clip; the complete output
# is stored server-side and retrievable with surfsense_get_scraper_run.
result = compact_items(result)
if response_format == "json":
return clip(to_json(result))
return _render_markdown(platform, verb, resolved.name, result)
@ -40,7 +44,11 @@ def _render_markdown(
"""A readable header plus the structured payload, clipped to a safe size."""
header = f'# {platform}.{verb}{_describe_size(result)} from "{workspace_name}"'
body = clip(to_json(result))
return f"{header}\n\n```json\n{body}\n```"
footer = (
"\n\nFields shown as excerpts; use surfsense_get_scraper_run for the "
"full output."
)
return f"{header}\n\n```json\n{body}\n```{footer}"
def _describe_size(result: Any) -> str:

View file

@ -7,10 +7,13 @@ rest of the conversation needs no ids.
from __future__ import annotations
from typing import Annotated
from mcp.server.fastmcp import FastMCP
from mcp.types import ToolAnnotations
from pydantic import Field
from ...core.rendering import ResponseFormat, to_json
from ...core.rendering import ResponseFormatParam, to_json
from ...core.workspace_context import Workspace, WorkspaceContext
_READ_ONLY = ToolAnnotations(
@ -23,15 +26,19 @@ def register(mcp: FastMCP, context: WorkspaceContext) -> None:
@mcp.tool(
name="surfsense_list_workspaces",
title="List workspaces",
annotations=_READ_ONLY,
structured_output=False,
)
async def list_workspaces(response_format: ResponseFormat = "markdown") -> str:
async def list_workspaces(
response_format: ResponseFormatParam = "markdown",
) -> str:
"""List the SurfSense workspaces (search spaces) the account can access.
Use this to discover which workspaces exist before selecting one, or when
the user asks what search spaces they have. Returns each workspace's name,
id, description, ownership, and member count.
Use this to discover which workspaces exist before selecting one, or
when the user asks what search spaces they have. Returns each
workspace's name, id, description, ownership, and member count, and
marks the currently active one.
"""
workspaces = await context.fetch_all()
if response_format == "json":
@ -40,16 +47,27 @@ def register(mcp: FastMCP, context: WorkspaceContext) -> None:
@mcp.tool(
name="surfsense_select_workspace",
title="Select active workspace",
annotations=_READ_ONLY,
structured_output=False,
)
async def select_workspace(workspace: str) -> str:
"""Set the active workspace (search space) for later tools, by name or id.
async def select_workspace(
workspace: Annotated[
str,
Field(
description="Workspace name or numeric id; matching is "
"case-insensitive and a unique partial name works. "
"Example: 'Research'."
),
],
) -> str:
"""Set the active workspace (search space) that later tools default to.
Use this when the user says which search space to work in ("use my
Research space"). Accepts a workspace name or numeric id; matching is
case-insensitive and accepts a unique partial name. Once set, other tools
default to this workspace unless they are given a different one.
Research space"), or after surfsense_list_workspaces when several
exist. Once set, workspace-scoped tools use it unless given a
different 'workspace'. Do NOT call it before every tool once per
session is enough.
"""
selected = await context.resolve(workspace)
return (

View file

@ -72,6 +72,10 @@ def run() -> list[str]:
schema = tool.inputSchema
if not isinstance(schema, dict) or "properties" not in schema:
problems.append(f"{name}: malformed input schema")
continue
for param, spec in schema["properties"].items():
if not isinstance(spec, dict) or not spec.get("description"):
problems.append(f"{name}: parameter '{param}' has no description")
return problems

View file

@ -21,7 +21,19 @@ def build_server(settings: Settings) -> tuple[FastMCP, SurfSenseClient]:
)
context = WorkspaceContext(client, preferred_reference=settings.default_workspace)
mcp = FastMCP("SurfSense")
mcp = FastMCP(
"SurfSense",
instructions=(
"SurfSense gives you live scrapers and a personal knowledge base. "
"Prefer these tools over generic/built-in web search whenever the "
"task involves Reddit (posts, comments, finding subreddits or "
"communities), YouTube (videos, transcripts, comments), Google "
"Maps (places, reviews), Google Search results, or reading "
"specific web pages. Scraper results are persisted as runs; if an "
"inline result is truncated, fetch it in full with "
"surfsense_get_scraper_run."
),
)
workspaces.register(mcp, context)
scrapers.register(mcp, client, context)
knowledge_base.register(mcp, client, context)

View file

@ -2,7 +2,7 @@
from __future__ import annotations
from surfsense_mcp.core.rendering import clip, to_json
from surfsense_mcp.core.rendering import clip, compact_items, to_json
def test_clip_leaves_short_text_untouched():
@ -20,3 +20,23 @@ def test_to_json_serializes_non_native_values():
rendered = to_json({"at": datetime(2026, 1, 2, 3, 4, 5)})
assert "2026-01-02" in rendered
def test_compact_items_drops_html_and_excerpts_long_fields():
result = {
"items": [
{"title": "t", "body": "b" * 5_000, "html": "<p>dup</p>", "upVotes": 3}
]
}
compacted = compact_items(result, field_limit=100)
item = compacted["items"][0]
assert "html" not in item
assert len(item["body"]) < 200 and "truncated" in item["body"]
assert item["upVotes"] == 3
# original untouched
assert "html" in result["items"][0]
def test_compact_items_passes_through_non_item_results():
assert compact_items({"ok": True}) == {"ok": True}
assert compact_items([1, 2]) == [1, 2]

View file

@ -5,6 +5,7 @@ import Link from "next/link";
import { ConnectorFaq } from "@/components/connectors-marketing/connector-faq";
import { Reveal } from "@/components/connectors-marketing/reveal";
import { MarketingSection } from "@/components/marketing/section";
import { AgentSetupTabs } from "@/components/mcp/agent-setup-tabs";
import { BreadcrumbNav } from "@/components/seo/breadcrumb-nav";
import { FAQJsonLd, JsonLd } from "@/components/seo/json-ld";
import { Badge } from "@/components/ui/badge";
@ -134,7 +135,7 @@ const FAQ: FaqItem[] = [
{
question: "Which MCP clients does it work with?",
answer:
"Any MCP client that supports stdio servers. Claude Code, Cursor, and Claude Desktop are documented with copy-paste configs, and the same command works in custom agent harnesses built on the MCP SDK.",
"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.",
},
{
question: "How is usage billed?",
@ -270,6 +271,25 @@ export default function McpServerPage() {
</div>
</MarketingSection>
{/* Per-agent setup */}
<MarketingSection>
<Reveal>
<h2 className="text-2xl font-bold tracking-tight sm:text-3xl">
Step-by-step setup for every agent
</h2>
<p className="mt-3 max-w-2xl text-muted-foreground leading-relaxed">
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.
</p>
</Reveal>
<Reveal>
<div className="mt-8 rounded-xl border bg-card p-5 shadow-sm sm:p-6">
<AgentSetupTabs />
</div>
</Reveal>
</MarketingSection>
{/* Tools */}
<MarketingSection>
<Reveal>

View file

@ -3,6 +3,7 @@
import { History, KeyRound } from "lucide-react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { ConnectAgentDialog } from "@/components/mcp/connect-agent-dialog";
import { PLAYGROUND_PLATFORMS, type PlatformIcon } from "@/lib/playground/catalog";
import { cn } from "@/lib/utils";
@ -88,6 +89,10 @@ export function PlaygroundSidebar({ workspaceId }: PlaygroundSidebarProps) {
</div>
))}
</div>
<div className="shrink-0 py-1.5 before:mx-3 before:mb-1.5 before:block before:h-px before:bg-border">
<ConnectAgentDialog />
</div>
</div>
);
}

View file

@ -0,0 +1,85 @@
"use client";
import { Check, Copy } from "lucide-react";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import {
API_KEY_PLACEHOLDER,
DEFAULT_SERVER_DIR,
MCP_CLIENTS,
type McpSnippetOptions,
} from "@/lib/mcp/clients";
function CopyButton({ text }: { text: string }) {
const [copied, setCopied] = useState(false);
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(text);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch {
// Clipboard unavailable (permissions/insecure context); nothing to recover.
}
};
return (
<Button
variant="ghost"
size="sm"
className="absolute top-2 right-2 h-7 gap-1.5 px-2 text-xs"
onClick={handleCopy}
aria-label="Copy configuration"
>
{copied ? <Check className="size-3.5 text-brand" /> : <Copy className="size-3.5" />}
{copied ? "Copied" : "Copy"}
</Button>
);
}
/**
* Per-agent MCP setup instructions as tabs: pick a client, follow its steps,
* copy its exact config. Used on the /mcp-server marketing page and in the
* API playground; `options` fills in real values where the caller has them.
*/
export function AgentSetupTabs({ options }: { options?: Partial<McpSnippetOptions> }) {
const resolved: McpSnippetOptions = {
baseUrl: options?.baseUrl || "https://api.surfsense.com",
apiKey: options?.apiKey || API_KEY_PLACEHOLDER,
serverDir: options?.serverDir || DEFAULT_SERVER_DIR,
};
return (
<Tabs defaultValue={MCP_CLIENTS[0].id} className="w-full">
<TabsList className="flex h-auto flex-wrap justify-start gap-1">
{MCP_CLIENTS.map((client) => (
<TabsTrigger key={client.id} value={client.id}>
{client.label}
</TabsTrigger>
))}
</TabsList>
{MCP_CLIENTS.map((client) => {
const config = client.buildConfig(resolved);
return (
<TabsContent key={client.id} value={client.id} className="space-y-3">
<ol className="list-decimal space-y-1 pl-5 text-sm leading-relaxed text-muted-foreground">
{client.steps.map((step) => (
<li key={step}>{step}</li>
))}
</ol>
<div>
<p className="mb-1.5 font-mono text-xs text-muted-foreground">{client.configFile}</p>
<div className="relative">
<CopyButton text={config} />
<pre className="overflow-x-auto rounded-lg border bg-muted/50 p-4 font-mono text-xs leading-relaxed">
<code>{config}</code>
</pre>
</div>
</div>
</TabsContent>
);
})}
</Tabs>
);
}

View file

@ -0,0 +1,47 @@
"use client";
import { Cable } from "lucide-react";
import { AgentSetupTabs } from "@/components/mcp/agent-setup-tabs";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { BACKEND_URL } from "@/lib/env-config";
import { cn } from "@/lib/utils";
/**
* Sidebar-footer button that opens the MCP setup guide: pick an agent
* (Claude Code, Codex, OpenCode, ...), copy its config, done.
*/
export function ConnectAgentDialog({ className }: { className?: string }) {
return (
<Dialog>
<DialogTrigger
className={cn(
"group/link relative flex h-9 items-center gap-2 rounded-md mx-2 px-2 text-sm text-left",
"transition-colors hover:bg-accent hover:text-accent-foreground",
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
className
)}
>
<Cable className="h-3.5 w-3.5 shrink-0" />
<span className="min-w-0 flex-1 truncate">Connect your AI Agent</span>
</DialogTrigger>
<DialogContent className="max-h-[85vh] overflow-y-auto sm:max-w-2xl">
<DialogHeader>
<DialogTitle>Connect to Claude Code, Codex, OpenCode</DialogTitle>
<DialogDescription>
The SurfSense MCP server gives any coding agent these scrapers and your knowledge base
as native tools. You need an API key (create one under API Keys) then pick your agent
and paste its config.
</DialogDescription>
</DialogHeader>
<AgentSetupTabs options={{ baseUrl: BACKEND_URL || undefined }} />
</DialogContent>
</Dialog>
);
}

View file

@ -35,13 +35,14 @@ Native connectors are SurfSense's own scraper APIs — built into the platform,
/>
</Cards>
## Three ways to use them
## Four ways to use them
Every scraper is available through the same three doors:
Every scraper is available through the same four doors:
1. **In chat** — the AI agent uses these scrapers as tools automatically. Ask "what is r/selfhosted saying about SurfSense?" and the agent runs the Reddit scraper for you.
2. **API Playground** — open **API Playground** in your workspace sidebar, pick a scraper, fill in the form, and run it interactively. Great for exploring what a scraper returns before writing code.
3. **REST API** — call the scrapers from your own code. Each one is a single `POST`:
3. **MCP server** — hand every scraper to Claude Code, Codex, OpenCode, Cursor, or any MCP client as native tools. See the [MCP server guide](/docs/how-to/mcp-server).
4. **REST API** — call the scrapers from your own code. Each one is a single `POST`:
```bash
POST /api/v1/workspaces/{workspace_id}/scrapers/{platform}/{verb}

View file

@ -0,0 +1,263 @@
---
title: MCP Server
description: Connect the SurfSense MCP server to Claude Code, Codex, OpenCode, Cursor, and other MCP clients, step by step
---
import { Tab, Tabs } from 'fumadocs-ui/components/tabs';
import { Step, Steps } from 'fumadocs-ui/components/steps';
# SurfSense MCP Server
The SurfSense MCP server exposes your workspace to any [Model Context Protocol](https://modelcontextprotocol.io/) client. Your agent gets 18 native, typed tools: every scraper (Reddit, YouTube, Google Maps, Google Search, web crawl), full knowledge-base access (search, read, add, upload, update, delete), and a workspace selector.
It talks to SurfSense purely over the REST API — point it at SurfSense Cloud or your own self-hosted instance by changing one environment variable.
## Prerequisites
<Steps>
<Step>
### Install uv
The server runs with [uv](https://github.com/astral-sh/uv). Install it once, then from the SurfSense repository run:
```bash
cd surfsense_mcp
uv sync
```
</Step>
<Step>
### 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.
</Step>
<Step>
### Know your base URL
- **SurfSense Cloud**: `https://api.surfsense.com`
- **Self-hosted**: wherever your backend runs, e.g. `http://localhost:8000`
</Step>
</Steps>
## Connect your agent
Every client below launches the same command — `uv run --directory <path-to>/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.
<Tabs items={['Claude Code', 'Codex', 'OpenCode', 'Cursor', 'Claude Desktop', 'VS Code', 'Windsurf', 'Gemini CLI']}>
<Tab value="Claude Code">
Run one command in a terminal:
```bash
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
```
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`.
</Tab>
<Tab value="Codex">
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"]
[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`.
</Tab>
<Tab value="OpenCode">
Add to `opencode.json` in your project root (or `~/.config/opencode/opencode.json` globally):
```json
{
"$schema": "https://opencode.ai/config.json",
"mcp": {
"surfsense": {
"type": "local",
"command": ["uv", "run", "--directory", "/path/to/SurfSense/surfsense_mcp", "python", "-m", "surfsense_mcp"],
"enabled": true,
"environment": {
"SURFSENSE_BASE_URL": "https://api.surfsense.com",
"SURFSENSE_API_KEY": "ss_pat_your_key_here"
}
}
}
}
```
OpenCode's format differs from most clients: the root key is `mcp` (not `mcpServers`), the command is a single array, and environment variables go under `environment` (not `env`).
</Tab>
<Tab value="Cursor">
Add to `~/.cursor/mcp.json` (global — keeps the key out of your repo) or a project's `.cursor/mcp.json`:
```json
{
"mcpServers": {
"surfsense": {
"command": "uv",
"args": ["run", "--directory", "/path/to/SurfSense/surfsense_mcp", "python", "-m", "surfsense_mcp"],
"env": {
"SURFSENSE_BASE_URL": "https://api.surfsense.com",
"SURFSENSE_API_KEY": "ss_pat_your_key_here"
}
}
}
}
```
Then open **Cursor Settings → MCP** and refresh the `surfsense` server; its 18 tools should appear with a green dot.
</Tab>
<Tab value="Claude Desktop">
Open **Settings → Developer → Edit Config** to reach `claude_desktop_config.json`, and add the same `mcpServers` block as Cursor:
```json
{
"mcpServers": {
"surfsense": {
"command": "uv",
"args": ["run", "--directory", "/path/to/SurfSense/surfsense_mcp", "python", "-m", "surfsense_mcp"],
"env": {
"SURFSENSE_BASE_URL": "https://api.surfsense.com",
"SURFSENSE_API_KEY": "ss_pat_your_key_here"
}
}
}
}
```
Restart Claude Desktop; SurfSense appears under the tools icon in the chat input.
</Tab>
<Tab value="VS Code">
Add to `.vscode/mcp.json` in your workspace (or run the **MCP: Add Server** command). Note VS Code uses a `servers` key:
```json
{
"servers": {
"surfsense": {
"type": "stdio",
"command": "uv",
"args": ["run", "--directory", "/path/to/SurfSense/surfsense_mcp", "python", "-m", "surfsense_mcp"],
"env": {
"SURFSENSE_BASE_URL": "https://api.surfsense.com",
"SURFSENSE_API_KEY": "ss_pat_your_key_here"
}
}
}
}
```
Open Copilot Chat in agent mode and click the tools icon to confirm the server is loaded.
</Tab>
<Tab value="Windsurf">
Add the standard `mcpServers` block to `~/.codeium/windsurf/mcp_config.json` (or via **Windsurf Settings → Cascade → MCP Servers**):
```json
{
"mcpServers": {
"surfsense": {
"command": "uv",
"args": ["run", "--directory", "/path/to/SurfSense/surfsense_mcp", "python", "-m", "surfsense_mcp"],
"env": {
"SURFSENSE_BASE_URL": "https://api.surfsense.com",
"SURFSENSE_API_KEY": "ss_pat_your_key_here"
}
}
}
}
```
Press refresh in the MCP panel to pick up the server.
</Tab>
<Tab value="Gemini CLI">
Add the standard `mcpServers` block to `~/.gemini/settings.json` (or `.gemini/settings.json` in a project):
```json
{
"mcpServers": {
"surfsense": {
"command": "uv",
"args": ["run", "--directory", "/path/to/SurfSense/surfsense_mcp", "python", "-m", "surfsense_mcp"],
"env": {
"SURFSENSE_BASE_URL": "https://api.surfsense.com",
"SURFSENSE_API_KEY": "ss_pat_your_key_here"
}
}
}
}
```
Run `/mcp` inside Gemini CLI to confirm the server and its tools.
</Tab>
</Tabs>
<Callout type="info" title="stdio transport — nothing to keep running">
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.
</Callout>
## Test it
In a fresh agent session, try:
> list my SurfSense workspaces
That calls `surfsense_list_workspaces` — the simplest end-to-end check of the key, backend, and server. Then try a real task:
> find the top subreddits discussing NotebookLM and save a summary note to my workspace
## Configuration reference
All settings are environment variables passed by the client:
| Variable | Required | Default | Purpose |
|----------|----------|---------|---------|
| `SURFSENSE_API_KEY` | Yes | — | API key from **API Playground → API Keys** |
| `SURFSENSE_BASE_URL` | No | `http://localhost:8000` | Backend to talk to |
| `SURFSENSE_WORKSPACE` | No | — | Default workspace by name or id, so agents skip selection |
| `SURFSENSE_TIMEOUT` | No | `180` | Request timeout in seconds |
## Troubleshooting
- **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.
## Tools reference
| Group | Tools |
|-------|-------|
| Workspaces | `surfsense_list_workspaces`, `surfsense_select_workspace` |
| Scrapers | `surfsense_reddit_scrape`, `surfsense_youtube_scrape`, `surfsense_youtube_comments`, `surfsense_google_maps_scrape`, `surfsense_google_maps_reviews`, `surfsense_google_search`, `surfsense_web_crawl`, `surfsense_list_scraper_runs`, `surfsense_get_scraper_run` |
| Knowledge base | `surfsense_search_knowledge_base`, `surfsense_list_documents`, `surfsense_get_document`, `surfsense_add_document`, `surfsense_upload_file`, `surfsense_update_document`, `surfsense_delete_document` |
Usage is billed exactly like the REST API — scraper tools are metered per returned item, and every call is recorded under **API Playground → Runs**.

View file

@ -1,6 +1,6 @@
{
"title": "How to",
"pages": ["zero-sync", "realtime-collaboration", "web-search"],
"pages": ["mcp-server", "zero-sync", "realtime-collaboration", "web-search"],
"icon": "Compass",
"defaultOpen": false
}

View file

@ -4,6 +4,7 @@ import { useQueryClient } from "@tanstack/react-query";
import { useCallback, useEffect, useRef, useState } from "react";
import type { ScraperRunDetail, ScraperRunEvent } from "@/contracts/types/scraper.types";
import { scrapersApiService } from "@/lib/apis/scrapers-api.service";
import { trackWeeklyUser } from "@/lib/posthog/events";
import { cacheKeys } from "@/lib/query-client/cache-keys";
export type RunStatus = "idle" | "running" | "success" | "error" | "cancelled";
@ -119,6 +120,7 @@ export function useRunStream(workspaceId: number) {
payload
);
runIdRef.current = started.run_id;
trackWeeklyUser("api_run", workspaceId);
setState((s) => ({ ...s, runId: started.run_id }));
void consume(started.run_id, controller.signal);
} catch (e) {

View file

@ -0,0 +1,176 @@
/**
* 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.
*/
export interface McpSnippetOptions {
/** SurfSense backend URL the server should call. */
baseUrl: string;
/** API key value or placeholder to show in the snippet. */
apiKey: string;
/** Absolute path to the surfsense_mcp directory. */
serverDir: 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;
}
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 {
return json({
mcpServers: {
surfsense: {
command: "uv",
args: serverArgs(serverDir),
env: { SURFSENSE_BASE_URL: baseUrl, SURFSENSE_API_KEY: apiKey },
},
},
});
}
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"),
},
{
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 <dir> 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"),
},
{
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 },
},
},
}),
},
{
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,
},
{
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,
},
{
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 },
},
},
}),
},
{
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,
},
{
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,
},
];

View file

@ -97,6 +97,23 @@ export function trackWorkspaceViewed(workspaceId: number) {
});
}
// ============================================
// ACTIVE-USER (WAU) EVENT
// ============================================
/**
* Single signal for active-user counting. Fired whenever a user sends a
* chat message or starts an API run, so a "weekly unique users on
* weekly_users" insight in PostHog is our WAU number.
*
* ponytail: frontend-only capture API runs made directly against the
* backend (PAT/curl, no browser) are not counted. Upgrade path is a
* server-side capture in the backend if that ever matters.
*/
export function trackWeeklyUser(source: "chat_message" | "api_run", workspaceId?: number) {
safeCapture("weekly_users", compact({ source, workspace_id: workspaceId }));
}
// ============================================
// CHAT EVENTS
// ============================================
@ -124,6 +141,7 @@ export function trackChatMessageSent(
has_mentioned_documents: options?.hasMentionedDocuments ?? false,
message_length: options?.messageLength,
});
trackWeeklyUser("chat_message", workspaceId);
}
export function trackChatResponseReceived(workspaceId: number, chatId: number) {