SurfSense/plugins/remote/host/post_only.py
DESKTOP-RTLN3BA\$punk 2fb720cd03 feat(plugin-host): plugin lifespans, one license cache, and results the agent reads
- A plugin's server.py may expose a lifespan: the host enters it at startup,
  warm-ups run in the background, and scraping/lifecycle.py closes the shared
  sessions, browsers and API clients once at shutdown.
- One license cache per process, stale-while-revalidate, instead of one per
  plugin: the app's 5-minute check no longer pays a Keygen round trip per
  plugin. Refusals and the 24 h grace work as before.
- Kept API clients for Keygen, SearXNG and the captcha solver, which now polls
  every second.
- mcp_server/results.py: tool_result puts each item in the text as one
  compact JSON line, within 32 KB. opencode reads only a result's text when
  there is any, so the agent never saw prices, page text or reviews. A request
  with x-surfsense-text-only: 1 gets no structured copy.
- mcp_server/progress.py: stage progress, so a long call never sits silent.
- GET and DELETE on /<id>/mcp answer 405: the stateless host has no stream to
  offer, and each GET held an idle SSE connection open per plugin.
2026-10-14 05:56:11 -07:00

57 lines
2 KiB
Python

"""A plugin's MCP endpoint takes POST only: no copy keeps a stream open for a client."""
import json
from starlette.types import ASGIApp, Receive, Scope, Send
# The SDK's own code for a request it does not take.
INVALID_REQUEST = -32600
_REFUSED = {"GET", "DELETE"}
class PostOnly:
"""GET and DELETE on `/mcp` are 405 with `Allow: POST`; anything else goes through.
Without sessions, the SDK answers the client's fire-and-forget GET with an
event stream that never carries a message, holding a connection, a task and
a ping every 15 s per plugin per agent. The client reads 405 as "no stream".
There is no session to DELETE.
"""
def __init__(self, app: ASGIApp) -> None:
self.app = app
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
if (
scope["type"] == "http"
and scope["method"] in _REFUSED
and _route_path(scope).rstrip("/") == "/mcp"
):
body = json.dumps(
{
"jsonrpc": "2.0",
"id": None,
"error": {"code": INVALID_REQUEST, "message": "Method Not Allowed"},
}
).encode()
await send(
{
"type": "http.response.start",
"status": 405,
"headers": [
(b"allow", b"POST"),
(b"content-type", b"application/json"),
(b"content-length", str(len(body)).encode()),
],
}
)
await send({"type": "http.response.body", "body": body})
return
await self.app(scope, receive, send)
def _route_path(scope: Scope) -> str:
"""The path under the plugin's mount, as Starlette's own routing reads it."""
path: str = scope["path"]
root_path: str = scope.get("root_path", "")
return path[len(root_path) :] if root_path and path.startswith(root_path) else path