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

@ -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]