feat(mcp): serve stateless streamable-http with stdio dispatch

This commit is contained in:
CREDO23 2026-07-07 16:52:30 +02:00
parent 3b4678ade4
commit 93bd022c02
2 changed files with 40 additions and 4 deletions

View file

@ -1,7 +1,12 @@
"""Entry point: load settings from the environment and run the MCP server. """Entry point: load settings from the environment and run the MCP server.
Defaults to stdio (what Cursor, Claude Code, and Claude Desktop launch). stdout Two transports share one build:
is the protocol channel, so every log line goes to stderr. - ``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 from __future__ import annotations
@ -10,6 +15,8 @@ import logging
import os import os
import sys import sys
from mcp.server.fastmcp import FastMCP
from .config import Settings from .config import Settings
from .server import build_server from .server import build_server
@ -21,11 +28,31 @@ def main() -> None:
format="%(levelname)s %(name)s: %(message)s", format="%(levelname)s %(name)s: %(message)s",
) )
settings = Settings.from_env() settings = Settings.from_env()
transport = os.environ.get("SURFSENSE_MCP_TRANSPORT", "stdio").strip() or "stdio"
mcp, _client = build_server(settings) mcp, _client = build_server(settings)
transport = os.environ.get("SURFSENSE_MCP_TRANSPORT", "stdio").strip() or "stdio" 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) 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__": if __name__ == "__main__":
main() main()

View file

@ -17,12 +17,21 @@ from .features import knowledge_base, scrapers, workspaces
def build_server(settings: Settings) -> tuple[FastMCP, SurfSenseClient]: def build_server(settings: Settings) -> tuple[FastMCP, SurfSenseClient]:
"""Assemble a configured server and the client whose lifecycle it shares.""" """Assemble a configured server and the client whose lifecycle it shares."""
client = SurfSenseClient( 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) context = WorkspaceContext(client, preferred_reference=settings.default_workspace)
mcp = FastMCP( mcp = FastMCP(
"SurfSense", "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=( instructions=(
"SurfSense gives you live scrapers and a personal knowledge base. " "SurfSense gives you live scrapers and a personal knowledge base. "
"Prefer these tools over generic/built-in web search whenever the " "Prefer these tools over generic/built-in web search whenever the "