diff --git a/README.es.md b/README.es.md index 82cfcae1d..70ccd5eb1 100644 --- a/README.es.md +++ b/README.es.md @@ -138,7 +138,7 @@ Agrega el servidor MCP de SurfSense a Claude, Cursor o tu propio framework de ag { "mcpServers": { "surfsense": { - "url": "https://mcp.surfsense.com", + "url": "https://mcp.surfsense.com/mcp", "headers": { "Authorization": "Bearer ${SURFSENSE_API_KEY}" } } } diff --git a/README.hi.md b/README.hi.md index 1c9cfa4a7..cbf9ae8fa 100644 --- a/README.hi.md +++ b/README.hi.md @@ -138,7 +138,7 @@ SurfSense MCP सर्वर को Claude, Cursor या अपने एज { "mcpServers": { "surfsense": { - "url": "https://mcp.surfsense.com", + "url": "https://mcp.surfsense.com/mcp", "headers": { "Authorization": "Bearer ${SURFSENSE_API_KEY}" } } } diff --git a/README.md b/README.md index a05548d66..8f2e65118 100644 --- a/README.md +++ b/README.md @@ -138,7 +138,7 @@ Add the SurfSense MCP server to Claude, Cursor, or your own agent framework: { "mcpServers": { "surfsense": { - "url": "https://mcp.surfsense.com", + "url": "https://mcp.surfsense.com/mcp", "headers": { "Authorization": "Bearer ${SURFSENSE_API_KEY}" } } } diff --git a/README.pt-BR.md b/README.pt-BR.md index 4f2b945f4..076736bcf 100644 --- a/README.pt-BR.md +++ b/README.pt-BR.md @@ -138,7 +138,7 @@ Adicione o servidor MCP do SurfSense ao Claude, ao Cursor ou ao seu próprio fra { "mcpServers": { "surfsense": { - "url": "https://mcp.surfsense.com", + "url": "https://mcp.surfsense.com/mcp", "headers": { "Authorization": "Bearer ${SURFSENSE_API_KEY}" } } } diff --git a/README.zh-CN.md b/README.zh-CN.md index 22410a727..7d2ae35c3 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -138,7 +138,7 @@ curl -X POST "$SURFSENSE_API_URL/workspaces/$WORKSPACE_ID/scrapers/reddit/scrape { "mcpServers": { "surfsense": { - "url": "https://mcp.surfsense.com", + "url": "https://mcp.surfsense.com/mcp", "headers": { "Authorization": "Bearer ${SURFSENSE_API_KEY}" } } } diff --git a/surfsense_backend/app/capabilities/core/access/rest.py b/surfsense_backend/app/capabilities/core/access/rest.py index 328a7c5a1..2047b21ae 100644 --- a/surfsense_backend/app/capabilities/core/access/rest.py +++ b/surfsense_backend/app/capabilities/core/access/rest.py @@ -71,6 +71,7 @@ class CapabilitySummary(BaseModel): name: str description: str + docs_url: str | None = None input_schema: dict output_schema: dict # Empty list = free (billing disabled or an unmetered verb). @@ -145,6 +146,7 @@ def _register_capabilities_list( CapabilitySummary( name=capability.name, description=capability.description, + docs_url=capability.docs_url, input_schema=capability.input_schema.model_json_schema(), output_schema=capability.output_schema.model_json_schema(), ), diff --git a/surfsense_backend/app/capabilities/core/types.py b/surfsense_backend/app/capabilities/core/types.py index ec44d29aa..c87601832 100644 --- a/surfsense_backend/app/capabilities/core/types.py +++ b/surfsense_backend/app/capabilities/core/types.py @@ -62,3 +62,4 @@ class Capability: output_schema: type[BaseModel] executor: Executor billing_unit: BillingUnit | None + docs_url: str | None = None diff --git a/surfsense_backend/app/capabilities/google_maps/reviews/definition.py b/surfsense_backend/app/capabilities/google_maps/reviews/definition.py index e96c6f906..5366194d5 100644 --- a/surfsense_backend/app/capabilities/google_maps/reviews/definition.py +++ b/surfsense_backend/app/capabilities/google_maps/reviews/definition.py @@ -10,15 +10,14 @@ from app.capabilities.google_maps.reviews.schemas import ReviewsInput, ReviewsOu GOOGLE_MAPS_REVIEWS = Capability( name="google_maps.reviews", description=( - "Fetch public reviews for one or more Google Maps places. Give it place " - "URLs or place IDs; returns structured review items with author, text, " - "star rating, like count, owner response, and timestamps. Use it to " - "gauge sentiment or pull recent feedback on specific places." + "Fetch public Google Maps reviews with authors, ratings, text, and " + "owner responses. Use urls or place IDs." ), input_schema=ReviewsInput, output_schema=ReviewsOutput, executor=build_reviews_executor(), billing_unit=BillingUnit.GOOGLE_MAPS_REVIEW, + docs_url="/docs/connectors/native/google-maps", ) register_capability(GOOGLE_MAPS_REVIEWS) diff --git a/surfsense_backend/app/capabilities/google_maps/scrape/definition.py b/surfsense_backend/app/capabilities/google_maps/scrape/definition.py index 035b7e00d..db117a2ac 100644 --- a/surfsense_backend/app/capabilities/google_maps/scrape/definition.py +++ b/surfsense_backend/app/capabilities/google_maps/scrape/definition.py @@ -11,17 +11,14 @@ from app.capabilities.google_maps.scrape.schemas import ScrapeInput, ScrapeOutpu GOOGLE_MAPS_SCRAPE = Capability( name="google_maps.scrape", description=( - "Scrape public Google Maps places. Give it search queries (optionally " - "scoped by location), Google Maps URLs, or place IDs, and it returns " - "structured place items — name, address, category, phone, website, " - "rating, review count, coordinates, and opening hours. Set " - "include_details for richer detail-page fields, or max_reviews/" - "max_images to attach reviews and photos per place." + "Scrape public Google Maps places, details, reviews, and photos. Use " + "search_queries, urls, or place IDs." ), input_schema=ScrapeInput, output_schema=ScrapeOutput, executor=build_scrape_executor(), billing_unit=BillingUnit.GOOGLE_MAPS_PLACE, + docs_url="/docs/connectors/native/google-maps", ) register_capability(GOOGLE_MAPS_SCRAPE) diff --git a/surfsense_backend/app/capabilities/google_search/scrape/definition.py b/surfsense_backend/app/capabilities/google_search/scrape/definition.py index 2f2c9de03..2f62847d1 100644 --- a/surfsense_backend/app/capabilities/google_search/scrape/definition.py +++ b/surfsense_backend/app/capabilities/google_search/scrape/definition.py @@ -10,16 +10,14 @@ from app.capabilities.google_search.scrape.schemas import ScrapeInput, ScrapeOut GOOGLE_SEARCH_SCRAPE = Capability( name="google_search.scrape", description=( - "Search Google and return structured results. Give it search terms " - "(optionally scoped by country/language or to a single site) or full " - "Google Search URLs, and it returns SERP items — organic results " - "(title, url, description), related queries, people-also-ask, and any " - "AI overview. Use max_pages_per_query to page deeper." + "Search Google and return structured SERP results. Use search_queries " + "or Google Search URLs." ), input_schema=ScrapeInput, output_schema=ScrapeOutput, executor=build_scrape_executor(), billing_unit=BillingUnit.GOOGLE_SEARCH_SERP, + docs_url="/docs/connectors/native/google-search", ) register_capability(GOOGLE_SEARCH_SCRAPE) diff --git a/surfsense_backend/app/capabilities/reddit/scrape/definition.py b/surfsense_backend/app/capabilities/reddit/scrape/definition.py index 01fb6db5f..fe00a77be 100644 --- a/surfsense_backend/app/capabilities/reddit/scrape/definition.py +++ b/surfsense_backend/app/capabilities/reddit/scrape/definition.py @@ -10,16 +10,14 @@ from app.capabilities.reddit.scrape.schemas import ScrapeInput, ScrapeOutput REDDIT_SCRAPE = Capability( name="reddit.scrape", description=( - "Scrape public Reddit data. Give it Reddit URLs (post, subreddit, or " - "user) and/or search terms, and it returns structured items — posts " - "(title, body, score, comment count, subreddit, author), their comments, " - "and community/user metadata. Use search_queries (optionally scoped to a " - "community) to discover posts, or urls to pull a known post/subreddit/user." + "Scrape public Reddit posts, comments, and metadata. Use urls or " + "search_queries." ), input_schema=ScrapeInput, output_schema=ScrapeOutput, executor=build_scrape_executor(), billing_unit=BillingUnit.REDDIT_ITEM, + docs_url="/docs/connectors/native/reddit", ) register_capability(REDDIT_SCRAPE) diff --git a/surfsense_backend/app/capabilities/web/crawl/definition.py b/surfsense_backend/app/capabilities/web/crawl/definition.py index cd3db2306..362f4bcd0 100644 --- a/surfsense_backend/app/capabilities/web/crawl/definition.py +++ b/surfsense_backend/app/capabilities/web/crawl/definition.py @@ -9,29 +9,14 @@ from app.capabilities.web.crawl.schemas import CrawlInput, CrawlOutput WEB_CRAWL = Capability( name="web.crawl", description=( - "Scrape a single web page or crawl a whole website. Give it one or more " - "startUrls. Set maxCrawlDepth=0 to fetch just those URLs, or higher to " - "also follow the links on each page (depth 1 = the start pages plus the " - "pages they link to, and so on) — staying on the same site and stopping " - "at maxCrawlPages. On a deeper crawl, narrow which links are followed with " - "includeUrlPatterns / excludeUrlPatterns (regexes). Returns one item per " - "fetched page with clean markdown content, metadata (title, description), " - "crawl provenance, every link with its anchor text and kind " - "(internal/external/social/email/tel — use the text/context to tie a " - "profile URL to a person or company), and contact signals (emails, phone " - "numbers, social profiles). The site-wide contacts summary deduplicates " - "them with provenance: siteWide=true marks footer/header values (the " - "company's own contacts) vs page-local finds (e.g. team members' " - "profiles). Useful for lead generation and competitive intelligence; " - "contact details often live on about/contact/privacy pages, so crawl " - "with maxCrawlDepth >= 1 to surface them. JS-rendered pages are loaded " - "in a real browser and auto-scrolled, so lazy-loaded listings " - "(directories, infinite-scroll feeds) are captured too." + "Scrape pages or crawl websites for clean markdown, links, metadata, " + "and contact signals. Use startUrls and crawl-depth controls." ), input_schema=CrawlInput, output_schema=CrawlOutput, executor=build_crawl_executor(), billing_unit=BillingUnit.WEB_CRAWL, + docs_url="/docs/connectors/native/web-crawl", ) register_capability(WEB_CRAWL) diff --git a/surfsense_backend/app/capabilities/youtube/comments/definition.py b/surfsense_backend/app/capabilities/youtube/comments/definition.py index ec689a23c..c22145547 100644 --- a/surfsense_backend/app/capabilities/youtube/comments/definition.py +++ b/surfsense_backend/app/capabilities/youtube/comments/definition.py @@ -10,15 +10,14 @@ from app.capabilities.youtube.comments.schemas import CommentsInput, CommentsOut YOUTUBE_COMMENTS = Capability( name="youtube.comments", description=( - "Fetch public comments (and their replies) for one or more YouTube " - "videos. Give it the video URLs; returns structured comment items with " - "author, text, like count, reply relationships, and timestamps. Use it " - "to gauge sentiment or pull discussion on specific videos." + "Fetch public YouTube comments and replies with authors, text, likes, " + "and timestamps. Use video URLs." ), input_schema=CommentsInput, output_schema=CommentsOutput, executor=build_comments_executor(), billing_unit=BillingUnit.YOUTUBE_COMMENT, + docs_url="/docs/connectors/native/youtube", ) register_capability(YOUTUBE_COMMENTS) diff --git a/surfsense_backend/app/capabilities/youtube/scrape/definition.py b/surfsense_backend/app/capabilities/youtube/scrape/definition.py index 43874e254..d281ea7d6 100644 --- a/surfsense_backend/app/capabilities/youtube/scrape/definition.py +++ b/surfsense_backend/app/capabilities/youtube/scrape/definition.py @@ -10,16 +10,14 @@ from app.capabilities.youtube.scrape.schemas import ScrapeInput, ScrapeOutput YOUTUBE_SCRAPE = Capability( name="youtube.scrape", description=( - "Scrape public YouTube data. Give it YouTube URLs (video, channel, " - "playlist, shorts, or hashtag) and/or search queries, and it returns " - "structured video items — title, views, likes, publish date, channel " - "info, description, and optionally subtitles. Use search_queries to " - "discover videos, or urls to pull a known video/channel/playlist." + "Scrape public YouTube videos, channels, playlists, and subtitles. Use " + "urls or search_queries." ), input_schema=ScrapeInput, output_schema=ScrapeOutput, executor=build_scrape_executor(), billing_unit=BillingUnit.YOUTUBE_VIDEO, + docs_url="/docs/connectors/native/youtube", ) register_capability(YOUTUBE_SCRAPE) diff --git a/surfsense_mcp/.dockerignore b/surfsense_mcp/.dockerignore new file mode 100644 index 000000000..28abd4591 --- /dev/null +++ b/surfsense_mcp/.dockerignore @@ -0,0 +1,11 @@ +.git +.gitignore +.venv +__pycache__/ +*.py[cod] +*$py.class +*.so +.pytest_cache/ +.ruff_cache/ +.env +tests/ diff --git a/surfsense_mcp/Dockerfile b/surfsense_mcp/Dockerfile new file mode 100644 index 000000000..673fb2c63 --- /dev/null +++ b/surfsense_mcp/Dockerfile @@ -0,0 +1,36 @@ +# syntax=docker.io/docker/dockerfile:1 +# SurfSense MCP Server — remote (streamable-http) image. +# Serves /mcp (per-request API key, no baked secret) and a public /health probe. + +# Stage 1: deps frozen from uv.lock so rebuilds never drift. +FROM python:3.12-slim AS deps + +WORKDIR /app + +# curl is used by the container healthcheck probe against /health. +RUN apt-get update && apt-get install -y --no-install-recommends curl \ + && rm -rf /var/lib/apt/lists/* + +COPY pyproject.toml uv.lock ./ +RUN pip install --no-cache-dir uv && \ + uv export --frozen --no-dev --no-emit-project --no-hashes \ + --format requirements-txt -o /tmp/requirements.txt && \ + uv pip install --system --no-cache-dir -r /tmp/requirements.txt && \ + rm /tmp/requirements.txt + + +# Stage 2: project source; --no-deps since deps are already installed above. +FROM deps AS production + +COPY . . +RUN uv pip install --system --no-cache-dir --no-deps -e . + +ENV PYTHONUNBUFFERED=1 \ + SURFSENSE_MCP_TRANSPORT=streamable-http \ + SURFSENSE_MCP_HOST=0.0.0.0 \ + SURFSENSE_MCP_PORT=8080 \ + SURFSENSE_BASE_URL=https://api.surfsense.com + +EXPOSE 8080 + +CMD ["python", "-m", "mcp_server"] diff --git a/surfsense_mcp/README.md b/surfsense_mcp/README.md index 47dabf55c..c18a857e1 100644 --- a/surfsense_mcp/README.md +++ b/surfsense_mcp/README.md @@ -2,9 +2,15 @@ A [Model Context Protocol](https://modelcontextprotocol.io/) server that exposes SurfSense to MCP clients like **Claude Code**, **Cursor**, and **Claude Desktop**. -It talks to a running SurfSense backend purely over its REST API using a SurfSense -API key — it imports no backend code and can point at any instance (local or -hosted) by changing two environment variables. +It talks to a SurfSense backend purely over its REST API using a SurfSense API +key — it imports no backend code. + +Connect it two ways: + +- **Hosted** (recommended) — point your client at `https://mcp.surfsense.com/mcp` + and pass your API key in a header. Nothing to install or keep running. +- **Self-host (stdio)** — run the server yourself against any backend (cloud or + your own). Best for self-hosters and clients without remote-server support. ## Tools @@ -29,34 +35,58 @@ Workspace-scoped tools default to the active workspace; pass `workspace` (a name or id) to override for a single call. Ids never need to be typed by hand — the model carries them between calls. -## Prerequisites +## Get an API key -1. A running SurfSense backend (default `http://localhost:8000`). -2. A **SurfSense API key**: SurfSense → Settings → API → create key (`ss_pat_…`). -3. **API access enabled** on the workspace(s) you want to use (workspace settings). +1. SurfSense → **API Playground → API Keys**: create a personal key (`ss_pat_…`). + It is shown only once. +2. Toggle **API key access** on for the workspace(s) you want to use. -## Setup +## Connect (hosted) -Uses [uv](https://github.com/astral-sh/uv): +Point your client at the hosted server and send the key as a Bearer token. For +clients that read an `mcpServers` map (Cursor, Claude Desktop, and others): + +```json +{ + "mcpServers": { + "surfsense": { + "url": "https://mcp.surfsense.com/mcp", + "headers": { "Authorization": "Bearer ss_pat_your_key_here" } + } + } +} +``` + +Claude Code, from a terminal: + +```bash +claude mcp add --transport http surfsense https://mcp.surfsense.com/mcp \ + --header "Authorization: Bearer ss_pat_your_key_here" +``` + +Most MCP clients accept this `url` + `headers` form; check your client's docs for +its exact remote-server field. + +## Self-host (stdio) + +Run the server yourself when you host your own backend or use a client without +remote support. It uses [uv](https://github.com/astral-sh/uv): ```bash cd surfsense_mcp uv sync -uv run python -m surfsense_mcp.selfcheck # verify tools register correctly +uv run python -m mcp_server.selfcheck # verify tools register correctly ``` -## Connect it to a client - -### Cursor - -Add to `~/.cursor/mcp.json` (or a project `.cursor/mcp.json`): +Then add it to your client. Cursor (`~/.cursor/mcp.json` or a project +`.cursor/mcp.json`): ```json { "mcpServers": { "surfsense": { "command": "uv", - "args": ["run", "--directory", "/absolute/path/to/SurfSense/surfsense_mcp", "python", "-m", "surfsense_mcp"], + "args": ["run", "--directory", "/absolute/path/to/SurfSense/surfsense_mcp", "python", "-m", "mcp_server"], "env": { "SURFSENSE_BASE_URL": "http://localhost:8000", "SURFSENSE_API_KEY": "ss_pat_your_token_here" @@ -66,24 +96,22 @@ Add to `~/.cursor/mcp.json` (or a project `.cursor/mcp.json`): } ``` -### Claude Code +Claude Code: ```bash claude mcp add surfsense \ -e SURFSENSE_BASE_URL=http://localhost:8000 \ -e SURFSENSE_API_KEY=ss_pat_your_token_here \ - -- uv run --directory /absolute/path/to/SurfSense/surfsense_mcp python -m surfsense_mcp + -- uv run --directory /absolute/path/to/SurfSense/surfsense_mcp python -m mcp_server ``` -### Claude Desktop - -Add the same `mcpServers` block as Cursor to +Claude Desktop: add the same `mcpServers` block as Cursor to `claude_desktop_config.json` (Settings → Developer → Edit Config). ## Configuration -See `.env.example`. Secrets are passed as environment variables by the client; -never commit tokens. +See `.env.example`. For self-host, secrets are passed as environment variables by +the client; never commit tokens. ## Backend dependency diff --git a/surfsense_mcp/src/surfsense_mcp/__init__.py b/surfsense_mcp/mcp_server/__init__.py similarity index 100% rename from surfsense_mcp/src/surfsense_mcp/__init__.py rename to surfsense_mcp/mcp_server/__init__.py diff --git a/surfsense_mcp/mcp_server/__main__.py b/surfsense_mcp/mcp_server/__main__.py new file mode 100644 index 000000000..37a2a1785 --- /dev/null +++ b/surfsense_mcp/mcp_server/__main__.py @@ -0,0 +1,58 @@ +"""Entry point: load settings from the environment and run the MCP server. + +Two transports share one build: +- ``stdio`` (default): Cursor/Claude launch one process per user; the key comes + from the environment, so it is required here. +- ``streamable-http``: one process serves many users, each passing their own key + per request; the key is enforced by the transport's auth middleware instead. + +For stdio, stdout is the protocol channel, so every log line goes to stderr. +""" + +from __future__ import annotations + +import logging +import os +import sys + +from mcp.server.fastmcp import FastMCP + +from .config import Settings +from .server import build_server + + +def main() -> None: + logging.basicConfig( + level=logging.INFO, + stream=sys.stderr, + format="%(levelname)s %(name)s: %(message)s", + ) + settings = Settings.from_env() + transport = os.environ.get("SURFSENSE_MCP_TRANSPORT", "stdio").strip() or "stdio" + mcp, _client = build_server(settings) + + if transport in ("streamable-http", "http"): + _run_http(mcp, settings) + return + + if transport == "stdio" and not settings.api_key: + raise SystemExit( + "SURFSENSE_API_KEY is required for stdio transport. Create an API " + "key in SurfSense (Settings -> API) and pass it via the " + "SURFSENSE_API_KEY environment variable." + ) + mcp.run(transport=transport) + + +def _run_http(mcp: FastMCP, settings: Settings) -> None: + """Serve the streamable-http app directly, so the per-request identity + middleware wraps the SDK's MCP endpoint.""" + import uvicorn + + from .core.transport import build_http_app + + uvicorn.run(build_http_app(mcp), host=settings.host, port=settings.port) + + +if __name__ == "__main__": + main() diff --git a/surfsense_mcp/src/surfsense_mcp/config.py b/surfsense_mcp/mcp_server/config.py similarity index 71% rename from surfsense_mcp/src/surfsense_mcp/config.py rename to surfsense_mcp/mcp_server/config.py index 5d3646545..18d0f1514 100644 --- a/surfsense_mcp/src/surfsense_mcp/config.py +++ b/surfsense_mcp/mcp_server/config.py @@ -12,6 +12,8 @@ from dataclasses import dataclass DEFAULT_BASE_URL = "http://localhost:8000" DEFAULT_API_PREFIX = "/api/v1" DEFAULT_TIMEOUT_SECONDS = 180.0 +DEFAULT_HTTP_HOST = "127.0.0.1" +DEFAULT_HTTP_PORT = 8080 @dataclass(frozen=True) @@ -19,10 +21,12 @@ class Settings: """Resolved configuration for a server process.""" base_url: str - api_key: str + api_key: str | None api_prefix: str timeout: float default_workspace: str | None + host: str + port: int @property def api_base(self) -> str: @@ -30,13 +34,9 @@ class Settings: @classmethod def from_env(cls) -> Settings: - api_key = os.environ.get("SURFSENSE_API_KEY", "").strip() - if not api_key: - raise SystemExit( - "SURFSENSE_API_KEY is required. Create an API key in SurfSense " - "(Settings -> API) and pass it via the SURFSENSE_API_KEY " - "environment variable." - ) + # Optional here: remote (http) callers pass their own key per request in + # a header. ``__main__`` enforces it for stdio, its only source of a key. + api_key = os.environ.get("SURFSENSE_API_KEY", "").strip() or None base_url = ( os.environ.get("SURFSENSE_BASE_URL", DEFAULT_BASE_URL).strip().rstrip("/") @@ -53,10 +53,19 @@ class Settings: default_workspace = os.environ.get("SURFSENSE_WORKSPACE", "").strip() or None + host = os.environ.get("SURFSENSE_MCP_HOST", "").strip() or DEFAULT_HTTP_HOST + raw_port = os.environ.get("SURFSENSE_MCP_PORT", "").strip() + try: + port = int(raw_port) if raw_port else DEFAULT_HTTP_PORT + except ValueError: + port = DEFAULT_HTTP_PORT + return cls( base_url=base_url, api_key=api_key, api_prefix=api_prefix, timeout=timeout, default_workspace=default_workspace, + host=host, + port=port, ) diff --git a/surfsense_mcp/src/surfsense_mcp/core/__init__.py b/surfsense_mcp/mcp_server/core/__init__.py similarity index 100% rename from surfsense_mcp/src/surfsense_mcp/core/__init__.py rename to surfsense_mcp/mcp_server/core/__init__.py diff --git a/surfsense_mcp/mcp_server/core/auth/__init__.py b/surfsense_mcp/mcp_server/core/auth/__init__.py new file mode 100644 index 000000000..e837c6d61 --- /dev/null +++ b/surfsense_mcp/mcp_server/core/auth/__init__.py @@ -0,0 +1,7 @@ +"""Per-request caller identity: header parsing, request-scoped storage, and the +ASGI middleware that binds them together for the remote transport.""" + +from .identity import current_api_key, current_identity +from .middleware import ApiKeyIdentityMiddleware + +__all__ = ["current_api_key", "current_identity", "ApiKeyIdentityMiddleware"] diff --git a/surfsense_mcp/mcp_server/core/auth/headers.py b/surfsense_mcp/mcp_server/core/auth/headers.py new file mode 100644 index 000000000..3bca45967 --- /dev/null +++ b/surfsense_mcp/mcp_server/core/auth/headers.py @@ -0,0 +1,24 @@ +"""Extract a SurfSense API key from request headers. + +Pure header parsing, kept separate from transport and state. +""" + +from __future__ import annotations + +from starlette.datastructures import Headers + +_BEARER_PREFIX = "bearer " + + +def extract_api_key(headers: Headers) -> str | None: + """Return the caller's key from the ``Authorization: Bearer`` slot the + backend already expects, falling back to ``X-API-Key`` for clients that can + only send custom headers.""" + authorization = headers.get("authorization", "") + if authorization[: len(_BEARER_PREFIX)].lower() == _BEARER_PREFIX: + token = authorization[len(_BEARER_PREFIX) :].strip() + if token: + return token + + fallback = headers.get("x-api-key", "").strip() + return fallback or None diff --git a/surfsense_mcp/mcp_server/core/auth/identity.py b/surfsense_mcp/mcp_server/core/auth/identity.py new file mode 100644 index 000000000..0c8f6b315 --- /dev/null +++ b/surfsense_mcp/mcp_server/core/auth/identity.py @@ -0,0 +1,34 @@ +"""Request-scoped caller identity. + +Over streamable-http one process serves many users, so the caller's key lives in +a contextvar for the life of a request: the auth middleware binds it and the +client reads it when calling the backend. Under stdio there is no request, so the +contextvar is empty and the env key is used instead. +""" + +from __future__ import annotations + +from contextvars import ContextVar, Token + +_LOCAL_IDENTITY = "__local__" + +_api_key: ContextVar[str | None] = ContextVar("surfsense_api_key", default=None) + + +def bind_api_key(api_key: str | None) -> Token: + """Bind the caller's key to the current request; returns a reset token.""" + return _api_key.set(api_key) + + +def unbind_api_key(token: Token) -> None: + _api_key.reset(token) + + +def current_api_key() -> str | None: + """The caller's key for the in-flight request, or ``None`` under stdio.""" + return _api_key.get() + + +def current_identity() -> str: + """Stable per-caller key for scoping request state; shared under stdio.""" + return _api_key.get() or _LOCAL_IDENTITY diff --git a/surfsense_mcp/mcp_server/core/auth/middleware.py b/surfsense_mcp/mcp_server/core/auth/middleware.py new file mode 100644 index 000000000..8ba6ce939 --- /dev/null +++ b/surfsense_mcp/mcp_server/core/auth/middleware.py @@ -0,0 +1,58 @@ +"""ASGI middleware that establishes the caller's identity for each request. + +A pure ASGI middleware, deliberately not Starlette's ``BaseHTTPMiddleware``: +the latter runs the endpoint in a separate task, so a contextvar set in it does +not reach the tool handler. A pure middleware binds the key in the request's own +task, from which the SDK's per-request handling inherits it. + +Requests without a key are rejected here so no tool ever runs unauthenticated. +Paths in ``public_paths`` (e.g. the health probe) skip the check entirely. +""" + +from __future__ import annotations + +from collections.abc import Iterable + +from starlette.datastructures import Headers +from starlette.responses import JSONResponse +from starlette.types import ASGIApp, Receive, Scope, Send + +from .headers import extract_api_key +from .identity import bind_api_key, unbind_api_key + + +class ApiKeyIdentityMiddleware: + """Binds the per-request API key into the identity contextvar, or 401s.""" + + def __init__(self, app: ASGIApp, public_paths: Iterable[str] = ()) -> None: + self._app = app + self._public_paths = frozenset(public_paths) + + async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: + if scope["type"] != "http" or scope["path"] in self._public_paths: + await self._app(scope, receive, send) + return + + api_key = extract_api_key(Headers(scope=scope)) + if api_key is None: + await _unauthenticated()(scope, receive, send) + return + + token = bind_api_key(api_key) + try: + await self._app(scope, receive, send) + finally: + unbind_api_key(token) + + +def _unauthenticated() -> JSONResponse: + return JSONResponse( + { + "error": "unauthorized", + "message": ( + "Missing SurfSense API key. Send 'Authorization: Bearer " + "ss_pat_...' (or an X-API-Key header)." + ), + }, + status_code=401, + ) diff --git a/surfsense_mcp/src/surfsense_mcp/core/client.py b/surfsense_mcp/mcp_server/core/client.py similarity index 72% rename from surfsense_mcp/src/surfsense_mcp/core/client.py rename to surfsense_mcp/mcp_server/core/client.py index 55f9d6048..9ae446ae2 100644 --- a/surfsense_mcp/src/surfsense_mcp/core/client.py +++ b/surfsense_mcp/mcp_server/core/client.py @@ -10,10 +10,11 @@ from typing import Any import httpx +from .auth.identity import current_api_key from .errors import ToolError _FAILURE_HINTS: dict[int, str] = { - 401: "Authentication failed — check that SURFSENSE_API_KEY is a valid, unexpired key.", + 401: "Authentication failed — the SurfSense API key is invalid or expired.", 402: "The workspace is out of credits for this operation.", 403: ( "Access denied — the token lacks permission, or API access is disabled " @@ -27,17 +28,30 @@ _FAILURE_HINTS: dict[int, str] = { class SurfSenseClient: """Issues authenticated requests against ``{base_url}{api_prefix}``.""" - def __init__(self, *, api_base: str, api_key: str, timeout: float) -> None: + def __init__( + self, *, api_base: str, timeout: float, fallback_api_key: str | None = None + ) -> None: self._api_base = api_base + # Resolved per request, so no key is baked into the shared client. The + # fallback is the env key used under stdio, where there is no header. + self._fallback_api_key = fallback_api_key self._http = httpx.AsyncClient( base_url=api_base, - headers={ - "Authorization": f"Bearer {api_key}", - "Accept": "application/json", - }, + headers={"Accept": "application/json"}, timeout=timeout, ) + def _auth_headers(self) -> dict[str, str]: + """Resolve the caller's key: the per-request header, else the env key.""" + api_key = current_api_key() or self._fallback_api_key + if not api_key: + raise ToolError( + "No SurfSense API key supplied. Send it as an 'Authorization: " + "Bearer ss_pat_...' header (remote server), or set the " + "SURFSENSE_API_KEY environment variable (stdio)." + ) + return {"Authorization": f"Bearer {api_key}"} + async def request( self, method: str, @@ -53,9 +67,16 @@ class SurfSenseClient: # as a value (e.g. int("") on folder_id) and fail. if params is not None: params = {key: value for key, value in params.items() if value is not None} + headers = self._auth_headers() try: response = await self._http.request( - method, path, params=params, json=json, data=data, files=files + method, + path, + params=params, + json=json, + data=data, + files=files, + headers=headers, ) except httpx.RequestError as exc: raise ToolError( diff --git a/surfsense_mcp/src/surfsense_mcp/core/errors.py b/surfsense_mcp/mcp_server/core/errors.py similarity index 100% rename from surfsense_mcp/src/surfsense_mcp/core/errors.py rename to surfsense_mcp/mcp_server/core/errors.py diff --git a/surfsense_mcp/src/surfsense_mcp/core/rendering.py b/surfsense_mcp/mcp_server/core/rendering.py similarity index 100% rename from surfsense_mcp/src/surfsense_mcp/core/rendering.py rename to surfsense_mcp/mcp_server/core/rendering.py diff --git a/surfsense_mcp/mcp_server/core/transport/__init__.py b/surfsense_mcp/mcp_server/core/transport/__init__.py new file mode 100644 index 000000000..53aaa6b8c --- /dev/null +++ b/surfsense_mcp/mcp_server/core/transport/__init__.py @@ -0,0 +1,5 @@ +"""Transport wiring for the MCP server (streamable-http app assembly).""" + +from .http import build_http_app + +__all__ = ["build_http_app"] diff --git a/surfsense_mcp/mcp_server/core/transport/http.py b/surfsense_mcp/mcp_server/core/transport/http.py new file mode 100644 index 000000000..e375e8b98 --- /dev/null +++ b/surfsense_mcp/mcp_server/core/transport/http.py @@ -0,0 +1,36 @@ +"""Wrap the SDK's MCP endpoint with identity + CORS for the remote transport. + +CORS is outermost so keyless browser preflight is answered before the identity +middleware. ``/health`` is a public path, exempt from the key check. +""" + +from __future__ import annotations + +from mcp.server.fastmcp import FastMCP +from starlette.requests import Request +from starlette.responses import JSONResponse +from starlette.middleware.cors import CORSMiddleware +from starlette.types import ASGIApp + +from ..auth.middleware import ApiKeyIdentityMiddleware + +HEALTH_PATH = "/health" + + +async def _health(_request: Request) -> JSONResponse: + return JSONResponse({"status": "ok"}) + + +def build_http_app(mcp: FastMCP) -> ASGIApp: + """Return the MCP streamable-http app wrapped with identity + CORS.""" + mcp.custom_route(HEALTH_PATH, methods=["GET"])(_health) + app: ASGIApp = ApiKeyIdentityMiddleware( + mcp.streamable_http_app(), public_paths={HEALTH_PATH} + ) + return CORSMiddleware( + app, + allow_origins=["*"], + allow_methods=["GET", "POST", "DELETE", "OPTIONS"], + allow_headers=["*"], + expose_headers=["Mcp-Session-Id"], + ) diff --git a/surfsense_mcp/src/surfsense_mcp/core/workspace_context.py b/surfsense_mcp/mcp_server/core/workspace_context.py similarity index 68% rename from surfsense_mcp/src/surfsense_mcp/core/workspace_context.py rename to surfsense_mcp/mcp_server/core/workspace_context.py index 6682d53b5..0c5423e6b 100644 --- a/surfsense_mcp/src/surfsense_mcp/core/workspace_context.py +++ b/surfsense_mcp/mcp_server/core/workspace_context.py @@ -7,13 +7,22 @@ speaks a name, we resolve it, and remember the choice for later calls. from __future__ import annotations +from collections import OrderedDict from dataclasses import dataclass from typing import Annotated from pydantic import Field +from .auth.identity import current_identity from .client import SurfSenseClient from .errors import ToolError +from .workspace_matching import as_int, match_by_name, name_list + +# ponytail: one small entry per distinct caller (API token). Bounded so a flood +# of keys can't grow memory without limit; an evicted caller just re-resolves +# its default workspace on the next call. Upgrade path: a TTL/LRU store if +# per-caller state ever grows past this one field. +_MAX_TRACKED_IDENTITIES = 2048 # Shared parameter type for every workspace-scoped tool. WorkspaceParam = Annotated[ @@ -44,15 +53,21 @@ class WorkspaceContext: ) -> None: self._client = client self._preferred_reference = preferred_reference - self._active: Workspace | None = None + # Active selection is per caller: one shared slot would leak one user's + # choice to every other user on a shared server. + self._active_by_identity: OrderedDict[str, Workspace] = OrderedDict() @property def active(self) -> Workspace | None: - return self._active + return self._active_by_identity.get(current_identity()) def remember(self, workspace: Workspace) -> Workspace: - """Make ``workspace`` the default for later scoped calls.""" - self._active = workspace + """Make ``workspace`` the default for the current caller's later calls.""" + identity = current_identity() + self._active_by_identity[identity] = workspace + self._active_by_identity.move_to_end(identity) + while len(self._active_by_identity) > _MAX_TRACKED_IDENTITIES: + self._active_by_identity.popitem(last=False) return workspace async def fetch_all(self) -> list[Workspace]: @@ -67,8 +82,9 @@ class WorkspaceContext: return self.remember(await self._match(reference)) async def _resolve_default(self) -> Workspace: - if self._active is not None: - return self._active + active = self.active + if active is not None: + return active if self._preferred_reference: return self.remember(await self._match(self._preferred_reference)) return self.remember(await self._only_workspace_or_prompt()) @@ -84,43 +100,20 @@ class WorkspaceContext: ) raise ToolError( "No workspace selected. Choose one first with surfsense_select_workspace, " - f"or pass 'workspace'. Available: {_name_list(workspaces)}." + f"or pass 'workspace'. Available: {name_list(workspaces)}." ) async def _match(self, reference: str | int) -> Workspace: workspaces = await self.fetch_all() - as_id = _as_int(reference) + as_id = as_int(reference) if as_id is not None: found = next((w for w in workspaces if w.id == as_id), None) if found is None: raise ToolError( - f"No workspace with id {as_id}. Available: {_name_list(workspaces)}." + f"No workspace with id {as_id}. Available: {name_list(workspaces)}." ) return found - return _match_by_name(str(reference), workspaces) - - -def _match_by_name(reference: str, workspaces: list[Workspace]) -> Workspace: - """Match on name: exact, then case-insensitive, then unique substring.""" - needle = reference.strip() - exact = [w for w in workspaces if w.name == needle] - if exact: - return exact[0] - lowered = needle.casefold() - insensitive = [w for w in workspaces if w.name.casefold() == lowered] - if insensitive: - return insensitive[0] - partial = [w for w in workspaces if lowered in w.name.casefold()] - if len(partial) == 1: - return partial[0] - if len(partial) > 1: - raise ToolError( - f"'{reference}' matches several workspaces: {_name_list(partial)}. " - "Use a more specific name or the id." - ) - raise ToolError( - f"No workspace named '{reference}'. Available: {_name_list(workspaces)}." - ) + return match_by_name(str(reference), workspaces) def _to_workspace(row: dict) -> Workspace: @@ -131,14 +124,3 @@ def _to_workspace(row: dict) -> Workspace: is_owner=row.get("is_owner", False), member_count=row.get("member_count", 1), ) - - -def _as_int(reference: str | int) -> int | None: - if isinstance(reference, int): - return reference - text = reference.strip() - return int(text) if text.isdigit() else None - - -def _name_list(workspaces: list[Workspace]) -> str: - return ", ".join(f"{w.name} (id {w.id})" for w in workspaces) diff --git a/surfsense_mcp/mcp_server/core/workspace_matching.py b/surfsense_mcp/mcp_server/core/workspace_matching.py new file mode 100644 index 000000000..33bfc50a5 --- /dev/null +++ b/surfsense_mcp/mcp_server/core/workspace_matching.py @@ -0,0 +1,51 @@ +"""Resolve a user-supplied workspace reference to a single workspace. + +Pure matching over an already-fetched list: name (exact, then case-insensitive, +then unique substring) or numeric id. Kept apart from WorkspaceContext so the +resolution rules can be read and tested without the network. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from .errors import ToolError + +if TYPE_CHECKING: + from .workspace_context import Workspace + + +def match_by_name(reference: str, workspaces: list[Workspace]) -> Workspace: + """Match on name: exact, then case-insensitive, then unique substring.""" + needle = reference.strip() + exact = [w for w in workspaces if w.name == needle] + if exact: + return exact[0] + lowered = needle.casefold() + insensitive = [w for w in workspaces if w.name.casefold() == lowered] + if insensitive: + return insensitive[0] + partial = [w for w in workspaces if lowered in w.name.casefold()] + if len(partial) == 1: + return partial[0] + if len(partial) > 1: + raise ToolError( + f"'{reference}' matches several workspaces: {name_list(partial)}. " + "Use a more specific name or the id." + ) + raise ToolError( + f"No workspace named '{reference}'. Available: {name_list(workspaces)}." + ) + + +def as_int(reference: str | int) -> int | None: + """Return the reference as an id, or None when it isn't numeric.""" + if isinstance(reference, int): + return reference + text = reference.strip() + return int(text) if text.isdigit() else None + + +def name_list(workspaces: list[Workspace]) -> str: + """Render workspaces as a human-readable 'name (id N)' list.""" + return ", ".join(f"{w.name} (id {w.id})" for w in workspaces) diff --git a/surfsense_mcp/src/surfsense_mcp/features/__init__.py b/surfsense_mcp/mcp_server/features/__init__.py similarity index 100% rename from surfsense_mcp/src/surfsense_mcp/features/__init__.py rename to surfsense_mcp/mcp_server/features/__init__.py diff --git a/surfsense_mcp/mcp_server/features/knowledge_base/__init__.py b/surfsense_mcp/mcp_server/features/knowledge_base/__init__.py new file mode 100644 index 000000000..1a971bfe4 --- /dev/null +++ b/surfsense_mcp/mcp_server/features/knowledge_base/__init__.py @@ -0,0 +1,22 @@ +"""Knowledge-base tools: search the KB and manage its documents. + +Semantic search plus the document lifecycle — list, read, add text, upload a +file, update, and delete — over a workspace's knowledge base. Read tools live in +search_tools, mutations in document_tools. +""" + +from __future__ import annotations + +from mcp.server.fastmcp import FastMCP + +from ...core.client import SurfSenseClient +from ...core.workspace_context import WorkspaceContext +from . import document_tools, search_tools + + +def register( + mcp: FastMCP, client: SurfSenseClient, context: WorkspaceContext +) -> None: + """Register every knowledge-base tool on the server.""" + search_tools.register(mcp, client, context) + document_tools.register(mcp, client, context) diff --git a/surfsense_mcp/mcp_server/features/knowledge_base/annotations.py b/surfsense_mcp/mcp_server/features/knowledge_base/annotations.py new file mode 100644 index 000000000..16322506c --- /dev/null +++ b/surfsense_mcp/mcp_server/features/knowledge_base/annotations.py @@ -0,0 +1,34 @@ +"""Tool-call policy hints and shared parameter types for knowledge-base tools.""" + +from __future__ import annotations + +from typing import Annotated + +from mcp.types import ToolAnnotations +from pydantic import Field + +READ = ToolAnnotations( + readOnlyHint=True, destructiveHint=False, idempotentHint=True, openWorldHint=False +) +WRITE = ToolAnnotations( + readOnlyHint=False, destructiveHint=False, idempotentHint=False, openWorldHint=False +) +DELETE = ToolAnnotations( + readOnlyHint=False, destructiveHint=True, idempotentHint=False, openWorldHint=False +) + +DocumentId = Annotated[ + int, + Field( + description="Document id from surfsense_search_knowledge_base or " + "surfsense_list_documents results." + ), +] + +DocumentTypes = Annotated[ + list[str] | None, + Field( + description="Restrict to these document types, e.g. " + "['FILE', 'CRAWLED_URL', 'YOUTUBE_VIDEO']. Omit for all types." + ), +] diff --git a/surfsense_mcp/mcp_server/features/knowledge_base/document_tools.py b/surfsense_mcp/mcp_server/features/knowledge_base/document_tools.py new file mode 100644 index 000000000..497a2526c --- /dev/null +++ b/surfsense_mcp/mcp_server/features/knowledge_base/document_tools.py @@ -0,0 +1,185 @@ +"""Knowledge-base write tools: add a note, upload a file, update, and delete. + +Add and upload target the active workspace; update and delete address a document +by its account-unique id, so they need no workspace. +""" + +from __future__ import annotations + +import mimetypes +from pathlib import Path +from typing import Annotated + +from mcp.server.fastmcp import FastMCP +from pydantic import Field + +from ...core.client import SurfSenseClient +from ...core.errors import ToolError +from ...core.workspace_context import WorkspaceContext, WorkspaceParam +from .annotations import DELETE, WRITE, DocumentId +from .note_ingestion import build_note_document + + +def register( + mcp: FastMCP, client: SurfSenseClient, context: WorkspaceContext +) -> None: + """Register the knowledge-base write and delete tools.""" + + @mcp.tool( + name="surfsense_add_document", + title="Add a note", + annotations=WRITE, + structured_output=False, + ) + async def add_document( + title: Annotated[ + str, + Field(min_length=1, description="Short descriptive title for the note."), + ], + content: Annotated[ + str, + Field( + min_length=1, + description="The note's body; plain text or markdown.", + ), + ], + source_url: Annotated[ + str | None, + Field(description="Where the text came from, if anywhere."), + ] = None, + workspace: WorkspaceParam = None, + ) -> str: + """Save a text or markdown note into the workspace's knowledge base. + + Use this to store notes, summaries, or findings so they become + searchable later — e.g. after finishing a piece of research. For files + on disk use surfsense_upload_file instead. Indexing is asynchronous, + so the note may take a moment to appear in search. + Example: title='NotebookLM subreddits', content='- r/notebooklm ...'. + """ + resolved = await context.resolve(workspace) + await client.request( + "POST", + "/documents", + json=build_note_document( + workspace_id=resolved.id, + title=title, + content=content, + source_url=source_url, + ), + ) + return ( + f"Queued '{title}' for indexing in '{resolved.name}'. " + "It will be searchable once processing completes." + ) + + @mcp.tool( + name="surfsense_upload_file", + title="Upload a file", + annotations=WRITE, + structured_output=False, + ) + async def upload_file( + file_path: Annotated[ + str, + Field( + description="Path to a local file, e.g. " + "'C:/Users/me/report.pdf' or '~/notes/summary.md'." + ), + ], + use_vision_llm: Annotated[ + bool, + Field( + description="True reads scanned or image-heavy files with a " + "vision model (slower)." + ), + ] = False, + workspace: WorkspaceParam = None, + ) -> str: + """Upload a local file (PDF, docx, markdown, etc.) into the knowledge base. + + Use this to ingest a file from disk so its content becomes searchable; + for text you already have in hand use surfsense_add_document instead. + The file is parsed, chunked, and indexed asynchronously. Duplicate + files are detected and skipped. + Example: file_path='C:/Users/me/report.pdf'. + """ + resolved = await context.resolve(workspace) + payload = _read_upload(file_path) + result = await client.request( + "POST", + "/documents/fileupload", + data={ + "workspace_id": str(resolved.id), + "use_vision_llm": str(use_vision_llm).lower(), + "processing_mode": "basic", + }, + files=[("files", payload)], + ) + pending = (result or {}).get("pending_files", 0) + skipped = (result or {}).get("skipped_duplicates", 0) + note = " (already present, skipped)" if skipped and not pending else "" + return ( + f"Uploaded '{Path(file_path).name}' to '{resolved.name}'{note}. " + "It will be searchable once processing completes." + ) + + @mcp.tool( + name="surfsense_update_document", + title="Replace a document's content", + annotations=WRITE, + structured_output=False, + ) + async def update_document( + document_id: DocumentId, + content: Annotated[ + str, + Field( + min_length=1, + description="New full text; replaces the existing content " + "entirely.", + ), + ], + ) -> str: + """Replace a document's stored content by id. + + Use this to correct or rewrite a document's text. The new content + REPLACES the old entirely — to append, read the document first with + surfsense_get_document and resend the combined text. Search chunks are + not re-indexed by this call. + """ + existing = await client.request("GET", f"/documents/{document_id}") + await client.request( + "PUT", + f"/documents/{document_id}", + json={ + "document_type": existing["document_type"], + "workspace_id": existing["workspace_id"], + "content": content, + }, + ) + return f"Updated document {document_id} ('{existing.get('title', '')}')." + + @mcp.tool( + name="surfsense_delete_document", + title="Delete a document", + annotations=DELETE, + structured_output=False, + ) + async def delete_document(document_id: DocumentId) -> str: + """Permanently delete a document from the knowledge base by id. + + Use this only when the user explicitly asks to remove a document — + deletion cannot be undone. The document stops appearing in searches + immediately. + """ + await client.request("DELETE", f"/documents/{document_id}") + return f"Deleted document {document_id}." + + +def _read_upload(file_path: str) -> tuple[str, bytes, str]: + path = Path(file_path).expanduser() + if not path.is_file(): + raise ToolError(f"No file at '{file_path}'.") + mime, _ = mimetypes.guess_type(path.name) + return (path.name, path.read_bytes(), mime or "application/octet-stream") diff --git a/surfsense_mcp/src/surfsense_mcp/features/knowledge_base/note_ingestion.py b/surfsense_mcp/mcp_server/features/knowledge_base/note_ingestion.py similarity index 100% rename from surfsense_mcp/src/surfsense_mcp/features/knowledge_base/note_ingestion.py rename to surfsense_mcp/mcp_server/features/knowledge_base/note_ingestion.py diff --git a/surfsense_mcp/mcp_server/features/knowledge_base/search_tools.py b/surfsense_mcp/mcp_server/features/knowledge_base/search_tools.py new file mode 100644 index 000000000..a9e60810d --- /dev/null +++ b/surfsense_mcp/mcp_server/features/knowledge_base/search_tools.py @@ -0,0 +1,188 @@ +"""Knowledge-base read tools: semantic search, list, and read one document. + +Search and list default to the active workspace; a document read is addressed by +id, which is unique across the account, so it needs no workspace. +""" + +from __future__ import annotations + +from typing import Annotated + +from mcp.server.fastmcp import FastMCP +from pydantic import Field + +from ...core.client import SurfSenseClient +from ...core.rendering import ResponseFormatParam, clip, to_json +from ...core.workspace_context import WorkspaceContext, WorkspaceParam +from .annotations import READ, DocumentId, DocumentTypes + + +def register( + mcp: FastMCP, client: SurfSenseClient, context: WorkspaceContext +) -> None: + """Register the knowledge-base read tools.""" + + @mcp.tool( + name="surfsense_search_knowledge_base", + title="Search knowledge base", + annotations=READ, + structured_output=False, + ) + async def search_knowledge_base( + query: Annotated[ + str, + Field( + min_length=1, + description="Natural-language search, e.g. " + "'notebooklm user complaints'.", + ), + ], + top_k: Annotated[ + int, Field(ge=1, le=20, description="Maximum documents to return.") + ] = 5, + document_types: DocumentTypes = None, + workspace: WorkspaceParam = None, + response_format: ResponseFormatParam = "markdown", + ) -> str: + """Search the workspace's knowledge base by meaning and keywords. + + Use this FIRST when a question might be answered by content already + stored in SurfSense — notes, uploaded files, saved pages, past + research. Do NOT use it to fetch new data from the web; use the + scraper tools for that. Returns the most relevant documents with the + passages that matched, ranked by relevance score. + Example: query='pricing feedback', top_k=5. + """ + resolved = await context.resolve(workspace) + hits = await client.request( + "POST", + "/documents/search-semantic", + json={ + "workspace_id": resolved.id, + "query": query, + "top_k": max(1, min(top_k, 20)), + "document_types": document_types, + }, + ) + items = (hits or {}).get("items", []) + if response_format == "json": + return to_json(items) + return _render_search(query, items) + + @mcp.tool( + name="surfsense_list_documents", + title="List documents", + annotations=READ, + structured_output=False, + ) + async def list_documents( + document_types: DocumentTypes = None, + folder_id: Annotated[ + int | None, + Field(description="Only documents in this folder. Omit for all."), + ] = None, + page: Annotated[ + int, Field(ge=0, description="Zero-based page number.") + ] = 0, + page_size: Annotated[ + int, Field(ge=1, description="Documents per page.") + ] = 20, + workspace: WorkspaceParam = None, + response_format: ResponseFormatParam = "markdown", + ) -> str: + """List documents in the workspace's knowledge base, newest first. + + Use this to browse or inventory what is stored; to find documents + about a topic, prefer surfsense_search_knowledge_base. Returns each + document's title, id, type, and update time, plus a has_more flag — + request the next page by increasing page. + Example: document_types=['FILE'], page=0, page_size=20. + """ + resolved = await context.resolve(workspace) + result = await client.request( + "GET", + "/documents", + params={ + "workspace_id": resolved.id, + "page": page, + "page_size": page_size, + "document_types": _join(document_types), + "folder_id": folder_id, + }, + ) + if response_format == "json": + return to_json(result) + return _render_document_list(result) + + @mcp.tool( + name="surfsense_get_document", + title="Read one document", + annotations=READ, + structured_output=False, + ) + async def get_document( + document_id: DocumentId, + response_format: ResponseFormatParam = "markdown", + ) -> str: + """Read one document's full content and metadata by id. + + Use this after surfsense_search_knowledge_base or + surfsense_list_documents to open a specific document — search results + only include the matching passages, this returns the whole text. + """ + document = await client.request("GET", f"/documents/{document_id}") + if response_format == "json": + return clip(to_json(document)) + return _render_document(document) + + +def _join(values: list[str] | None) -> str | None: + return ",".join(values) if values else None + + +def _render_search(query: str, items: list[dict]) -> str: + if not items: + return f'No matches for "{query}".' + lines = [f'# {len(items)} result(s) for "{query}"', ""] + for hit in items: + lines.append( + f"## {hit.get('title', 'Untitled')} " + f"(id {hit.get('document_id')}) — score {hit.get('score', 0):.3f}" + ) + for chunk in hit.get("chunks", []): + excerpt = clip(chunk.get("content", "").strip(), 500) + lines.append(f"> {excerpt}") + lines.append("") + return "\n".join(lines).strip() + + +def _render_document_list(result: dict | None) -> str: + items = (result or {}).get("items", []) + if not items: + return "No documents found." + lines = ["# Documents", ""] + for doc in items: + lines.append( + f"- **{doc.get('title', 'Untitled')}** (id {doc.get('id')}) · " + f"{doc.get('document_type')} · updated {doc.get('updated_at')}" + ) + total = (result or {}).get("total", len(items)) + page = (result or {}).get("page", 0) + has_more = (result or {}).get("has_more", False) + lines.append("") + lines.append( + f"_Page {page} · showing {len(items)} of {total}" + + (" · more available_" if has_more else "_") + ) + return "\n".join(lines) + + +def _render_document(document: dict) -> str: + content = clip(document.get("content", "") or "(empty)") + return ( + f"# {document.get('title', 'Untitled')} (id {document.get('id')})\n" + f"- type: {document.get('document_type')}\n" + f"- workspace: {document.get('workspace_id')}\n" + f"- updated: {document.get('updated_at')}\n\n" + f"{content}" + ) diff --git a/surfsense_mcp/mcp_server/features/scrapers/__init__.py b/surfsense_mcp/mcp_server/features/scrapers/__init__.py new file mode 100644 index 000000000..dfa2f3ab2 --- /dev/null +++ b/surfsense_mcp/mcp_server/features/scrapers/__init__.py @@ -0,0 +1,26 @@ +"""Scraper tools: one MCP surface per SurfSense platform capability. + +Web crawl, Google Search, Reddit, YouTube, and Google Maps each get a tool that +maps a natural-language request to the workspace's scraper. Two run-history tools +list and fetch past runs, so a large result truncated inline can be retrieved in +full later. Each platform lives in its own module under platforms/. +""" + +from __future__ import annotations + +from mcp.server.fastmcp import FastMCP + +from ...core.client import SurfSenseClient +from ...core.workspace_context import WorkspaceContext +from . import run_history +from .platforms import google_maps, google_search, reddit, web, youtube + +_REGISTRARS = (web, google_search, reddit, youtube, google_maps, run_history) + + +def register( + mcp: FastMCP, client: SurfSenseClient, context: WorkspaceContext +) -> None: + """Register every scraper and run-history tool on the server.""" + for module in _REGISTRARS: + module.register(mcp, client, context) diff --git a/surfsense_mcp/mcp_server/features/scrapers/annotations.py b/surfsense_mcp/mcp_server/features/scrapers/annotations.py new file mode 100644 index 000000000..cb872cc90 --- /dev/null +++ b/surfsense_mcp/mcp_server/features/scrapers/annotations.py @@ -0,0 +1,13 @@ +"""Tool-call policy hints shared across scraper tools.""" + +from __future__ import annotations + +from mcp.types import ToolAnnotations + +SCRAPE = ToolAnnotations( + readOnlyHint=False, destructiveHint=False, idempotentHint=False, openWorldHint=True +) + +READ_RUNS = ToolAnnotations( + readOnlyHint=True, destructiveHint=False, idempotentHint=True, openWorldHint=False +) diff --git a/surfsense_mcp/src/surfsense_mcp/features/scrapers/capability.py b/surfsense_mcp/mcp_server/features/scrapers/capability.py similarity index 100% rename from surfsense_mcp/src/surfsense_mcp/features/scrapers/capability.py rename to surfsense_mcp/mcp_server/features/scrapers/capability.py diff --git a/surfsense_mcp/mcp_server/features/scrapers/platforms/__init__.py b/surfsense_mcp/mcp_server/features/scrapers/platforms/__init__.py new file mode 100644 index 000000000..f61704380 --- /dev/null +++ b/surfsense_mcp/mcp_server/features/scrapers/platforms/__init__.py @@ -0,0 +1 @@ +"""One module per scraper platform; each exposes register(mcp, client, context).""" diff --git a/surfsense_mcp/mcp_server/features/scrapers/platforms/google_maps.py b/surfsense_mcp/mcp_server/features/scrapers/platforms/google_maps.py new file mode 100644 index 000000000..e1613ca4e --- /dev/null +++ b/surfsense_mcp/mcp_server/features/scrapers/platforms/google_maps.py @@ -0,0 +1,151 @@ +"""Google Maps scraper tools: places and reviews.""" + +from __future__ import annotations + +from typing import Annotated, Literal + +from mcp.server.fastmcp import FastMCP +from pydantic import Field + +from ....core.client import SurfSenseClient +from ....core.rendering import ResponseFormatParam +from ....core.workspace_context import WorkspaceContext, WorkspaceParam +from ..annotations import SCRAPE +from ..capability import run_scraper + +ReviewSort = Literal["newest", "mostRelevant", "highestRanking", "lowestRanking"] + + +def register( + mcp: FastMCP, client: SurfSenseClient, context: WorkspaceContext +) -> None: + """Register the Google Maps place and review tools.""" + + @mcp.tool( + name="surfsense_google_maps_scrape", + title="Find places on Google Maps", + annotations=SCRAPE, + structured_output=False, + ) + async def google_maps_scrape( + search_queries: Annotated[ + list[str] | None, + Field( + description="Place searches, e.g. ['coffee shops']. Provide " + "search_queries OR urls OR place_ids." + ), + ] = None, + urls: Annotated[ + list[str] | None, + Field(description="Google Maps URLs of specific places."), + ] = None, + place_ids: Annotated[ + list[str] | None, + Field(description="Google place ids, e.g. ['ChIJj61dQgK6j4AR...']."), + ] = None, + location: Annotated[ + str | None, + Field( + description="Geographic scope for a search, e.g. " + "'Seattle, USA'." + ), + ] = None, + max_places: Annotated[ + int, Field(ge=1, description="Maximum places to return.") + ] = 10, + include_details: Annotated[ + bool, + Field( + description="True adds opening hours and extra contact info " + "(slower)." + ), + ] = False, + workspace: WorkspaceParam = None, + response_format: ResponseFormatParam = "markdown", + ) -> str: + """Find places on Google Maps by search, URL, or place id. + + Use this for local-business and location research: names, addresses, + ratings, categories, coordinates, place ids. For a place's customer + reviews use surfsense_google_maps_reviews instead. + Example: search_queries=['ramen'], location='Osaka, Japan', max_places=5. + """ + return await run_scraper( + client, + context, + platform="google_maps", + verb="scrape", + payload={ + "search_queries": search_queries, + "urls": urls, + "place_ids": place_ids, + "location": location, + "max_places": max_places, + "include_details": include_details, + }, + workspace=workspace, + response_format=response_format, + ) + + @mcp.tool( + name="surfsense_google_maps_reviews", + title="Fetch Google Maps reviews", + annotations=SCRAPE, + structured_output=False, + ) + async def google_maps_reviews( + urls: Annotated[ + list[str] | None, + Field( + description="Google Maps URLs of places. Provide urls OR " + "place_ids." + ), + ] = None, + place_ids: Annotated[ + list[str] | None, + Field( + description="Google place ids from surfsense_google_maps_scrape." + ), + ] = None, + max_reviews: Annotated[ + int, Field(ge=1, description="Maximum reviews per place.") + ] = 20, + sort_by: Annotated[ + ReviewSort, Field(description="Review ordering.") + ] = "newest", + language: Annotated[ + str, Field(description="Reviews language code, e.g. 'en'.") + ] = "en", + start_date: Annotated[ + str | None, + Field( + description="ISO date like '2026-01-01'; keeps only reviews on " + "or after that day." + ), + ] = None, + workspace: WorkspaceParam = None, + response_format: ResponseFormatParam = "markdown", + ) -> str: + """Fetch customer reviews for Google Maps places by URL or place id. + + Use this to read feedback on specific places; get urls or place_ids + from surfsense_google_maps_scrape first if you only have a name. + Returns review text, rating, author, and date per review. + Example: place_ids=['ChIJj61dQgK6j4AR...'], sort_by='newest'. + """ + return await run_scraper( + client, + context, + platform="google_maps", + verb="reviews", + payload={ + "urls": urls, + "place_ids": place_ids, + "max_reviews": max_reviews, + "sort_by": sort_by, + "language": language, + "start_date": start_date, + }, + workspace=workspace, + response_format=response_format, + ) diff --git a/surfsense_mcp/mcp_server/features/scrapers/platforms/google_search.py b/surfsense_mcp/mcp_server/features/scrapers/platforms/google_search.py new file mode 100644 index 000000000..cc1a1f8ed --- /dev/null +++ b/surfsense_mcp/mcp_server/features/scrapers/platforms/google_search.py @@ -0,0 +1,79 @@ +"""Google Search scraper tool.""" + +from __future__ import annotations + +from typing import Annotated + +from mcp.server.fastmcp import FastMCP +from pydantic import Field + +from ....core.client import SurfSenseClient +from ....core.rendering import ResponseFormatParam +from ....core.workspace_context import WorkspaceContext, WorkspaceParam +from ..annotations import SCRAPE +from ..capability import run_scraper + + +def register( + mcp: FastMCP, client: SurfSenseClient, context: WorkspaceContext +) -> None: + """Register the Google Search tool.""" + + @mcp.tool( + name="surfsense_google_search", + title="Scrape Google Search", + annotations=SCRAPE, + structured_output=False, + ) + async def google_search( + queries: Annotated[ + list[str], + Field( + min_length=1, + description="Search terms or full Google Search URLs, e.g. " + "['best rss readers 2026'].", + ), + ], + max_pages_per_query: Annotated[ + int, Field(ge=1, description="Result pages to fetch per query.") + ] = 1, + country_code: Annotated[ + str | None, + Field(description="Two-letter country to search from, e.g. 'us'."), + ] = None, + language_code: Annotated[ + str, Field(description="Results language, e.g. 'en'. Empty for default.") + ] = "", + site: Annotated[ + str | None, + Field( + description="Restrict results to one domain, e.g. 'example.com'." + ), + ] = None, + workspace: WorkspaceParam = None, + response_format: ResponseFormatParam = "markdown", + ) -> str: + """Scrape Google Search result pages for one or more queries. + + Use this to discover pages on the open web by topic; follow up with + surfsense_web_crawl to read a result in full. Do NOT use it for + Reddit, YouTube, or Google Maps research — the dedicated tools return + richer data. Returns each query's parsed results: title, url, and + snippet per organic result. + Example: queries=['notebooklm review'], site='news.ycombinator.com'. + """ + return await run_scraper( + client, + context, + platform="google_search", + verb="scrape", + payload={ + "queries": queries, + "max_pages_per_query": max_pages_per_query, + "country_code": country_code, + "language_code": language_code, + "site": site, + }, + workspace=workspace, + response_format=response_format, + ) diff --git a/surfsense_mcp/mcp_server/features/scrapers/platforms/reddit.py b/surfsense_mcp/mcp_server/features/scrapers/platforms/reddit.py new file mode 100644 index 000000000..035193ebc --- /dev/null +++ b/surfsense_mcp/mcp_server/features/scrapers/platforms/reddit.py @@ -0,0 +1,98 @@ +"""Reddit scraper tool.""" + +from __future__ import annotations + +from typing import Annotated, Literal + +from mcp.server.fastmcp import FastMCP +from pydantic import Field + +from ....core.client import SurfSenseClient +from ....core.rendering import ResponseFormatParam +from ....core.workspace_context import WorkspaceContext, WorkspaceParam +from ..annotations import SCRAPE +from ..capability import run_scraper + +RedditSort = Literal["relevance", "hot", "top", "new", "rising", "comments"] +RedditTime = Literal["hour", "day", "week", "month", "year", "all"] + + +def register( + mcp: FastMCP, client: SurfSenseClient, context: WorkspaceContext +) -> None: + """Register the Reddit tool.""" + + @mcp.tool( + name="surfsense_reddit_scrape", + title="Search or scrape Reddit", + annotations=SCRAPE, + structured_output=False, + ) + async def reddit_scrape( + urls: Annotated[ + list[str] | None, + Field( + description="Reddit URLs: a post, a subreddit like " + "'https://reddit.com/r/LocalLLaMA', a user page, or a search " + "URL. Provide urls OR search_queries." + ), + ] = None, + search_queries: Annotated[ + list[str] | None, + Field( + description="Terms to search Reddit for, e.g. " + "['NotebookLM alternatives']. Provide search_queries OR urls." + ), + ] = None, + community: Annotated[ + str | None, + Field( + description="Restrict a search to one subreddit, name without " + "'r/', e.g. 'ArtificialInteligence'." + ), + ] = None, + sort: Annotated[RedditSort, Field(description="Post ordering.")] = "new", + time_filter: Annotated[ + RedditTime | None, + Field(description="Time window; only valid with sort='top'."), + ] = None, + max_items: Annotated[ + int, Field(ge=1, description="Maximum posts to return.") + ] = 10, + skip_comments: Annotated[ + bool, + Field( + description="True fetches posts only (faster); False also " + "fetches each post's comment thread." + ), + ] = False, + workspace: WorkspaceParam = None, + response_format: ResponseFormatParam = "markdown", + ) -> str: + """Search or scrape Reddit: posts, comments, subreddits, and users. + + Use this for ANY Reddit research — finding relevant subreddits or + communities for a topic, top posts, or discussions — instead of a + generic web search. Returns posts (title, text, score, subreddit, url) + with comment threads unless skip_comments is set. Every post carries + its subreddit, so to find communities for a topic, search posts and + aggregate their subreddits. + Example: search_queries=['NotebookLM'], sort='top', time_filter='month'. + """ + return await run_scraper( + client, + context, + platform="reddit", + verb="scrape", + payload={ + "urls": urls, + "search_queries": search_queries, + "community": community, + "sort": sort, + "time_filter": time_filter, + "max_items": max_items, + "skip_comments": skip_comments, + }, + workspace=workspace, + response_format=response_format, + ) diff --git a/surfsense_mcp/mcp_server/features/scrapers/platforms/web.py b/surfsense_mcp/mcp_server/features/scrapers/platforms/web.py new file mode 100644 index 000000000..9c24a4352 --- /dev/null +++ b/surfsense_mcp/mcp_server/features/scrapers/platforms/web.py @@ -0,0 +1,89 @@ +"""Web crawl scraper tool.""" + +from __future__ import annotations + +from typing import Annotated + +from mcp.server.fastmcp import FastMCP +from pydantic import Field + +from ....core.client import SurfSenseClient +from ....core.rendering import ResponseFormatParam +from ....core.workspace_context import WorkspaceContext, WorkspaceParam +from ..annotations import SCRAPE +from ..capability import run_scraper + + +def register( + mcp: FastMCP, client: SurfSenseClient, context: WorkspaceContext +) -> None: + """Register the web crawl tool.""" + + @mcp.tool( + name="surfsense_web_crawl", + title="Crawl web pages", + annotations=SCRAPE, + structured_output=False, + ) + async def web_crawl( + start_urls: Annotated[ + list[str], + Field( + min_length=1, + description="Full URLs to fetch, e.g. " + "['https://example.com/blog/post'].", + ), + ], + max_crawl_depth: Annotated[ + int, + Field( + ge=0, + description="Link-hops to follow from start_urls within the " + "same site. 0 fetches only start_urls.", + ), + ] = 0, + max_crawl_pages: Annotated[ + int, Field(ge=1, description="Stop after this many pages in total.") + ] = 10, + max_length: Annotated[ + int, Field(ge=1, description="Max characters kept per page.") + ] = 50_000, + include_url_patterns: Annotated[ + list[str] | None, + Field( + description="Regexes; only discovered links matching one are " + "followed, e.g. ['/docs/.*']." + ), + ] = None, + exclude_url_patterns: Annotated[ + list[str] | None, + Field(description="Regexes; discovered links matching one are skipped."), + ] = None, + workspace: WorkspaceParam = None, + response_format: ResponseFormatParam = "markdown", + ) -> str: + """Fetch specific web pages and return their cleaned content as markdown. + + Use this to read a page the user names, or to spider a site from a + starting URL. Do NOT use it to find pages on a topic — use + surfsense_google_search for discovery. Returns one item per crawled + page: url, title, and the page text as markdown. + Example: start_urls=['https://blog.example.com'], max_crawl_depth=1, + include_url_patterns=['/2026/']. + """ + return await run_scraper( + client, + context, + platform="web", + verb="crawl", + payload={ + "startUrls": start_urls, + "maxCrawlDepth": max_crawl_depth, + "maxCrawlPages": max_crawl_pages, + "maxLength": max_length, + "includeUrlPatterns": include_url_patterns, + "excludeUrlPatterns": exclude_url_patterns, + }, + workspace=workspace, + response_format=response_format, + ) diff --git a/surfsense_mcp/mcp_server/features/scrapers/platforms/youtube.py b/surfsense_mcp/mcp_server/features/scrapers/platforms/youtube.py new file mode 100644 index 000000000..5582c82bb --- /dev/null +++ b/surfsense_mcp/mcp_server/features/scrapers/platforms/youtube.py @@ -0,0 +1,131 @@ +"""YouTube scraper tools: videos and comments.""" + +from __future__ import annotations + +from typing import Annotated, Literal + +from mcp.server.fastmcp import FastMCP +from pydantic import Field + +from ....core.client import SurfSenseClient +from ....core.rendering import ResponseFormatParam +from ....core.workspace_context import WorkspaceContext, WorkspaceParam +from ..annotations import SCRAPE +from ..capability import run_scraper + +CommentSort = Literal["TOP_COMMENTS", "NEWEST_FIRST"] + + +def register( + mcp: FastMCP, client: SurfSenseClient, context: WorkspaceContext +) -> None: + """Register the YouTube video and comment tools.""" + + @mcp.tool( + name="surfsense_youtube_scrape", + title="Search or scrape YouTube", + annotations=SCRAPE, + structured_output=False, + ) + async def youtube_scrape( + urls: Annotated[ + list[str] | None, + Field( + description="YouTube URLs: video, channel, playlist, shorts, " + "or hashtag pages. Provide urls OR search_queries." + ), + ] = None, + search_queries: Annotated[ + list[str] | None, + Field( + description="Terms to search YouTube for, e.g. " + "['NotebookLM tutorial']. Provide search_queries OR urls." + ), + ] = None, + max_results: Annotated[ + int, Field(ge=1, description="Maximum videos to return.") + ] = 10, + download_subtitles: Annotated[ + bool, + Field(description="True also fetches each video's transcript."), + ] = False, + subtitles_language: Annotated[ + str, Field(description="Transcript language code, e.g. 'en'.") + ] = "en", + workspace: WorkspaceParam = None, + response_format: ResponseFormatParam = "markdown", + ) -> str: + """Search or scrape YouTube videos, optionally with transcripts. + + Use this for YouTube research: finding videos on a topic, or reading a + video's details or transcript. For a video's comment section use + surfsense_youtube_comments instead. Returns per-video metadata (title, + channel, views, description, url) and, if requested, the transcript. + Example: search_queries=['NotebookLM tutorial'], download_subtitles=True. + """ + return await run_scraper( + client, + context, + platform="youtube", + verb="scrape", + payload={ + "urls": urls, + "search_queries": search_queries, + "max_results": max_results, + "download_subtitles": download_subtitles, + "subtitles_language": subtitles_language, + }, + workspace=workspace, + response_format=response_format, + ) + + @mcp.tool( + name="surfsense_youtube_comments", + title="Fetch YouTube comments", + annotations=SCRAPE, + structured_output=False, + ) + async def youtube_comments( + urls: Annotated[ + list[str], + Field( + min_length=1, + description="YouTube video URLs, e.g. " + "['https://www.youtube.com/watch?v=abc123'].", + ), + ], + max_comments: Annotated[ + int, + Field( + ge=1, + description="Maximum comments per video, counting top-level " + "comments and replies together.", + ), + ] = 20, + sort_by: Annotated[ + CommentSort, Field(description="Comment ordering.") + ] = "NEWEST_FIRST", + workspace: WorkspaceParam = None, + response_format: ResponseFormatParam = "markdown", + ) -> str: + """Fetch the comments (and replies) on one or more YouTube videos. + + Use this when the user wants a video's discussion or audience reaction + rather than the video itself; get video URLs from + surfsense_youtube_scrape if you only have a topic. Returns comment + text, author, likes, and replies. + Example: urls=['https://www.youtube.com/watch?v=abc123'], max_comments=50. + """ + return await run_scraper( + client, + context, + platform="youtube", + verb="comments", + payload={ + "urls": urls, + "max_comments": max_comments, + "sort_by": sort_by, + }, + workspace=workspace, + response_format=response_format, + ) diff --git a/surfsense_mcp/mcp_server/features/scrapers/run_history.py b/surfsense_mcp/mcp_server/features/scrapers/run_history.py new file mode 100644 index 000000000..9274a1a69 --- /dev/null +++ b/surfsense_mcp/mcp_server/features/scrapers/run_history.py @@ -0,0 +1,112 @@ +"""Scraper run history: list past runs and fetch one in full. + +A scrape whose inline result was truncated is retrievable here by run id, so the +model never re-runs a scraper just to recover output. +""" + +from __future__ import annotations + +from typing import Annotated + +from mcp.server.fastmcp import FastMCP +from pydantic import Field + +from ...core.client import SurfSenseClient +from ...core.rendering import ResponseFormatParam, clip, to_json +from ...core.workspace_context import WorkspaceContext, WorkspaceParam +from .annotations import READ_RUNS + + +def register( + mcp: FastMCP, client: SurfSenseClient, context: WorkspaceContext +) -> None: + """Register the run-history tools.""" + + @mcp.tool( + name="surfsense_list_scraper_runs", + title="List past scraper runs", + annotations=READ_RUNS, + structured_output=False, + ) + async def list_scraper_runs( + limit: Annotated[ + int, Field(ge=1, description="Maximum runs to list.") + ] = 20, + capability: Annotated[ + str | None, + Field( + description="Filter by capability slug, e.g. 'web.crawl' or " + "'reddit.scrape'." + ), + ] = None, + status: Annotated[ + str | None, + Field(description="Filter by run status: 'success' or 'error'."), + ] = None, + workspace: WorkspaceParam = None, + response_format: ResponseFormatParam = "markdown", + ) -> str: + """List recent scraper runs in the workspace, newest first. + + Use this to find the run_id of an earlier scrape — for example when an + inline result was truncated — then fetch it in full with + surfsense_get_scraper_run. Returns each run's id, capability, status, + item count, and creation time. + Example: capability='reddit.scrape', status='success'. + """ + resolved = await context.resolve(workspace) + runs = await client.request( + "GET", + f"/workspaces/{resolved.id}/scrapers/runs", + params={ + "limit": limit, + "capability": capability, + "status": status, + }, + ) + if response_format == "json": + return to_json(runs) + return _render_runs(runs) + + @mcp.tool( + name="surfsense_get_scraper_run", + title="Fetch one scraper run in full", + annotations=READ_RUNS, + structured_output=False, + ) + async def get_scraper_run( + run_id: Annotated[ + str, + Field( + description="Run id from surfsense_list_scraper_runs or a " + "prior scrape's output." + ), + ], + workspace: WorkspaceParam = None, + response_format: ResponseFormatParam = "markdown", + ) -> str: + """Fetch a single scraper run in full, including its stored output. + + Use this to retrieve the complete, untruncated result of an earlier + scrape. Do NOT re-run a scraper just to recover a truncated result — + fetch the stored run instead. + """ + resolved = await context.resolve(workspace) + run = await client.request( + "GET", f"/workspaces/{resolved.id}/scrapers/runs/{run_id}" + ) + if response_format == "json": + return clip(to_json(run)) + return f"# Run {run.get('id', run_id)}\n\n```json\n{clip(to_json(run))}\n```" + + +def _render_runs(runs: list[dict] | None) -> str: + if not runs: + return "No scraper runs found." + lines = ["# Scraper runs", ""] + for run in runs: + lines.append( + f"- **{run.get('id')}** — {run.get('capability')} · {run.get('status')} · " + f"{run.get('item_count', 0)} item(s) · {run.get('created_at')}" + ) + return "\n".join(lines) diff --git a/surfsense_mcp/src/surfsense_mcp/features/workspaces/__init__.py b/surfsense_mcp/mcp_server/features/workspaces/__init__.py similarity index 100% rename from surfsense_mcp/src/surfsense_mcp/features/workspaces/__init__.py rename to surfsense_mcp/mcp_server/features/workspaces/__init__.py diff --git a/surfsense_mcp/src/surfsense_mcp/selfcheck.py b/surfsense_mcp/mcp_server/selfcheck.py similarity index 98% rename from surfsense_mcp/src/surfsense_mcp/selfcheck.py rename to surfsense_mcp/mcp_server/selfcheck.py index e38edc909..20224c173 100644 --- a/surfsense_mcp/src/surfsense_mcp/selfcheck.py +++ b/surfsense_mcp/mcp_server/selfcheck.py @@ -47,6 +47,8 @@ async def _collect_tools() -> dict[str, object]: api_prefix="/api/v1", timeout=5.0, default_workspace=None, + host="127.0.0.1", + port=8080, ) mcp, _client = build_server(settings) tools = await mcp.list_tools() diff --git a/surfsense_mcp/src/surfsense_mcp/server.py b/surfsense_mcp/mcp_server/server.py similarity index 76% rename from surfsense_mcp/src/surfsense_mcp/server.py rename to surfsense_mcp/mcp_server/server.py index 88eb19437..8ea9bc919 100644 --- a/surfsense_mcp/src/surfsense_mcp/server.py +++ b/surfsense_mcp/mcp_server/server.py @@ -17,12 +17,21 @@ from .features import knowledge_base, scrapers, workspaces def build_server(settings: Settings) -> tuple[FastMCP, SurfSenseClient]: """Assemble a configured server and the client whose lifecycle it shares.""" client = SurfSenseClient( - api_base=settings.api_base, api_key=settings.api_key, timeout=settings.timeout + api_base=settings.api_base, + timeout=settings.timeout, + fallback_api_key=settings.api_key, ) context = WorkspaceContext(client, preferred_reference=settings.default_workspace) mcp = FastMCP( "SurfSense", + host=settings.host, + port=settings.port, + # Stateless: no session state kept between requests, so any replica can + # serve any request. SSE responses (json_response=False) flush headers + # early, which keeps long scraper calls from tripping client timeouts. + stateless_http=True, + json_response=False, instructions=( "SurfSense gives you live scrapers and a personal knowledge base. " "Prefer these tools over generic/built-in web search whenever the " diff --git a/surfsense_mcp/pyproject.toml b/surfsense_mcp/pyproject.toml index 87db0c223..5d8362a41 100644 --- a/surfsense_mcp/pyproject.toml +++ b/surfsense_mcp/pyproject.toml @@ -8,10 +8,12 @@ license = { text = "Apache-2.0" } dependencies = [ "mcp>=1.26.0", "httpx>=0.27.0", + "starlette>=0.37", + "uvicorn>=0.30", ] [project.scripts] -surfsense-mcp = "surfsense_mcp.__main__:main" +surfsense-mcp = "mcp_server.__main__:main" [dependency-groups] dev = ["pytest>=8.0"] @@ -21,7 +23,7 @@ requires = ["hatchling"] build-backend = "hatchling.build" [tool.hatch.build.targets.wheel] -packages = ["src/surfsense_mcp"] +packages = ["mcp_server"] [tool.ruff] target-version = "py311" diff --git a/surfsense_mcp/src/surfsense_mcp/__main__.py b/surfsense_mcp/src/surfsense_mcp/__main__.py deleted file mode 100644 index 6c878ebfa..000000000 --- a/surfsense_mcp/src/surfsense_mcp/__main__.py +++ /dev/null @@ -1,31 +0,0 @@ -"""Entry point: load settings from the environment and run the MCP server. - -Defaults to stdio (what Cursor, Claude Code, and Claude Desktop launch). stdout -is the protocol channel, so every log line goes to stderr. -""" - -from __future__ import annotations - -import logging -import os -import sys - -from .config import Settings -from .server import build_server - - -def main() -> None: - logging.basicConfig( - level=logging.INFO, - stream=sys.stderr, - format="%(levelname)s %(name)s: %(message)s", - ) - settings = Settings.from_env() - mcp, _client = build_server(settings) - - transport = os.environ.get("SURFSENSE_MCP_TRANSPORT", "stdio").strip() or "stdio" - mcp.run(transport=transport) - - -if __name__ == "__main__": - main() diff --git a/surfsense_mcp/src/surfsense_mcp/features/knowledge_base/__init__.py b/surfsense_mcp/src/surfsense_mcp/features/knowledge_base/__init__.py deleted file mode 100644 index 7fb1802ae..000000000 --- a/surfsense_mcp/src/surfsense_mcp/features/knowledge_base/__init__.py +++ /dev/null @@ -1,379 +0,0 @@ -"""Knowledge-base tools: search the KB and manage its documents. - -Semantic search plus the document lifecycle — list, read, add text, upload a -file, update, and delete — over a workspace's knowledge base. Search and reads -default to the active workspace; document ids identify a single document across -the whole account, so id-addressed tools need no workspace. -""" - -from __future__ import annotations - -import mimetypes -from pathlib import Path -from typing import Annotated - -from mcp.server.fastmcp import FastMCP -from mcp.types import ToolAnnotations -from pydantic import Field - -from ...core.client import SurfSenseClient -from ...core.errors import ToolError -from ...core.rendering import ResponseFormatParam, clip, to_json -from ...core.workspace_context import WorkspaceContext, WorkspaceParam -from .note_ingestion import build_note_document - -_READ = ToolAnnotations( - readOnlyHint=True, destructiveHint=False, idempotentHint=True, openWorldHint=False -) -_WRITE = ToolAnnotations( - readOnlyHint=False, destructiveHint=False, idempotentHint=False, openWorldHint=False -) -_DELETE = ToolAnnotations( - readOnlyHint=False, destructiveHint=True, idempotentHint=False, openWorldHint=False -) - -_DOCUMENT_ID = Annotated[ - int, - Field( - description="Document id from surfsense_search_knowledge_base or " - "surfsense_list_documents results." - ), -] - -_DOCUMENT_TYPES = Annotated[ - list[str] | None, - Field( - description="Restrict to these document types, e.g. " - "['FILE', 'CRAWLED_URL', 'YOUTUBE_VIDEO']. Omit for all types." - ), -] - - -def register( - mcp: FastMCP, client: SurfSenseClient, context: WorkspaceContext -) -> None: - """Register the knowledge-base tools on the server.""" - - @mcp.tool( - name="surfsense_search_knowledge_base", - title="Search knowledge base", - annotations=_READ, - structured_output=False, - ) - async def search_knowledge_base( - query: Annotated[ - str, - Field( - min_length=1, - description="Natural-language search, e.g. " - "'notebooklm user complaints'.", - ), - ], - top_k: Annotated[ - int, Field(ge=1, le=20, description="Maximum documents to return.") - ] = 5, - document_types: _DOCUMENT_TYPES = None, - workspace: WorkspaceParam = None, - response_format: ResponseFormatParam = "markdown", - ) -> str: - """Search the workspace's knowledge base by meaning and keywords. - - Use this FIRST when a question might be answered by content already - stored in SurfSense — notes, uploaded files, saved pages, past - research. Do NOT use it to fetch new data from the web; use the - scraper tools for that. Returns the most relevant documents with the - passages that matched, ranked by relevance score. - Example: query='pricing feedback', top_k=5. - """ - resolved = await context.resolve(workspace) - hits = await client.request( - "POST", - "/documents/search-semantic", - json={ - "workspace_id": resolved.id, - "query": query, - "top_k": max(1, min(top_k, 20)), - "document_types": document_types, - }, - ) - items = (hits or {}).get("items", []) - if response_format == "json": - return to_json(items) - return _render_search(query, items) - - @mcp.tool( - name="surfsense_list_documents", - title="List documents", - annotations=_READ, - structured_output=False, - ) - async def list_documents( - document_types: _DOCUMENT_TYPES = None, - folder_id: Annotated[ - int | None, - Field(description="Only documents in this folder. Omit for all."), - ] = None, - page: Annotated[ - int, Field(ge=0, description="Zero-based page number.") - ] = 0, - page_size: Annotated[ - int, Field(ge=1, description="Documents per page.") - ] = 20, - workspace: WorkspaceParam = None, - response_format: ResponseFormatParam = "markdown", - ) -> str: - """List documents in the workspace's knowledge base, newest first. - - Use this to browse or inventory what is stored; to find documents - about a topic, prefer surfsense_search_knowledge_base. Returns each - document's title, id, type, and update time, plus a has_more flag — - request the next page by increasing page. - Example: document_types=['FILE'], page=0, page_size=20. - """ - resolved = await context.resolve(workspace) - result = await client.request( - "GET", - "/documents", - params={ - "workspace_id": resolved.id, - "page": page, - "page_size": page_size, - "document_types": _join(document_types), - "folder_id": folder_id, - }, - ) - if response_format == "json": - return to_json(result) - return _render_document_list(result) - - @mcp.tool( - name="surfsense_get_document", - title="Read one document", - annotations=_READ, - structured_output=False, - ) - async def get_document( - document_id: _DOCUMENT_ID, - response_format: ResponseFormatParam = "markdown", - ) -> str: - """Read one document's full content and metadata by id. - - Use this after surfsense_search_knowledge_base or - surfsense_list_documents to open a specific document — search results - only include the matching passages, this returns the whole text. - """ - document = await client.request("GET", f"/documents/{document_id}") - if response_format == "json": - return clip(to_json(document)) - return _render_document(document) - - @mcp.tool( - name="surfsense_add_document", - title="Add a note", - annotations=_WRITE, - structured_output=False, - ) - async def add_document( - title: Annotated[ - str, - Field(min_length=1, description="Short descriptive title for the note."), - ], - content: Annotated[ - str, - Field( - min_length=1, - description="The note's body; plain text or markdown.", - ), - ], - source_url: Annotated[ - str | None, - Field(description="Where the text came from, if anywhere."), - ] = None, - workspace: WorkspaceParam = None, - ) -> str: - """Save a text or markdown note into the workspace's knowledge base. - - Use this to store notes, summaries, or findings so they become - searchable later — e.g. after finishing a piece of research. For files - on disk use surfsense_upload_file instead. Indexing is asynchronous, - so the note may take a moment to appear in search. - Example: title='NotebookLM subreddits', content='- r/notebooklm ...'. - """ - resolved = await context.resolve(workspace) - await client.request( - "POST", - "/documents", - json=build_note_document( - workspace_id=resolved.id, - title=title, - content=content, - source_url=source_url, - ), - ) - return ( - f"Queued '{title}' for indexing in '{resolved.name}'. " - "It will be searchable once processing completes." - ) - - @mcp.tool( - name="surfsense_upload_file", - title="Upload a file", - annotations=_WRITE, - structured_output=False, - ) - async def upload_file( - file_path: Annotated[ - str, - Field( - description="Path to a local file, e.g. " - "'C:/Users/me/report.pdf' or '~/notes/summary.md'." - ), - ], - use_vision_llm: Annotated[ - bool, - Field( - description="True reads scanned or image-heavy files with a " - "vision model (slower)." - ), - ] = False, - workspace: WorkspaceParam = None, - ) -> str: - """Upload a local file (PDF, docx, markdown, etc.) into the knowledge base. - - Use this to ingest a file from disk so its content becomes searchable; - for text you already have in hand use surfsense_add_document instead. - The file is parsed, chunked, and indexed asynchronously. Duplicate - files are detected and skipped. - Example: file_path='C:/Users/me/report.pdf'. - """ - resolved = await context.resolve(workspace) - payload = _read_upload(file_path) - result = await client.request( - "POST", - "/documents/fileupload", - data={ - "workspace_id": str(resolved.id), - "use_vision_llm": str(use_vision_llm).lower(), - "processing_mode": "basic", - }, - files=[("files", payload)], - ) - pending = (result or {}).get("pending_files", 0) - skipped = (result or {}).get("skipped_duplicates", 0) - note = " (already present, skipped)" if skipped and not pending else "" - return ( - f"Uploaded '{Path(file_path).name}' to '{resolved.name}'{note}. " - "It will be searchable once processing completes." - ) - - @mcp.tool( - name="surfsense_update_document", - title="Replace a document's content", - annotations=_WRITE, - structured_output=False, - ) - async def update_document( - document_id: _DOCUMENT_ID, - content: Annotated[ - str, - Field( - min_length=1, - description="New full text; replaces the existing content " - "entirely.", - ), - ], - ) -> str: - """Replace a document's stored content by id. - - Use this to correct or rewrite a document's text. The new content - REPLACES the old entirely — to append, read the document first with - surfsense_get_document and resend the combined text. Search chunks are - not re-indexed by this call. - """ - existing = await client.request("GET", f"/documents/{document_id}") - await client.request( - "PUT", - f"/documents/{document_id}", - json={ - "document_type": existing["document_type"], - "workspace_id": existing["workspace_id"], - "content": content, - }, - ) - return f"Updated document {document_id} ('{existing.get('title', '')}')." - - @mcp.tool( - name="surfsense_delete_document", - title="Delete a document", - annotations=_DELETE, - structured_output=False, - ) - async def delete_document(document_id: _DOCUMENT_ID) -> str: - """Permanently delete a document from the knowledge base by id. - - Use this only when the user explicitly asks to remove a document — - deletion cannot be undone. The document stops appearing in searches - immediately. - """ - await client.request("DELETE", f"/documents/{document_id}") - return f"Deleted document {document_id}." - - -def _read_upload(file_path: str) -> tuple[str, bytes, str]: - path = Path(file_path).expanduser() - if not path.is_file(): - raise ToolError(f"No file at '{file_path}'.") - mime, _ = mimetypes.guess_type(path.name) - return (path.name, path.read_bytes(), mime or "application/octet-stream") - - -def _join(values: list[str] | None) -> str | None: - return ",".join(values) if values else None - - -def _render_search(query: str, items: list[dict]) -> str: - if not items: - return f'No matches for "{query}".' - lines = [f'# {len(items)} result(s) for "{query}"', ""] - for hit in items: - lines.append( - f"## {hit.get('title', 'Untitled')} " - f"(id {hit.get('document_id')}) — score {hit.get('score', 0):.3f}" - ) - for chunk in hit.get("chunks", []): - excerpt = clip(chunk.get("content", "").strip(), 500) - lines.append(f"> {excerpt}") - lines.append("") - return "\n".join(lines).strip() - - -def _render_document_list(result: dict | None) -> str: - items = (result or {}).get("items", []) - if not items: - return "No documents found." - lines = ["# Documents", ""] - for doc in items: - lines.append( - f"- **{doc.get('title', 'Untitled')}** (id {doc.get('id')}) · " - f"{doc.get('document_type')} · updated {doc.get('updated_at')}" - ) - total = (result or {}).get("total", len(items)) - page = (result or {}).get("page", 0) - has_more = (result or {}).get("has_more", False) - lines.append("") - lines.append( - f"_Page {page} · showing {len(items)} of {total}" - + (" · more available_" if has_more else "_") - ) - return "\n".join(lines) - - -def _render_document(document: dict) -> str: - content = clip(document.get("content", "") or "(empty)") - return ( - f"# {document.get('title', 'Untitled')} (id {document.get('id')})\n" - f"- type: {document.get('document_type')}\n" - f"- workspace: {document.get('workspace_id')}\n" - f"- updated: {document.get('updated_at')}\n\n" - f"{content}" - ) diff --git a/surfsense_mcp/src/surfsense_mcp/features/scrapers/__init__.py b/surfsense_mcp/src/surfsense_mcp/features/scrapers/__init__.py deleted file mode 100644 index aa8265148..000000000 --- a/surfsense_mcp/src/surfsense_mcp/features/scrapers/__init__.py +++ /dev/null @@ -1,570 +0,0 @@ -"""Scraper tools: one MCP surface per SurfSense platform capability. - -Web crawl, Google Search, Reddit, YouTube, and Google Maps each get a tool that -maps a natural-language request to the workspace's scraper door. Two more tools -list and fetch past runs, so a large result truncated inline can be retrieved in -full later. -""" - -from __future__ import annotations - -from typing import Annotated, Literal - -from mcp.server.fastmcp import FastMCP -from mcp.types import ToolAnnotations -from pydantic import Field - -from ...core.client import SurfSenseClient -from ...core.rendering import ResponseFormatParam, clip, to_json -from ...core.workspace_context import WorkspaceContext, WorkspaceParam -from .capability import run_scraper - -# Scrapers reach the open web and record a billable run; they are neither -# read-only nor idempotent, but they do not mutate the knowledge base. -_SCRAPE = ToolAnnotations( - readOnlyHint=False, destructiveHint=False, idempotentHint=False, openWorldHint=True -) -_READ_RUNS = ToolAnnotations( - readOnlyHint=True, destructiveHint=False, idempotentHint=True, openWorldHint=False -) - -RedditSort = Literal["relevance", "hot", "top", "new", "rising", "comments"] -RedditTime = Literal["hour", "day", "week", "month", "year", "all"] -CommentSort = Literal["TOP_COMMENTS", "NEWEST_FIRST"] -ReviewSort = Literal["newest", "mostRelevant", "highestRanking", "lowestRanking"] - - -def register( - mcp: FastMCP, client: SurfSenseClient, context: WorkspaceContext -) -> None: - """Register the scraper and run-history tools on the server.""" - - @mcp.tool( - name="surfsense_web_crawl", - title="Crawl web pages", - annotations=_SCRAPE, - structured_output=False, - ) - async def web_crawl( - start_urls: Annotated[ - list[str], - Field( - min_length=1, - description="Full URLs to fetch, e.g. " - "['https://example.com/blog/post'].", - ), - ], - max_crawl_depth: Annotated[ - int, - Field( - ge=0, - description="Link-hops to follow from start_urls within the " - "same site. 0 fetches only start_urls.", - ), - ] = 0, - max_crawl_pages: Annotated[ - int, Field(ge=1, description="Stop after this many pages in total.") - ] = 10, - max_length: Annotated[ - int, Field(ge=1, description="Max characters kept per page.") - ] = 50_000, - include_url_patterns: Annotated[ - list[str] | None, - Field( - description="Regexes; only discovered links matching one are " - "followed, e.g. ['/docs/.*']." - ), - ] = None, - exclude_url_patterns: Annotated[ - list[str] | None, - Field(description="Regexes; discovered links matching one are skipped."), - ] = None, - workspace: WorkspaceParam = None, - response_format: ResponseFormatParam = "markdown", - ) -> str: - """Fetch specific web pages and return their cleaned content as markdown. - - Use this to read a page the user names, or to spider a site from a - starting URL. Do NOT use it to find pages on a topic — use - surfsense_google_search for discovery. Returns one item per crawled - page: url, title, and the page text as markdown. - Example: start_urls=['https://blog.example.com'], max_crawl_depth=1, - include_url_patterns=['/2026/']. - """ - return await run_scraper( - client, - context, - platform="web", - verb="crawl", - payload={ - "startUrls": start_urls, - "maxCrawlDepth": max_crawl_depth, - "maxCrawlPages": max_crawl_pages, - "maxLength": max_length, - "includeUrlPatterns": include_url_patterns, - "excludeUrlPatterns": exclude_url_patterns, - }, - workspace=workspace, - response_format=response_format, - ) - - @mcp.tool( - name="surfsense_google_search", - title="Scrape Google Search", - annotations=_SCRAPE, - structured_output=False, - ) - async def google_search( - queries: Annotated[ - list[str], - Field( - min_length=1, - description="Search terms or full Google Search URLs, e.g. " - "['best rss readers 2026'].", - ), - ], - max_pages_per_query: Annotated[ - int, Field(ge=1, description="Result pages to fetch per query.") - ] = 1, - country_code: Annotated[ - str | None, - Field(description="Two-letter country to search from, e.g. 'us'."), - ] = None, - language_code: Annotated[ - str, Field(description="Results language, e.g. 'en'. Empty for default.") - ] = "", - site: Annotated[ - str | None, - Field( - description="Restrict results to one domain, e.g. 'example.com'." - ), - ] = None, - workspace: WorkspaceParam = None, - response_format: ResponseFormatParam = "markdown", - ) -> str: - """Scrape Google Search result pages for one or more queries. - - Use this to discover pages on the open web by topic; follow up with - surfsense_web_crawl to read a result in full. Do NOT use it for - Reddit, YouTube, or Google Maps research — the dedicated tools return - richer data. Returns each query's parsed results: title, url, and - snippet per organic result. - Example: queries=['notebooklm review'], site='news.ycombinator.com'. - """ - return await run_scraper( - client, - context, - platform="google_search", - verb="scrape", - payload={ - "queries": queries, - "max_pages_per_query": max_pages_per_query, - "country_code": country_code, - "language_code": language_code, - "site": site, - }, - workspace=workspace, - response_format=response_format, - ) - - @mcp.tool( - name="surfsense_reddit_scrape", - title="Search or scrape Reddit", - annotations=_SCRAPE, - structured_output=False, - ) - async def reddit_scrape( - urls: Annotated[ - list[str] | None, - Field( - description="Reddit URLs: a post, a subreddit like " - "'https://reddit.com/r/LocalLLaMA', a user page, or a search " - "URL. Provide urls OR search_queries." - ), - ] = None, - search_queries: Annotated[ - list[str] | None, - Field( - description="Terms to search Reddit for, e.g. " - "['NotebookLM alternatives']. Provide search_queries OR urls." - ), - ] = None, - community: Annotated[ - str | None, - Field( - description="Restrict a search to one subreddit, name without " - "'r/', e.g. 'ArtificialInteligence'." - ), - ] = None, - sort: Annotated[RedditSort, Field(description="Post ordering.")] = "new", - time_filter: Annotated[ - RedditTime | None, - Field(description="Time window; only valid with sort='top'."), - ] = None, - max_items: Annotated[ - int, Field(ge=1, description="Maximum posts to return.") - ] = 10, - skip_comments: Annotated[ - bool, - Field( - description="True fetches posts only (faster); False also " - "fetches each post's comment thread." - ), - ] = False, - workspace: WorkspaceParam = None, - response_format: ResponseFormatParam = "markdown", - ) -> str: - """Search or scrape Reddit: posts, comments, subreddits, and users. - - Use this for ANY Reddit research — finding relevant subreddits or - communities for a topic, top posts, or discussions — instead of a - generic web search. Returns posts (title, text, score, subreddit, url) - with comment threads unless skip_comments is set. Every post carries - its subreddit, so to find communities for a topic, search posts and - aggregate their subreddits. - Example: search_queries=['NotebookLM'], sort='top', time_filter='month'. - """ - return await run_scraper( - client, - context, - platform="reddit", - verb="scrape", - payload={ - "urls": urls, - "search_queries": search_queries, - "community": community, - "sort": sort, - "time_filter": time_filter, - "max_items": max_items, - "skip_comments": skip_comments, - }, - workspace=workspace, - response_format=response_format, - ) - - @mcp.tool( - name="surfsense_youtube_scrape", - title="Search or scrape YouTube", - annotations=_SCRAPE, - structured_output=False, - ) - async def youtube_scrape( - urls: Annotated[ - list[str] | None, - Field( - description="YouTube URLs: video, channel, playlist, shorts, " - "or hashtag pages. Provide urls OR search_queries." - ), - ] = None, - search_queries: Annotated[ - list[str] | None, - Field( - description="Terms to search YouTube for, e.g. " - "['NotebookLM tutorial']. Provide search_queries OR urls." - ), - ] = None, - max_results: Annotated[ - int, Field(ge=1, description="Maximum videos to return.") - ] = 10, - download_subtitles: Annotated[ - bool, - Field(description="True also fetches each video's transcript."), - ] = False, - subtitles_language: Annotated[ - str, Field(description="Transcript language code, e.g. 'en'.") - ] = "en", - workspace: WorkspaceParam = None, - response_format: ResponseFormatParam = "markdown", - ) -> str: - """Search or scrape YouTube videos, optionally with transcripts. - - Use this for YouTube research: finding videos on a topic, or reading a - video's details or transcript. For a video's comment section use - surfsense_youtube_comments instead. Returns per-video metadata (title, - channel, views, description, url) and, if requested, the transcript. - Example: search_queries=['NotebookLM tutorial'], download_subtitles=True. - """ - return await run_scraper( - client, - context, - platform="youtube", - verb="scrape", - payload={ - "urls": urls, - "search_queries": search_queries, - "max_results": max_results, - "download_subtitles": download_subtitles, - "subtitles_language": subtitles_language, - }, - workspace=workspace, - response_format=response_format, - ) - - @mcp.tool( - name="surfsense_youtube_comments", - title="Fetch YouTube comments", - annotations=_SCRAPE, - structured_output=False, - ) - async def youtube_comments( - urls: Annotated[ - list[str], - Field( - min_length=1, - description="YouTube video URLs, e.g. " - "['https://www.youtube.com/watch?v=abc123'].", - ), - ], - max_comments: Annotated[ - int, - Field( - ge=1, - description="Maximum comments per video, counting top-level " - "comments and replies together.", - ), - ] = 20, - sort_by: Annotated[ - CommentSort, Field(description="Comment ordering.") - ] = "NEWEST_FIRST", - workspace: WorkspaceParam = None, - response_format: ResponseFormatParam = "markdown", - ) -> str: - """Fetch the comments (and replies) on one or more YouTube videos. - - Use this when the user wants a video's discussion or audience reaction - rather than the video itself; get video URLs from - surfsense_youtube_scrape if you only have a topic. Returns comment - text, author, likes, and replies. - Example: urls=['https://www.youtube.com/watch?v=abc123'], max_comments=50. - """ - return await run_scraper( - client, - context, - platform="youtube", - verb="comments", - payload={ - "urls": urls, - "max_comments": max_comments, - "sort_by": sort_by, - }, - workspace=workspace, - response_format=response_format, - ) - - @mcp.tool( - name="surfsense_google_maps_scrape", - title="Find places on Google Maps", - annotations=_SCRAPE, - structured_output=False, - ) - async def google_maps_scrape( - search_queries: Annotated[ - list[str] | None, - Field( - description="Place searches, e.g. ['coffee shops']. Provide " - "search_queries OR urls OR place_ids." - ), - ] = None, - urls: Annotated[ - list[str] | None, - Field(description="Google Maps URLs of specific places."), - ] = None, - place_ids: Annotated[ - list[str] | None, - Field(description="Google place ids, e.g. ['ChIJj61dQgK6j4AR...']."), - ] = None, - location: Annotated[ - str | None, - Field( - description="Geographic scope for a search, e.g. " - "'Seattle, USA'." - ), - ] = None, - max_places: Annotated[ - int, Field(ge=1, description="Maximum places to return.") - ] = 10, - include_details: Annotated[ - bool, - Field( - description="True adds opening hours and extra contact info " - "(slower)." - ), - ] = False, - workspace: WorkspaceParam = None, - response_format: ResponseFormatParam = "markdown", - ) -> str: - """Find places on Google Maps by search, URL, or place id. - - Use this for local-business and location research: names, addresses, - ratings, categories, coordinates, place ids. For a place's customer - reviews use surfsense_google_maps_reviews instead. - Example: search_queries=['ramen'], location='Osaka, Japan', max_places=5. - """ - return await run_scraper( - client, - context, - platform="google_maps", - verb="scrape", - payload={ - "search_queries": search_queries, - "urls": urls, - "place_ids": place_ids, - "location": location, - "max_places": max_places, - "include_details": include_details, - }, - workspace=workspace, - response_format=response_format, - ) - - @mcp.tool( - name="surfsense_google_maps_reviews", - title="Fetch Google Maps reviews", - annotations=_SCRAPE, - structured_output=False, - ) - async def google_maps_reviews( - urls: Annotated[ - list[str] | None, - Field( - description="Google Maps URLs of places. Provide urls OR " - "place_ids." - ), - ] = None, - place_ids: Annotated[ - list[str] | None, - Field( - description="Google place ids from surfsense_google_maps_scrape." - ), - ] = None, - max_reviews: Annotated[ - int, Field(ge=1, description="Maximum reviews per place.") - ] = 20, - sort_by: Annotated[ - ReviewSort, Field(description="Review ordering.") - ] = "newest", - language: Annotated[ - str, Field(description="Reviews language code, e.g. 'en'.") - ] = "en", - start_date: Annotated[ - str | None, - Field( - description="ISO date like '2026-01-01'; keeps only reviews on " - "or after that day." - ), - ] = None, - workspace: WorkspaceParam = None, - response_format: ResponseFormatParam = "markdown", - ) -> str: - """Fetch customer reviews for Google Maps places by URL or place id. - - Use this to read feedback on specific places; get urls or place_ids - from surfsense_google_maps_scrape first if you only have a name. - Returns review text, rating, author, and date per review. - Example: place_ids=['ChIJj61dQgK6j4AR...'], sort_by='newest'. - """ - return await run_scraper( - client, - context, - platform="google_maps", - verb="reviews", - payload={ - "urls": urls, - "place_ids": place_ids, - "max_reviews": max_reviews, - "sort_by": sort_by, - "language": language, - "start_date": start_date, - }, - workspace=workspace, - response_format=response_format, - ) - - @mcp.tool( - name="surfsense_list_scraper_runs", - title="List past scraper runs", - annotations=_READ_RUNS, - structured_output=False, - ) - async def list_scraper_runs( - limit: Annotated[ - int, Field(ge=1, description="Maximum runs to list.") - ] = 20, - capability: Annotated[ - str | None, - Field( - description="Filter by capability slug, e.g. 'web.crawl' or " - "'reddit.scrape'." - ), - ] = None, - status: Annotated[ - str | None, - Field(description="Filter by run status: 'success' or 'error'."), - ] = None, - workspace: WorkspaceParam = None, - response_format: ResponseFormatParam = "markdown", - ) -> str: - """List recent scraper runs in the workspace, newest first. - - Use this to find the run_id of an earlier scrape — for example when an - inline result was truncated — then fetch it in full with - surfsense_get_scraper_run. Returns each run's id, capability, status, - item count, and creation time. - Example: capability='reddit.scrape', status='success'. - """ - resolved = await context.resolve(workspace) - runs = await client.request( - "GET", - f"/workspaces/{resolved.id}/scrapers/runs", - params={ - "limit": limit, - "capability": capability, - "status": status, - }, - ) - if response_format == "json": - return to_json(runs) - return _render_runs(runs) - - @mcp.tool( - name="surfsense_get_scraper_run", - title="Fetch one scraper run in full", - annotations=_READ_RUNS, - structured_output=False, - ) - async def get_scraper_run( - run_id: Annotated[ - str, - Field( - description="Run id from surfsense_list_scraper_runs or a " - "prior scrape's output." - ), - ], - workspace: WorkspaceParam = None, - response_format: ResponseFormatParam = "markdown", - ) -> str: - """Fetch a single scraper run in full, including its stored output. - - Use this to retrieve the complete, untruncated result of an earlier - scrape. Do NOT re-run a scraper just to recover a truncated result — - fetch the stored run instead. - """ - resolved = await context.resolve(workspace) - run = await client.request( - "GET", f"/workspaces/{resolved.id}/scrapers/runs/{run_id}" - ) - if response_format == "json": - return clip(to_json(run)) - return f"# Run {run.get('id', run_id)}\n\n```json\n{clip(to_json(run))}\n```" - - -def _render_runs(runs: list[dict] | None) -> str: - if not runs: - return "No scraper runs found." - lines = ["# Scraper runs", ""] - for run in runs: - lines.append( - f"- **{run.get('id')}** — {run.get('capability')} · {run.get('status')} · " - f"{run.get('item_count', 0)} item(s) · {run.get('created_at')}" - ) - return "\n".join(lines) diff --git a/surfsense_mcp/tests/test_auth_headers.py b/surfsense_mcp/tests/test_auth_headers.py new file mode 100644 index 000000000..feb21a9e1 --- /dev/null +++ b/surfsense_mcp/tests/test_auth_headers.py @@ -0,0 +1,40 @@ +"""API key extraction from request headers: Bearer, fallback, and rejection.""" + +from __future__ import annotations + +from starlette.datastructures import Headers + +from mcp_server.core.auth.headers import extract_api_key + + +def _headers(**pairs: str) -> Headers: + return Headers(pairs) + + +def test_reads_bearer_token(): + assert extract_api_key(_headers(authorization="Bearer ss_pat_abc")) == "ss_pat_abc" + + +def test_bearer_scheme_is_case_insensitive(): + assert extract_api_key(_headers(authorization="bearer ss_pat_abc")) == "ss_pat_abc" + + +def test_falls_back_to_x_api_key(): + assert extract_api_key(Headers({"x-api-key": "ss_pat_xyz"})) == "ss_pat_xyz" + + +def test_bearer_wins_over_fallback(): + headers = Headers({"authorization": "Bearer primary", "x-api-key": "secondary"}) + assert extract_api_key(headers) == "primary" + + +def test_missing_headers_return_none(): + assert extract_api_key(_headers()) is None + + +def test_empty_bearer_is_rejected(): + assert extract_api_key(_headers(authorization="Bearer ")) is None + + +def test_non_bearer_authorization_is_ignored(): + assert extract_api_key(_headers(authorization="Basic abc123")) is None diff --git a/surfsense_mcp/tests/test_client_errors.py b/surfsense_mcp/tests/test_client_errors.py index 648bfde3e..3245f39b1 100644 --- a/surfsense_mcp/tests/test_client_errors.py +++ b/surfsense_mcp/tests/test_client_errors.py @@ -4,7 +4,7 @@ from __future__ import annotations import httpx -from surfsense_mcp.core.client import SurfSenseClient +from mcp_server.core.client import SurfSenseClient _REQUEST = httpx.Request("GET", "http://localhost:8000/api/v1/documents") @@ -15,7 +15,7 @@ def _response(status: int, **kwargs) -> httpx.Response: def test_explains_401_with_token_hint(): message = SurfSenseClient._explain_failure(_response(401, json={"detail": "bad"})) - assert "SURFSENSE_API_KEY" in message + assert "API key" in message assert "bad" in message diff --git a/surfsense_mcp/tests/test_client_params.py b/surfsense_mcp/tests/test_client_params.py index 4840ad488..c6b013d88 100644 --- a/surfsense_mcp/tests/test_client_params.py +++ b/surfsense_mcp/tests/test_client_params.py @@ -6,7 +6,7 @@ import asyncio import httpx -from surfsense_mcp.core.client import SurfSenseClient +from mcp_server.core.client import SurfSenseClient def _capture(client: SurfSenseClient) -> dict: @@ -25,7 +25,9 @@ def _capture(client: SurfSenseClient) -> dict: def test_none_params_are_dropped(): - client = SurfSenseClient(api_base="http://test/api/v1", api_key="ss_pat_x", timeout=5) + client = SurfSenseClient( + api_base="http://test/api/v1", timeout=5, fallback_api_key="ss_pat_x" + ) seen = _capture(client) asyncio.run( client.request( diff --git a/surfsense_mcp/tests/test_note_ingestion.py b/surfsense_mcp/tests/test_note_ingestion.py index 6ab16acfc..da980e5db 100644 --- a/surfsense_mcp/tests/test_note_ingestion.py +++ b/surfsense_mcp/tests/test_note_ingestion.py @@ -2,7 +2,7 @@ from __future__ import annotations -from surfsense_mcp.features.knowledge_base.note_ingestion import build_note_document +from mcp_server.features.knowledge_base.note_ingestion import build_note_document def test_builds_extension_document_with_content(): diff --git a/surfsense_mcp/tests/test_rendering.py b/surfsense_mcp/tests/test_rendering.py index 5b254e39a..fd5024f86 100644 --- a/surfsense_mcp/tests/test_rendering.py +++ b/surfsense_mcp/tests/test_rendering.py @@ -2,7 +2,7 @@ from __future__ import annotations -from surfsense_mcp.core.rendering import clip, compact_items, to_json +from mcp_server.core.rendering import clip, compact_items, to_json def test_clip_leaves_short_text_untouched(): diff --git a/surfsense_mcp/tests/test_request_auth.py b/surfsense_mcp/tests/test_request_auth.py new file mode 100644 index 000000000..088946196 --- /dev/null +++ b/surfsense_mcp/tests/test_request_auth.py @@ -0,0 +1,100 @@ +"""Per-request key resolution and the Authorization header the backend receives. + +Covers the security-critical behaviors: the per-request key wins over the env +fallback, the fallback covers stdio, a missing key is refused, and concurrent +callers never see each other's key. +""" + +from __future__ import annotations + +import asyncio + +import httpx +import pytest + +from mcp_server.core.auth import identity +from mcp_server.core.client import SurfSenseClient +from mcp_server.core.errors import ToolError + + +def _client_recording_auth(seen: dict, *, fallback: str | None) -> SurfSenseClient: + async def handler(request: httpx.Request) -> httpx.Response: + seen["authorization"] = request.headers.get("authorization") + return httpx.Response(200, json={"ok": True}) + + client = SurfSenseClient( + api_base="http://test/api/v1", timeout=5, fallback_api_key=fallback + ) + client._http = httpx.AsyncClient( + base_url="http://test/api/v1", transport=httpx.MockTransport(handler) + ) + return client + + +async def _get(client: SurfSenseClient) -> None: + await client.request("GET", "/workspaces") + + +def test_request_key_is_sent_as_bearer(): + seen: dict = {} + client = _client_recording_auth(seen, fallback=None) + + async def run() -> None: + token = identity.bind_api_key("ss_pat_request") + try: + await _get(client) + finally: + identity.unbind_api_key(token) + + asyncio.run(run()) + assert seen["authorization"] == "Bearer ss_pat_request" + + +def test_request_key_overrides_env_fallback(): + seen: dict = {} + client = _client_recording_auth(seen, fallback="ss_pat_env") + + async def run() -> None: + token = identity.bind_api_key("ss_pat_request") + try: + await _get(client) + finally: + identity.unbind_api_key(token) + + asyncio.run(run()) + assert seen["authorization"] == "Bearer ss_pat_request" + + +def test_env_fallback_used_without_request_key(): + seen: dict = {} + client = _client_recording_auth(seen, fallback="ss_pat_env") + asyncio.run(_get(client)) + assert seen["authorization"] == "Bearer ss_pat_env" + + +def test_missing_key_is_refused(): + client = _client_recording_auth({}, fallback=None) + with pytest.raises(ToolError): + asyncio.run(_get(client)) + + +def test_concurrent_callers_do_not_leak_keys(): + seen_by_caller: dict[str, str | None] = {} + + async def call_as(key: str) -> None: + # Each caller runs in its own task, so the contextvar is isolated. + recorded: dict = {} + client = _client_recording_auth(recorded, fallback=None) + token = identity.bind_api_key(key) + try: + await _get(client) + finally: + identity.unbind_api_key(token) + seen_by_caller[key] = recorded["authorization"] + + async def run() -> None: + await asyncio.gather(call_as("ss_pat_A"), call_as("ss_pat_B")) + + asyncio.run(run()) + assert seen_by_caller["ss_pat_A"] == "Bearer ss_pat_A" + assert seen_by_caller["ss_pat_B"] == "Bearer ss_pat_B" diff --git a/surfsense_mcp/tests/test_workspace_context.py b/surfsense_mcp/tests/test_workspace_context.py index 9ba9c2ca3..6d5eace8f 100644 --- a/surfsense_mcp/tests/test_workspace_context.py +++ b/surfsense_mcp/tests/test_workspace_context.py @@ -6,8 +6,9 @@ import asyncio import pytest -from surfsense_mcp.core.errors import ToolError -from surfsense_mcp.core.workspace_context import WorkspaceContext +from mcp_server.core.auth import identity +from mcp_server.core.errors import ToolError +from mcp_server.core.workspace_context import WorkspaceContext class FakeClient: @@ -96,3 +97,30 @@ def test_resolution_is_remembered_as_active(): assert ctx.active is not None and ctx.active.id == 2 # a later default call reuses the active selection without re-choosing assert asyncio.run(ctx.resolve(None)).id == 2 + + +def test_active_workspace_is_isolated_per_identity(): + ctx = _context(_rows(("A", 1), ("B", 2))) + + async def select_as(key: str, reference: str) -> None: + token = identity.bind_api_key(key) + try: + await ctx.resolve(reference) + finally: + identity.unbind_api_key(token) + + async def active_for(key: str) -> int | None: + token = identity.bind_api_key(key) + try: + return ctx.active.id if ctx.active else None + finally: + identity.unbind_api_key(token) + + asyncio.run(select_as("ss_pat_A", "A")) + asyncio.run(select_as("ss_pat_B", "B")) + + # Each caller keeps its own selection; no bleed across identities. + assert asyncio.run(active_for("ss_pat_A")) == 1 + assert asyncio.run(active_for("ss_pat_B")) == 2 + # An unknown caller has no active selection. + assert asyncio.run(active_for("ss_pat_C")) is None diff --git a/surfsense_mcp/uv.lock b/surfsense_mcp/uv.lock index bc97a9b2a..d1bfab702 100644 --- a/surfsense_mcp/uv.lock +++ b/surfsense_mcp/uv.lock @@ -718,6 +718,8 @@ source = { editable = "." } dependencies = [ { name = "httpx" }, { name = "mcp" }, + { name = "starlette" }, + { name = "uvicorn" }, ] [package.dev-dependencies] @@ -729,6 +731,8 @@ dev = [ requires-dist = [ { name = "httpx", specifier = ">=0.27.0" }, { name = "mcp", specifier = ">=1.26.0" }, + { name = "starlette", specifier = ">=0.37" }, + { name = "uvicorn", specifier = ">=0.30" }, ] [package.metadata.requires-dev] diff --git a/surfsense_web/app/(home)/mcp-server/page.tsx b/surfsense_web/app/(home)/mcp-server/page.tsx index 6dfc69622..5e4961b1e 100644 --- a/surfsense_web/app/(home)/mcp-server/page.tsx +++ b/surfsense_web/app/(home)/mcp-server/page.tsx @@ -50,16 +50,13 @@ export const metadata: Metadata = { }, }; -/* Mirrors surfsense_mcp/README.md: the real Cursor config. */ +/* The hosted Cursor config; mirrors lib/mcp/clients.ts. */ const CURSOR_CONFIG = `{ "mcpServers": { "surfsense": { - "command": "uv", - "args": ["run", "--directory", ".../surfsense_mcp", - "python", "-m", "surfsense_mcp"], - "env": { - "SURFSENSE_BASE_URL": "https://api.surfsense.com", - "SURFSENSE_API_KEY": "ss_pat_..." + "url": "https://mcp.surfsense.com/mcp", + "headers": { + "Authorization": "Bearer ss_pat_..." } } } @@ -76,7 +73,7 @@ const STEPS = [ icon: TerminalSquare, title: "Add the server to your client", description: - "Drop the config into Cursor's mcp.json, run claude mcp add for Claude Code, or paste it into Claude Desktop. Point it at the cloud or your own self-hosted instance.", + "Point your client at https://mcp.surfsense.com/mcp with your key in an Authorization header — the hosted config for Cursor, Claude Code, and others is one paste. Prefer stdio? Switch to Self-host and run it against your own backend.", }, { icon: Server, @@ -135,7 +132,7 @@ const FAQ: FaqItem[] = [ { question: "Which MCP clients does it work with?", answer: - "Any MCP client that supports stdio servers. Claude Code, Codex, OpenCode, Cursor, Claude Desktop, VS Code, Windsurf, and Gemini CLI are documented with copy-paste configs on this page, and the same command works in custom agent harnesses built on the MCP SDK.", + "Any MCP client that speaks remote (streamable HTTP) or stdio. Claude Code, Codex, OpenCode, Cursor, Claude Desktop, VS Code, Windsurf, and Gemini CLI all have copy-paste configs on this page — Hosted for the one-paste https://mcp.surfsense.com/mcp endpoint, or Self-host for stdio against your own backend.", }, { question: "How is usage billed?", @@ -278,9 +275,9 @@ export default function McpServerPage() { Step-by-step setup for every agent

- Pick your client, follow its two steps, and paste the config. Replace the placeholder - path with your surfsense_mcp checkout and the key with one from API Playground → API - Keys — or grab a pre-filled config from the playground itself. + Pick your client, choose Hosted or Self-host, and + paste the config. Replace the key with one from API Playground → API Keys — or grab a + pre-filled config from the playground itself.

diff --git a/surfsense_web/app/dashboard/[workspace_id]/playground/api-keys/page.tsx b/surfsense_web/app/dashboard/[workspace_id]/playground/api-keys/page.tsx index 0dbd12818..28a532e92 100644 --- a/surfsense_web/app/dashboard/[workspace_id]/playground/api-keys/page.tsx +++ b/surfsense_web/app/dashboard/[workspace_id]/playground/api-keys/page.tsx @@ -1,4 +1,8 @@ -import { ApiKeysSection } from "../components/api-keys-section"; +import { Info } from "lucide-react"; +import { WorkspaceApiAccessControl } from "@/components/settings/workspace-api-access-control"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { Separator } from "@/components/ui/separator"; +import { ApiKeyContent } from "../../user-settings/components/ApiKeyContent"; export default async function PlaygroundApiKeysPage({ params, @@ -6,10 +10,33 @@ export default async function PlaygroundApiKeysPage({ params: Promise<{ workspace_id: string }>; }) { const { workspace_id } = await params; + const workspaceId = Number(workspace_id); return ( -
- +
+
+

API keys

+

+ Create user API keys and choose whether they can access this workspace. +

+
+ + + + + External API calls need both a user API key and workspace API key access enabled. + + + +
+ +
+ + + +
+ +
); } diff --git a/surfsense_web/app/dashboard/[workspace_id]/playground/components/api-keys-section.tsx b/surfsense_web/app/dashboard/[workspace_id]/playground/components/api-keys-section.tsx deleted file mode 100644 index e3df313f0..000000000 --- a/surfsense_web/app/dashboard/[workspace_id]/playground/components/api-keys-section.tsx +++ /dev/null @@ -1,82 +0,0 @@ -"use client"; - -import { useQuery } from "@tanstack/react-query"; -import { useAtomValue } from "jotai"; -import { useState } from "react"; -import { toast } from "sonner"; -import { updateWorkspaceApiAccessMutationAtom } from "@/atoms/workspaces/workspace-mutation.atoms"; -import { Label } from "@/components/ui/label"; -import { Skeleton } from "@/components/ui/skeleton"; -import { Switch } from "@/components/ui/switch"; -import { workspacesApiService } from "@/lib/apis/workspaces-api.service"; -import { cacheKeys } from "@/lib/query-client/cache-keys"; -import { ApiKeyContent } from "../../user-settings/components/ApiKeyContent"; - -/** - * One-stop API key management for the playground: the workspace API-access - * toggle (otherwise buried in workspace settings) plus the personal API key - * manager (otherwise buried in user settings). - */ -export function ApiKeysSection({ workspaceId }: { workspaceId: number }) { - const { - data: workspace, - isLoading, - refetch, - } = useQuery({ - queryKey: cacheKeys.workspaces.detail(workspaceId.toString()), - queryFn: () => workspacesApiService.getWorkspace({ id: workspaceId }), - enabled: !!workspaceId, - }); - const { mutateAsync: updateWorkspaceApiAccess } = useAtomValue( - updateWorkspaceApiAccessMutationAtom - ); - const [saving, setSaving] = useState(false); - - const handleToggle = async (enabled: boolean) => { - try { - setSaving(true); - await updateWorkspaceApiAccess({ id: workspaceId, api_access_enabled: enabled }); - await refetch(); - } catch (error) { - console.error("Error updating API access:", error); - toast.error(error instanceof Error ? error.message : "Failed to update API access"); - } finally { - setSaving(false); - } - }; - - const apiAccessEnabled = !!workspace?.api_access_enabled; - - return ( -
-
-

API Keys

-

- Enable API access for this workspace and manage the keys that use it. -

-
- -
-
- -

- Allow API keys to access this workspace. - {!isLoading && !apiAccessEnabled && " Currently disabled — keys won't work here."} -

-
- {isLoading ? ( - - ) : ( - - )} -
- - -
- ); -} diff --git a/surfsense_web/app/dashboard/[workspace_id]/playground/components/api-reference.tsx b/surfsense_web/app/dashboard/[workspace_id]/playground/components/api-reference.tsx index acdcddd6c..05434b953 100644 --- a/surfsense_web/app/dashboard/[workspace_id]/playground/components/api-reference.tsx +++ b/surfsense_web/app/dashboard/[workspace_id]/playground/components/api-reference.tsx @@ -1,6 +1,6 @@ "use client"; -import { Check, Copy } from "lucide-react"; +import { Check, ChevronRight, Copy } from "lucide-react"; import { useMemo, useState } from "react"; import { Button } from "@/components/ui/button"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; @@ -22,10 +22,10 @@ function CopyButton({ text }: { text: string }) { variant="ghost" size="sm" onClick={copy} - className="absolute right-2 top-2 h-7 gap-1.5 px-2 text-xs" + aria-label={copied ? "Copied" : "Copy"} + className="absolute right-2 top-2 h-7 w-7 p-0" > {copied ? : } - {copied ? "Copied" : "Copy"} ); } @@ -45,8 +45,9 @@ function SchemaBlock({ title, schema }: { title: string; schema: Record JSON.stringify(schema, null, 2), [schema]); return (
- - {title} + + {title} +
@@ -90,16 +91,13 @@ export function ApiReference({

API reference

- Call this API from your own project. Create a key under{" "} - API Keys (and enable API access for - this workspace), then send it as a{" "} - Authorization: Bearer{" "} - header. + Create an API key, enable API access for this workspace, then use the examples below to + call this endpoint.

- + {snippets.map((snippet) => ( {snippet.label} diff --git a/surfsense_web/app/dashboard/[workspace_id]/playground/components/output-viewer.tsx b/surfsense_web/app/dashboard/[workspace_id]/playground/components/output-viewer.tsx index 606548e14..e692734d3 100644 --- a/surfsense_web/app/dashboard/[workspace_id]/playground/components/output-viewer.tsx +++ b/surfsense_web/app/dashboard/[workspace_id]/playground/components/output-viewer.tsx @@ -3,6 +3,7 @@ import { Check, Copy, Download } from "lucide-react"; import { useMemo, useState } from "react"; import { Button } from "@/components/ui/button"; +import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Table, TableBody, @@ -12,7 +13,6 @@ import { TableRow, } from "@/components/ui/table"; import { downloadCsv, rowsToCsv } from "@/lib/playground/csv"; -import { cn } from "@/lib/utils"; const MAX_TABLE_ROWS = 200; @@ -109,34 +109,12 @@ export function OutputViewer({ data, filenameBase }: { data: unknown; filenameBa return (
-
- {items && ( - - )} - -
+ setView(value as "table" | "json")}> + + {items && Table} + JSON + +
{items && items.length > 0 && (
@@ -168,7 +152,7 @@ export function OutputViewer({ data, filenameBase }: { data: unknown; filenameBa {truncated && (

- Showing first {MAX_TABLE_ROWS} of {items.length} items. Use Copy JSON for the full + Showing first {MAX_TABLE_ROWS} of {items.length} items. Switch to JSON for the full output.

)} diff --git a/surfsense_web/app/dashboard/[workspace_id]/playground/components/playground-index.tsx b/surfsense_web/app/dashboard/[workspace_id]/playground/components/playground-index.tsx index 741ed291f..58d56e146 100644 --- a/surfsense_web/app/dashboard/[workspace_id]/playground/components/playground-index.tsx +++ b/surfsense_web/app/dashboard/[workspace_id]/playground/components/playground-index.tsx @@ -1,8 +1,9 @@ "use client"; -import { ArrowRight, History, KeyRound } from "lucide-react"; +import { ArrowRight, History, Info, KeyRound } from "lucide-react"; import Link from "next/link"; import { useMemo } from "react"; +import { Alert, AlertDescription } from "@/components/ui/alert"; import { useScraperCapabilities } from "@/hooks/use-scraper-capabilities"; import { PLAYGROUND_PLATFORMS } from "@/lib/playground/catalog"; import { formatPricing } from "@/lib/playground/format"; @@ -20,13 +21,21 @@ export function PlaygroundIndex({ workspaceId }: { workspaceId: number }) { return (
-
-

API Playground

-

- Manually run SurfSense's platform-native APIs and inspect their output. Every run is - captured under Runs. -

-
+ + + +

+ Manually run SurfSense's platform-native APIs and inspect their output. To use these APIs outside SurfSense,{" "} + + create an API key + + . +

+
+
-

Runs

+

API Runs

See every API run in this workspace

@@ -50,9 +59,7 @@ export function PlaygroundIndex({ workspaceId }: { workspaceId: number }) {

API Keys

-

- Enable workspace access and manage keys -

+

Manage keys and workspace API access

diff --git a/surfsense_web/app/dashboard/[workspace_id]/playground/components/playground-runner.tsx b/surfsense_web/app/dashboard/[workspace_id]/playground/components/playground-runner.tsx index 3b76a7d09..6b426b196 100644 --- a/surfsense_web/app/dashboard/[workspace_id]/playground/components/playground-runner.tsx +++ b/surfsense_web/app/dashboard/[workspace_id]/playground/components/playground-runner.tsx @@ -1,14 +1,16 @@ "use client"; -import { AlertTriangle, Coins, Hash, Loader2, Play, Timer, X } from "lucide-react"; +import { Check, Coins, Copy, Hash, Info, Timer } from "lucide-react"; import Link from "next/link"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { toast } from "sonner"; +import { Alert, AlertDescription } from "@/components/ui/alert"; import { Button } from "@/components/ui/button"; import { Spinner } from "@/components/ui/spinner"; import { useRunStream } from "@/hooks/use-run-stream"; import { useScraperCapabilities } from "@/hooks/use-scraper-capabilities"; import { scrapersApiService } from "@/lib/apis/scrapers-api.service"; -import { AbortedError, AppError } from "@/lib/error"; +import { AppError } from "@/lib/error"; import { findVerb } from "@/lib/playground/catalog"; import { formatCost, formatDuration, formatPricing } from "@/lib/playground/format"; import { buildPayload, initialFormValues, parseSchemaFields } from "@/lib/playground/json-schema"; @@ -58,36 +60,44 @@ function RunStat({ ); } -function ErrorPanel({ error, workspaceId }: { error: unknown; workspaceId: number }) { - if (error instanceof AbortedError) { - return null; - } +function getRunErrorMessage(error: unknown): string { const status = error instanceof AppError ? error.status : undefined; if (status === 402) { - return ( -
-

Insufficient credits

-

You don't have enough credits to run this API.

- -
- ); + return "Insufficient credits. Add credits to run this API."; } - const message = - status === 422 - ? "Invalid input. Check the fields above and try again." - : error instanceof Error && error.message - ? error.message - : "Something went wrong running this API."; + if (status === 422) { + return "Invalid input. Check the fields above and try again."; + } + + return error instanceof Error && error.message + ? error.message + : "Something went wrong running this API."; +} + +function EndpointCopyButton({ endpoint }: { endpoint: string }) { + const [copied, setCopied] = useState(false); + + const handleCopy = () => { + navigator.clipboard.writeText(endpoint).then(() => { + setCopied(true); + setTimeout(() => setCopied(false), 1500); + }); + }; return ( -
- - {message} -
+ ); } @@ -110,6 +120,8 @@ export function PlaygroundRunner({ workspaceId, platform, verb }: PlaygroundRunn const [values, setValues] = useState>({}); const run = useRunStream(workspaceId); const isRunning = run.status === "running"; + const previousStatusRef = useRef(run.status); + const notifiedRunRef = useRef(null); // Seed form defaults once the schema is available. useEffect(() => { @@ -158,6 +170,29 @@ export function PlaygroundRunner({ workspaceId, platform, verb }: PlaygroundRunn () => (run.detail ? parseJsonlOutput(run.detail.output_text) : null), [run.detail] ); + const endpoint = `POST /workspaces/${workspaceId}/scrapers/${platform}/${verb}`; + + useEffect(() => { + const previousStatus = previousStatusRef.current; + previousStatusRef.current = run.status; + + if (previousStatus !== "running") return; + + if (run.status === "success") { + const key = `${run.runId ?? "run"}:success`; + if (notifiedRunRef.current === key) return; + notifiedRunRef.current = key; + toast.success("API run completed."); + return; + } + + if (run.status === "error") { + const key = `${run.runId ?? "run"}:error`; + if (notifiedRunRef.current === key) return; + notifiedRunRef.current = key; + toast.error(getRunErrorMessage(run.error)); + } + }, [run.status, run.runId, run.error]); if (isLoading) { return ( @@ -186,26 +221,40 @@ export function PlaygroundRunner({ workspaceId, platform, verb }: PlaygroundRunn return (
-
+
+ {capability.description && ( + + + +

+ {capability.description} + {capability.docs_url ? ( + <> + {" "} + + Read docs + {" "} + for more info. + + ) : null} +

+
+
+ )} +
-
-

- {catalogVerb.label} · {platform} -

- {capability.description && ( -

{capability.description}

- )} - - POST /workspaces/{workspaceId}/scrapers/{platform}/{verb} - -
- +
+ +
+ Pricing: {formatPricing(capability.pricing)}
-
- {isRunning && ( - )}
- - {run.status === "error" && }

Output

{isRunning ? ( - + ) : run.status === "cancelled" ? (
Run cancelled. diff --git a/surfsense_web/app/dashboard/[workspace_id]/playground/components/runs-table.tsx b/surfsense_web/app/dashboard/[workspace_id]/playground/components/runs-table.tsx index f47bd4572..6368a1f76 100644 --- a/surfsense_web/app/dashboard/[workspace_id]/playground/components/runs-table.tsx +++ b/surfsense_web/app/dashboard/[workspace_id]/playground/components/runs-table.tsx @@ -1,8 +1,9 @@ "use client"; import { useInfiniteQuery } from "@tanstack/react-query"; -import { ChevronDown, ChevronRight, History } from "lucide-react"; +import { ChevronDown, ChevronRight, History, Info } from "lucide-react"; import { Fragment, useState } from "react"; +import { Alert, AlertDescription } from "@/components/ui/alert"; import { Button } from "@/components/ui/button"; import { Select, @@ -67,13 +68,12 @@ export function RunsTable({ workspaceId }: { workspaceId: number }) { return (
-
-

Runs

-

- Every platform-native API run in this workspace — from the playground, API keys, and - agents. Newest first. -

-
+ + + + View all API runs for this workspace, including runs from the playground, API keys, and agents. + +
setSearchQuery(e.target.value)} - className="h-8 border-0 bg-muted pl-8 pr-7 text-sm shadow-none" - /> - {searchQuery && ( - - )} -
-
- - { - const tab = value as InboxTab; - setActiveTab(tab); - if (tab !== "status" && activeFilter === "errors") { - setActiveFilter("all"); - } - }} - className="shrink-0 mx-3 mt-1.5" - > - - - - - {t("comments") || "Comments"} - - {formatInboxCount(comments.unreadCount)} - - - - - - - {t("status") || "Status"} - - {formatInboxCount(status.unreadCount)} - - - - - - -
- {isLoading ? ( -
- {activeTab === "comments" - ? [85, 60, 90, 70, 50, 75].map((titleWidth) => ( -
- -
- - -
- -
- )) - : [75, 90, 55, 80, 65, 85].map((titleWidth) => ( -
- -
- - -
-
- - -
-
- ))} -
- ) : filteredItems.length > 0 ? ( -
- {filteredItems.map((item, index) => { - const isMarkingAsRead = markingAsReadId === item.id; - const isPrefetchTrigger = - !isSearchMode && activeSource.hasMore && index === filteredItems.length - 5; - - return ( -
- {activeTab === "status" ? ( - - - - - -

{item.title}

-

- {convertRenderedToDisplay(item.message)} -

-
-
- ) : ( - - )} - -
- - {formatTime(item.created_at)} - - {!item.read && } -
-
- ); - })} - {!isSearchMode && filteredItems.length < 5 && activeSource.hasMore && ( -
- )} - {activeSource.loadingMore && - (activeTab === "comments" - ? [80, 60, 90].map((titleWidth) => ( -
- -
- - -
- -
- )) - : [70, 85, 55].map((titleWidth) => ( -
- -
- - -
-
- - -
-
- )))} -
- ) : isSearchMode ? ( -
- -

- {t("no_results_found") || "No results found"} -

-

- {t("try_different_search") || "Try a different search term"} -

-
- ) : ( -
- {activeTab === "comments" ? ( - - ) : ( - - )} -

{getEmptyStateMessage().title}

-

- {getEmptyStateMessage().hint} -

-
- )} -
- - ); -} - -export function InboxSidebar({ - open, - onOpenChange, - comments, - status, - totalUnreadCount, - onCloseMobileSidebar, -}: InboxSidebarProps) { - const t = useTranslations("sidebar"); - - return ( - - - - ); -} diff --git a/surfsense_web/components/layout/ui/sidebar/MobileSidebar.tsx b/surfsense_web/components/layout/ui/sidebar/MobileSidebar.tsx index f9901d7cb..1d02b56f4 100644 --- a/surfsense_web/components/layout/ui/sidebar/MobileSidebar.tsx +++ b/surfsense_web/components/layout/ui/sidebar/MobileSidebar.tsx @@ -6,6 +6,7 @@ import { ScrollArea } from "@/components/ui/scroll-area"; import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet"; import type { ChatItem, NavItem, PageUsage, User, Workspace } from "../../types/layout.types"; import { WorkspaceAvatar } from "../icon-rail/WorkspaceAvatar"; +import { NotificationsDropdown, type NotificationsDropdownData } from "./NotificationsDropdown"; import { Sidebar } from "./Sidebar"; import { SidebarUserProfile } from "./SidebarUserProfile"; @@ -35,6 +36,7 @@ interface MobileSidebarProps { onUserSettings?: () => void; onAnnouncements?: () => void; announcementUnreadCount?: number; + notifications?: NotificationsDropdownData; onLogout?: () => void; pageUsage?: PageUsage; theme?: string; @@ -83,6 +85,7 @@ export function MobileSidebar({ onUserSettings, onAnnouncements, announcementUnreadCount = 0, + notifications, onLogout, pageUsage, theme, @@ -161,11 +164,19 @@ export function MobileSidebar({ isCollapsed theme={theme} setTheme={setTheme} + topContent={ + notifications ? ( + onOpenChange(false)} + /> + ) : undefined + } />
{/* Sidebar Content - right side */} -
+
void; + markAsRead: (id: number) => Promise; + markAllAsRead: () => Promise; +} + +export interface NotificationsDropdownData { + totalUnreadCount: number; + comments: NotificationsDataSource; + status: NotificationsDataSource; +} + +interface NotificationsDropdownProps { + notifications: NotificationsDropdownData; + onCloseMobileSidebar?: () => void; +} + +type NotificationFilter = "all" | "mentions" | "unread"; + +function formatNotificationCount(count: number): string { + if (count <= 999) { + return count.toString(); + } + const thousands = Math.floor(count / 1000); + return `${thousands}k+`; +} + +function formatTime(dateString: string): string { + try { + const date = new Date(dateString); + const now = new Date(); + const diffMs = now.getTime() - date.getTime(); + const diffMins = Math.floor(diffMs / (1000 * 60)); + const diffHours = Math.floor(diffMs / (1000 * 60 * 60)); + const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24)); + + if (diffMins < 1) return "now"; + if (diffMins < 60) return `${diffMins}m ago`; + if (diffHours < 24) return `${diffHours}h ago`; + if (diffDays < 7) return `${diffDays}d ago`; + return `${Math.floor(diffDays / 7)}w ago`; + } catch { + return "now"; + } +} + +function isCommentNotification(item: InboxItem): boolean { + return item.type === "new_mention" || item.type === "comment_reply"; +} + +export function NotificationsDropdown({ + notifications, + onCloseMobileSidebar, +}: NotificationsDropdownProps) { + const router = useRouter(); + const isMobile = useIsMobile(); + const [, setTargetCommentId] = useAtom(setTargetCommentIdAtom); + const [open, setOpen] = useState(false); + const [activeFilter, setActiveFilter] = useState("all"); + const [markingAsReadId, setMarkingAsReadId] = useState(null); + const [markingAllAsRead, setMarkingAllAsRead] = useState(false); + const scrollContainerRef = useRef(null); + const loadMoreTriggerRef = useRef(null); + + const unreadLabel = formatNotificationCount(notifications.totalUnreadCount); + const allCount = + (notifications.comments.totalCount ?? 0) + (notifications.status.totalCount ?? 0); + const mentionsCount = notifications.comments.totalCount ?? notifications.comments.items.length; + const visibleUnreadCount = + activeFilter === "mentions" + ? notifications.comments.unreadCount + : notifications.totalUnreadCount; + const isLoading = + activeFilter === "mentions" + ? notifications.comments.loading + : notifications.comments.loading || notifications.status.loading; + const isLoadingMore = + activeFilter === "mentions" + ? !!notifications.comments.loadingMore + : !!notifications.comments.loadingMore || !!notifications.status.loadingMore; + const hasMore = + activeFilter === "mentions" + ? !!notifications.comments.hasMore + : !!notifications.comments.hasMore || !!notifications.status.hasMore; + const items = useMemo(() => { + const sourceItems = + activeFilter === "mentions" + ? notifications.comments.items + : [...notifications.comments.items, ...notifications.status.items]; + + return sourceItems + .filter((item) => activeFilter !== "unread" || !item.read) + .toSorted((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime()); + }, [activeFilter, notifications.comments.items, notifications.status.items]); + + const loadMoreForActiveFilter = useCallback(() => { + if (isLoadingMore) return; + + if (activeFilter === "mentions") { + if (notifications.comments.hasMore) { + notifications.comments.loadMore?.(); + } + return; + } + + if (notifications.comments.hasMore) { + notifications.comments.loadMore?.(); + } + if (notifications.status.hasMore) { + notifications.status.loadMore?.(); + } + }, [activeFilter, isLoadingMore, notifications.comments, notifications.status]); + + useEffect(() => { + if (!open || isLoading || isLoadingMore || !hasMore) return; + const root = scrollContainerRef.current; + const target = loadMoreTriggerRef.current; + if (!root || !target) return; + + const observer = new IntersectionObserver( + (entries) => { + if (entries[0]?.isIntersecting) { + loadMoreForActiveFilter(); + } + }, + { + root, + rootMargin: "120px", + threshold: 0, + } + ); + + observer.observe(target); + return () => observer.disconnect(); + }, [hasMore, isLoading, isLoadingMore, loadMoreForActiveFilter, open]); + + const markItemAsRead = useCallback( + async (item: InboxItem) => { + if (item.read) return; + setMarkingAsReadId(item.id); + try { + await (isCommentNotification(item) + ? notifications.comments.markAsRead(item.id) + : notifications.status.markAsRead(item.id)); + } finally { + setMarkingAsReadId(null); + } + }, + [notifications.comments, notifications.status] + ); + + const handleItemClick = useCallback( + async (item: InboxItem) => { + await markItemAsRead(item); + + if (item.type === "new_mention" && isNewMentionMetadata(item.metadata)) { + const threadId = item.metadata.thread_id; + const commentId = item.metadata.comment_id; + if (item.workspace_id && threadId) { + if (commentId) setTargetCommentId(commentId); + setOpen(false); + onCloseMobileSidebar?.(); + router.push( + commentId + ? `/dashboard/${item.workspace_id}/new-chat/${threadId}?commentId=${commentId}` + : `/dashboard/${item.workspace_id}/new-chat/${threadId}` + ); + } + return; + } + + if (item.type === "comment_reply" && isCommentReplyMetadata(item.metadata)) { + const threadId = item.metadata.thread_id; + const replyId = item.metadata.reply_id; + if (item.workspace_id && threadId) { + if (replyId) setTargetCommentId(replyId); + setOpen(false); + onCloseMobileSidebar?.(); + router.push( + replyId + ? `/dashboard/${item.workspace_id}/new-chat/${threadId}?commentId=${replyId}` + : `/dashboard/${item.workspace_id}/new-chat/${threadId}` + ); + } + return; + } + + if (item.type === "insufficient_credits" && isInsufficientCreditsMetadata(item.metadata)) { + if (item.metadata.action_url) { + setOpen(false); + onCloseMobileSidebar?.(); + router.push(item.metadata.action_url); + } + } + }, + [markItemAsRead, onCloseMobileSidebar, router, setTargetCommentId] + ); + + const handleMarkAllAsRead = useCallback(async () => { + if (visibleUnreadCount === 0 || markingAllAsRead) return; + setMarkingAllAsRead(true); + try { + if (activeFilter === "mentions") { + await notifications.comments.markAllAsRead(); + } else { + await Promise.all([ + notifications.comments.markAllAsRead(), + notifications.status.markAllAsRead(), + ]); + } + } finally { + setMarkingAllAsRead(false); + } + }, [activeFilter, markingAllAsRead, notifications, visibleUnreadCount]); + + const emptyStateCopy = + activeFilter === "mentions" + ? { + title: "No mentions", + description: "Mentions and replies will appear here.", + } + : activeFilter === "unread" + ? { + title: "No unread notifications", + description: "New mentions and status updates will appear here.", + } + : { + title: "No notifications", + description: "Mentions, replies, and status updates will appear here.", + }; + + const tabs: { value: NotificationFilter; label: string; count: number }[] = [ + { value: "all", label: "All", count: allCount }, + { value: "mentions", label: "Mentions", count: mentionsCount }, + { value: "unread", label: "Unread", count: notifications.totalUnreadCount }, + ]; + + const triggerButton = ( + + ); + + const panelContent = ( + <> +
+
+

Notifications

+
+ +
+ +
+ {tabs.map((tab) => { + const isActive = activeFilter === tab.value; + return ( + + ); + })} +
+ +
+ {isLoading ? ( +
+ {[82, 64, 74].map((width) => ( +
+
+ + +
+
+ ))} +
+ ) : items.length > 0 ? ( +
+ {items.map((item) => { + const isMarkingAsRead = markingAsReadId === item.id; + return ( + + ); + })} + {hasMore ? ( +
+ {isLoadingMore ? : null} +
+ ) : null} +
+ ) : ( +
+

{emptyStateCopy.title}

+

{emptyStateCopy.description}

+ {hasMore ? ( + + ) : null} +
+ )} +
+ + ); + + if (isMobile) { + return ( + + {triggerButton} + + + Notifications +
{panelContent}
+
+
+ ); + } + + return ( + + + + {triggerButton} + + + Notifications + + + + {panelContent} + + + ); +} diff --git a/surfsense_web/components/layout/ui/sidebar/PlaygroundSidebar.tsx b/surfsense_web/components/layout/ui/sidebar/PlaygroundSidebar.tsx deleted file mode 100644 index 1c2f96212..000000000 --- a/surfsense_web/components/layout/ui/sidebar/PlaygroundSidebar.tsx +++ /dev/null @@ -1,98 +0,0 @@ -"use client"; - -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"; - -interface PlaygroundSidebarProps { - workspaceId: number | string; -} - -function PlaygroundNavLink({ - href, - label, - icon: Icon, - isActive, - indented = false, -}: { - href: string; - label: string; - icon?: PlatformIcon; - isActive: boolean; - indented?: boolean; -}) { - return ( - - {Icon ? : null} - {label} - - ); -} - -export function PlaygroundSidebar({ workspaceId }: PlaygroundSidebarProps) { - const pathname = usePathname(); - const base = `/dashboard/${workspaceId}/playground`; - - return ( -
-
- API Playground -
- -
- - -
- -
- {PLAYGROUND_PLATFORMS.map((platform) => ( -
-
- - {platform.label} -
- {platform.verbs.map((verb) => { - const href = `${base}/${platform.id}/${verb.verb}`; - return ( - - ); - })} -
- ))} -
- -
- -
-
- ); -} diff --git a/surfsense_web/components/layout/ui/sidebar/Sidebar.tsx b/surfsense_web/components/layout/ui/sidebar/Sidebar.tsx index 35746acf1..7862ae90e 100644 --- a/surfsense_web/components/layout/ui/sidebar/Sidebar.tsx +++ b/surfsense_web/components/layout/ui/sidebar/Sidebar.tsx @@ -5,7 +5,7 @@ import Link from "next/link"; import { useParams } from "next/navigation"; import { useTranslations } from "next-intl"; import { type ReactNode, useMemo, useState } from "react"; -import { Badge } from "@/components/ui/badge"; +import { ConnectAgentDialog } from "@/components/mcp/connect-agent-dialog"; import { Button } from "@/components/ui/button"; import { Progress } from "@/components/ui/progress"; import { Skeleton } from "@/components/ui/skeleton"; @@ -18,7 +18,7 @@ import { ChatListItem } from "./ChatListItem"; import { CreditBalanceDisplay } from "./CreditBalanceDisplay"; import { DocumentsSidebar } from "./DocumentsSidebar"; import { NavSection } from "./NavSection"; -import { SidebarButton } from "./SidebarButton"; +import { SidebarButton, SidebarButtonBadge } from "./SidebarButton"; import { SidebarCollapseButton } from "./SidebarCollapseButton"; import { SidebarHeader } from "./SidebarHeader"; import { SidebarSection } from "./SidebarSection"; @@ -46,27 +46,14 @@ function ChatListSkeletonRows() { ); } -function CollapsedInboxIcon({ item }: { item: NavItem }) { - const Icon = item.icon; - - return ( - - - {typeof item.badge === "string" ? ( - - {item.badge} - - ) : null} - - ); -} - interface SidebarProps { workspace: Workspace | null; isCollapsed?: boolean; onToggleCollapse?: () => void; navItems: NavItem[]; onNavItemClick?: (item: NavItem) => void; + onPlaygroundItemClick?: (item: NavItem) => void; + isPlaygroundSidebarOpen?: boolean; chats: ChatItem[]; activeChatId?: number | null; onNewChat: () => void; @@ -104,6 +91,8 @@ export function Sidebar({ onToggleCollapse, navItems, onNavItemClick, + onPlaygroundItemClick, + isPlaygroundSidebarOpen, chats, activeChatId, onNewChat, @@ -138,10 +127,9 @@ export function Sidebar({ const [openDropdownChatId, setOpenDropdownChatId] = useState(null); const [isSidebarNavScrolled, setIsSidebarNavScrolled] = useState(false); - // Inbox, Automations, and Artifacts are rendered explicitly right below + // Automations, Artifacts, and Playground are rendered explicitly right below // New Chat. Pull them out of the nav items list so they don't also appear // in the bottom NavSection. Documents is embedded below Recents. - const inboxItem = useMemo(() => navItems.find((item) => item.url === "#inbox"), [navItems]); const automationsItem = useMemo( () => navItems.find((item) => item.url.endsWith("/automations")), [navItems] @@ -158,7 +146,6 @@ export function Sidebar({ () => navItems.filter( (item) => - item.url !== "#inbox" && !item.url.endsWith("/automations") && !item.url.endsWith("/artifacts") && !item.url.endsWith("/playground") @@ -218,7 +205,7 @@ export function Sidebar({
@@ -235,23 +222,6 @@ export function Sidebar({ onScroll={(event) => setIsSidebarNavScrolled(event.currentTarget.scrollTop > 0)} >
- {inboxItem && ( - onNavItemClick?.(inboxItem)} - isCollapsed={isCollapsed} - isActive={inboxItem.isActive} - badge={inboxItem.badge} - collapsedIconNode={} - tooltipContent={isCollapsed ? inboxItem.title : undefined} - buttonProps={ - { - "data-joyride": "inbox-sidebar", - } as React.ButtonHTMLAttributes - } - /> - )} {automationsItem && ( onNavItemClick?.(playgroundItem)} + onClick={() => (onPlaygroundItemClick ?? onNavItemClick)?.(playgroundItem)} isCollapsed={isCollapsed} - isActive={playgroundItem.isActive} + isActive={isPlaygroundSidebarOpen ?? playgroundItem.isActive} + badge={New} tooltipContent={isCollapsed ? playgroundItem.title : undefined} /> )} @@ -356,10 +327,16 @@ export function Sidebar({ /> )} + {!isCollapsed && ( +
+ +
+ )} + 0} + hasNavSectionAbove={footerNavItems.length > 0 || !isCollapsed} onNavigate={onNavigate} /> @@ -436,29 +413,25 @@ function SidebarUsageFooter({ return (
-
+
- - - Earn credits - - + + Earn + FREE - + - - - Buy credits - + + Buy
diff --git a/surfsense_web/components/layout/ui/sidebar/SidebarButton.tsx b/surfsense_web/components/layout/ui/sidebar/SidebarButton.tsx index cc7f83e1d..1557253a9 100644 --- a/surfsense_web/components/layout/ui/sidebar/SidebarButton.tsx +++ b/surfsense_web/components/layout/ui/sidebar/SidebarButton.tsx @@ -2,6 +2,7 @@ import type { LucideIcon } from "lucide-react"; import type React from "react"; +import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { cn } from "@/lib/utils"; @@ -28,6 +29,26 @@ const baseClassName = cn( "focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring" ); +export function SidebarButtonBadge({ + children, + className, +}: { + children: React.ReactNode; + className?: string; +}) { + return ( + + {children} + + ); +} + export function SidebarButton({ icon: Icon, label, @@ -73,17 +94,19 @@ export function SidebarButton({ isCollapsed ? "max-w-0 opacity-0 ml-0" : "max-w-[260px] flex-1 opacity-100 ml-2" )} > - {label} + + {label} + {!isCollapsed && badge && typeof badge !== "string" ? badge : null} + {!isCollapsed && badge && typeof badge === "string" ? ( + + {badge} + + ) : null} + {!isCollapsed && trailingContent} - {!isCollapsed && badge && typeof badge !== "string" ? badge : null} - {!isCollapsed && badge && typeof badge === "string" ? ( - - {badge} - - ) : null} {collapsedOverlay && ( void; - ariaLabel: string; - width?: number; - children: React.ReactNode; -} - -/** - * Reusable slide-out panel that extends from the sidebar. - * - * Desktop: absolutely positioned at the sidebar's right edge, overlaying the main - * content with a blur backdrop. Does not push/shrink the main content. - * - * Mobile: full-width absolute overlay (unchanged). - */ -export function SidebarSlideOutPanel({ - open, - onOpenChange, - ariaLabel, - width = 360, - children, -}: SidebarSlideOutPanelProps) { - const isMobile = useIsMobile(); - const bumpSlideoutOpenedTick = useSetAtom(slideoutOpenedTickAtom); - - useEffect(() => { - if (open) { - bumpSlideoutOpenedTick((tick) => tick + 1); - } - }, [open, bumpSlideoutOpenedTick]); - - const handleEscape = useCallback( - (e: KeyboardEvent) => { - if (e.key === "Escape") onOpenChange(false); - }, - [onOpenChange] - ); - - useEffect(() => { - if (!open) return; - document.addEventListener("keydown", handleEscape); - return () => document.removeEventListener("keydown", handleEscape); - }, [open, handleEscape]); - - if (isMobile) { - return ( - - {open && ( -
- - {children} - -
- )} -
- ); - } - - return ( - - {open && ( - <> - {/* Blur backdrop covering the main content area (right of sidebar) */} - onOpenChange(false)} - aria-hidden="true" - /> - - {/* Panel extending from sidebar's right edge, flush with the wrapper border */} - -
- {children} -
-
- - )} -
- ); -} diff --git a/surfsense_web/components/layout/ui/sidebar/SidebarUserProfile.tsx b/surfsense_web/components/layout/ui/sidebar/SidebarUserProfile.tsx index ea93ce4d0..d5e72174e 100644 --- a/surfsense_web/components/layout/ui/sidebar/SidebarUserProfile.tsx +++ b/surfsense_web/components/layout/ui/sidebar/SidebarUserProfile.tsx @@ -2,6 +2,7 @@ import { Check, + ChevronRight, ChevronUp, Download, ExternalLink, @@ -16,8 +17,10 @@ import { } from "lucide-react"; import Image from "next/image"; import { useTranslations } from "next-intl"; -import { useState } from "react"; +import type React from "react"; +import { Fragment, useState } from "react"; import { Button } from "@/components/ui/button"; +import { Drawer, DrawerContent, DrawerHandle, DrawerTitle } from "@/components/ui/drawer"; import { DropdownMenu, DropdownMenuContent, @@ -63,6 +66,8 @@ const LEARN_MORE_LINKS = [ { key: "github" as const, href: "https://github.com/MODSetter/SurfSense" }, ]; +type MobileProfileSubmenu = "theme" | "language" | "learn_more"; + interface SidebarUserProfileProps { user: User; onUserSettings?: () => void; @@ -72,6 +77,7 @@ interface SidebarUserProfileProps { isCollapsed?: boolean; theme?: string; setTheme?: (theme: "light" | "dark" | "system") => void; + topContent?: React.ReactNode; } function formatAnnouncementCount(count: number): string { @@ -134,6 +140,7 @@ export function SidebarUserProfile({ isCollapsed = false, theme, setTheme, + topContent, }: SidebarUserProfileProps) { const t = useTranslations("sidebar"); const { locale, setLocale } = useLocaleContext(); @@ -141,12 +148,14 @@ export function SidebarUserProfile({ const isDesktopViewport = useMediaQuery("(min-width: 768px)"); const { os, primary, isMobileOS } = usePrimaryDownload(); const [isLoggingOut, setIsLoggingOut] = useState(false); + const [mobileSubmenu, setMobileSubmenu] = useState(null); const bgColor = getUserAvatarColor(user.email); const initials = getUserInitials(user.email); const displayName = user.name || user.email.split("@")[0]; const downloadUrl = primary?.url ?? GITHUB_RELEASES_URL; const downloadLabel = t("download_for_os", { os }); const showDownloadCta = !isDesktop && !isMobileOS && isDesktopViewport; + const useMobileSubmenus = !isDesktopViewport; const handleLanguageChange = (newLocale: "en" | "es" | "pt" | "hi" | "zh") => { setLocale(newLocale); @@ -166,11 +175,248 @@ export function SidebarUserProfile({ } }; + const submenuTriggerClassName = "cursor-default"; + const drawerItemClassName = cn( + "flex h-12 w-full items-center gap-3 rounded-lg px-3 text-left text-sm transition-colors", + "hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring" + ); + const drawerSeparatorClassName = "mx-3 h-px bg-popover-border"; + + const renderThemeSubmenu = () => { + if (!setTheme) return null; + + if (useMobileSubmenus) { + return ( + setMobileSubmenu("theme")} + > + + {t("theme")} + + + ); + } + + return ( + + + + {t("theme")} + + + + {THEMES.map((themeOption) => { + const Icon = themeOption.icon; + const isSelected = theme === themeOption.value; + return ( + handleThemeChange(themeOption.value)} + className={cn( + "mb-1 last:mb-0 transition-all", + "hover:bg-accent hover:text-accent-foreground", + isSelected && "text-primary" + )} + > + + {t(themeOption.value)} + {isSelected && } + + ); + })} + + + + ); + }; + + const renderLanguageSubmenu = () => { + if (useMobileSubmenus) { + return ( + setMobileSubmenu("language")} + > + + {t("language")} + + + ); + } + + return ( + + + + {t("language")} + + + + {LANGUAGES.map((language) => { + const isSelected = locale === language.code; + return ( + handleLanguageChange(language.code)} + className={cn( + "mb-1 last:mb-0 transition-all", + "hover:bg-accent hover:text-accent-foreground", + isSelected && "text-primary" + )} + > + {language.flag} + {language.name} + {isSelected && } + + ); + })} + + + + ); + }; + + const renderLearnMoreSubmenu = () => { + if (useMobileSubmenus) { + return ( + setMobileSubmenu("learn_more")} + > + + {t("learn_more")} + + + ); + } + + return ( + + + + {t("learn_more")} + + + + {LEARN_MORE_LINKS.map((link) => ( + + + {t(link.key)} + + + + ))} + +

+ v{APP_VERSION} +

+
+
+
+ ); + }; + + const mobileSubmenuDrawer = ( + { + if (!open) setMobileSubmenu(null); + }} + shouldScaleBackground={false} + > + + + + {mobileSubmenu === "theme" + ? t("theme") + : mobileSubmenu === "language" + ? t("language") + : t("learn_more")} + +
+ {mobileSubmenu === "theme" && + setTheme && + THEMES.map((themeOption, index) => { + const Icon = themeOption.icon; + const isSelected = theme === themeOption.value; + return ( + + {index > 0 &&
} + + + ); + })} + + {mobileSubmenu === "language" && + LANGUAGES.map((language, index) => { + const isSelected = locale === language.code; + return ( + + {index > 0 &&
} + + + ); + })} + + {mobileSubmenu === "learn_more" && ( + <> + {LEARN_MORE_LINKS.map((link, index) => ( + + {index > 0 &&
} + setMobileSubmenu(null)} + > + {t(link.key)} + + + + ))} +

+ v{APP_VERSION} +

+ + )} +
+ + + ); + // Collapsed view - just show avatar with dropdown if (isCollapsed) { return ( -
+
+ {topContent} {showDownloadCta && ( @@ -247,89 +493,11 @@ export function SidebarUserProfile({ )} - {setTheme && ( - - - - {t("theme")} - - - - {THEMES.map((themeOption) => { - const Icon = themeOption.icon; - const isSelected = theme === themeOption.value; - return ( - handleThemeChange(themeOption.value)} - className={cn( - "mb-1 last:mb-0 transition-all", - "hover:bg-accent hover:text-accent-foreground", - isSelected && "text-primary" - )} - > - - {t(themeOption.value)} - {isSelected && } - - ); - })} - - - - )} + {renderThemeSubmenu()} - - - - {t("language")} - - - - {LANGUAGES.map((language) => { - const isSelected = locale === language.code; - return ( - handleLanguageChange(language.code)} - className={cn( - "mb-1 last:mb-0 transition-all", - "hover:bg-accent hover:text-accent-foreground", - isSelected && "text-primary" - )} - > - {language.flag} - {language.name} - {isSelected && } - - ); - })} - - - + {renderLanguageSubmenu()} - - - - {t("learn_more")} - - - - {LEARN_MORE_LINKS.map((link) => ( - - - {t(link.key)} - - - - ))} - -

- v{APP_VERSION} -

-
-
-
+ {renderLearnMoreSubmenu()} {!isDesktop && !isMobileOS && ( @@ -352,6 +520,7 @@ export function SidebarUserProfile({ + {mobileSubmenuDrawer}
); @@ -429,89 +598,11 @@ export function SidebarUserProfile({ )} - {setTheme && ( - - - - {t("theme")} - - - - {THEMES.map((themeOption) => { - const Icon = themeOption.icon; - const isSelected = theme === themeOption.value; - return ( - handleThemeChange(themeOption.value)} - className={cn( - "mb-1 last:mb-0 transition-all", - "hover:bg-accent hover:text-accent-foreground", - isSelected && "text-primary" - )} - > - - {t(themeOption.value)} - {isSelected && } - - ); - })} - - - - )} + {renderThemeSubmenu()} - - - - {t("language")} - - - - {LANGUAGES.map((language) => { - const isSelected = locale === language.code; - return ( - handleLanguageChange(language.code)} - className={cn( - "mb-1 last:mb-0 transition-all", - "hover:bg-accent hover:text-accent-foreground", - isSelected && "text-primary" - )} - > - {language.flag} - {language.name} - {isSelected && } - - ); - })} - - - + {renderLanguageSubmenu()} - - - - {t("learn_more")} - - - - {LEARN_MORE_LINKS.map((link) => ( - - - {t(link.key)} - - - - ))} - -

- v{APP_VERSION} -

-
-
-
+ {renderLearnMoreSubmenu()} {!isDesktop && ( @@ -530,6 +621,7 @@ export function SidebarUserProfile({ + {mobileSubmenuDrawer}
); } diff --git a/surfsense_web/components/layout/ui/sidebar/index.ts b/surfsense_web/components/layout/ui/sidebar/index.ts index d1a1f85e5..e806b06d0 100644 --- a/surfsense_web/components/layout/ui/sidebar/index.ts +++ b/surfsense_web/components/layout/ui/sidebar/index.ts @@ -2,10 +2,9 @@ export { AllChatsWorkspaceContent } from "./AllChatsSidebar"; export { ChatListItem } from "./ChatListItem"; export { CreditBalanceDisplay } from "./CreditBalanceDisplay"; export { DocumentsSidebar } from "./DocumentsSidebar"; -export { InboxSidebar, InboxSidebarContent } from "./InboxSidebar"; export { MobileSidebar, MobileSidebarTrigger } from "./MobileSidebar"; export { NavSection } from "./NavSection"; -export { PlaygroundSidebar } from "./PlaygroundSidebar"; +export { NotificationsDropdown } from "./NotificationsDropdown"; export { Sidebar } from "./Sidebar"; export { SidebarCollapseButton } from "./SidebarCollapseButton"; export { SidebarHeader } from "./SidebarHeader"; diff --git a/surfsense_web/components/mcp/agent-setup-tabs.tsx b/surfsense_web/components/mcp/agent-setup-tabs.tsx index 1701b8924..3790441f2 100644 --- a/surfsense_web/components/mcp/agent-setup-tabs.tsx +++ b/surfsense_web/components/mcp/agent-setup-tabs.tsx @@ -9,6 +9,8 @@ import { DEFAULT_SERVER_DIR, MCP_CLIENTS, type McpSnippetOptions, + type McpTransport, + REMOTE_URL, } from "@/lib/mcp/clients"; function CopyButton({ text }: { text: string }) { @@ -38,48 +40,82 @@ function CopyButton({ text }: { text: string }) { ); } +const TRANSPORTS: { id: McpTransport; label: string; hint: string }[] = [ + { id: "remote", label: "Hosted", hint: "mcp.surfsense.com — nothing to install" }, + { id: "stdio", label: "Self-host", hint: "run the server against your own backend" }, +]; + /** - * 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. + * Per-agent MCP setup instructions as tabs: pick a client, then Hosted or + * Self-host, and 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 }) { + const [transport, setTransport] = useState("remote"); + const resolved: McpSnippetOptions = { - baseUrl: options?.baseUrl || "https://api.surfsense.com", + remoteUrl: options?.remoteUrl || REMOTE_URL, apiKey: options?.apiKey || API_KEY_PLACEHOLDER, + baseUrl: options?.baseUrl || "https://api.surfsense.com", serverDir: options?.serverDir || DEFAULT_SERVER_DIR, }; + const active = TRANSPORTS.find((t) => t.id === transport) ?? TRANSPORTS[0]; + return ( - - - {MCP_CLIENTS.map((client) => ( - - {client.label} - - ))} - - {MCP_CLIENTS.map((client) => { - const config = client.buildConfig(resolved); - return ( - -
    - {client.steps.map((step) => ( -
  1. {step}
  2. - ))} -
-
-

{client.configFile}

-
- -
-									{config}
-								
+
+
+
+ {TRANSPORTS.map((t) => ( + + ))} +
+ {active.hint} +
+ + + + {MCP_CLIENTS.map((client) => ( + + {client.label} + + ))} + + {MCP_CLIENTS.map((client) => { + const snippet = client[transport]; + const config = snippet.build(resolved); + return ( + +
    + {snippet.steps.map((step) => ( +
  1. {step}
  2. + ))} +
+
+

+ {snippet.configFile} +

+
+ +
+										{config}
+									
+
-
- - ); - })} - + + ); + })} + +
); } diff --git a/surfsense_web/components/mcp/connect-agent-dialog.tsx b/surfsense_web/components/mcp/connect-agent-dialog.tsx index b48b849c6..fdbf08d71 100644 --- a/surfsense_web/components/mcp/connect-agent-dialog.tsx +++ b/surfsense_web/components/mcp/connect-agent-dialog.tsx @@ -1,6 +1,6 @@ "use client"; -import { Cable } from "lucide-react"; +import { SidebarButtonBadge } from "@/components/layout/ui/sidebar/SidebarButton"; import { AgentSetupTabs } from "@/components/mcp/agent-setup-tabs"; import { Dialog, @@ -28,8 +28,18 @@ export function ConnectAgentDialog({ className }: { className?: string }) { className )} > - - Connect your AI Agent +