host/usage_meter/ counts only tools/call requests to /<id>/mcp: per UTC day, Keygen license id (or unlicensed), plugin, tool and outcome (ok, tool_error, refused, cancelled, error), with calls and total milliseconds, and each license's first day with a call. It sits outside the license check so refusals count, reads only the tool's name, keeps a name the plugin lacks as unknown, and never keeps the arguments, the key or the thread's token.
Counts are written to SQLite (USAGE_DB_PATH) every minute and at shutdown, off the event loop; a failing meter is logged once and never fails a call. Once a day, rows older than 13 months are forgotten and, with POSTHOG_API_KEY, yesterday's totals go to PostHog as personless plugin_usage_daily and plugin_licences_daily events with no license id; python -m host.usage_meter.rollup --day sends one by hand. With USAGE_ADMIN_TOKEN, /admin/usage/first-seen and /admin/usage/daily serve the records to a bearer token; admin is reserved as a plugin id.
- 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.
Removing, importing or replacing a license, opening Plugins with a stale
verdict, and a turn after one all ask the plugin host's new GET /license,
once for every paid plugin, and apply its answer: a refused license holds
the plugin out of @ and the agent, and a valid one brings it back. The host
stays the only judge; the license file only says when to ask, and no answer
keeps every verdict. A paid plugin's refusal in a turn is named as its
license, not a sign-in. Contract 2 gains the route.
The core/ and remote/ proposals described work that has shipped. What they
said that the architecture docs lacked now lives there: the MCP client's
rules, what the gateway does today, what a plugin can reach, adding a plugin
to the host, who changes proprietary/, and the backend's copy of the
scrapers. proposals/plugins/README.md now holds only what is not built: the
chat router step, jobs and scaling on the host, organisation policy, later
MCP features and the open questions. Bundles stay deferred, with their
registry entry moved in. ADRs 0051, 0053 and 0054 are accepted, and links
into the deleted files point at the architecture docs.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The plugin host serves MCP without sessions, so any copy answers any request
and Azure can add copies on load. opencode cancels with a message no such
copy can match, so each turn gives plugins on the plugin host a token for
the thread, and the app's Stop calls the host's POST /cancel with it and the
license; Redis passes it to the copy running the call, whose response ends
with Request cancelled. PLUGINS limits a container to some plugins, as one
split out of the shared container runs.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The first-party list holds ten scraper plugins, from Google Search to Indeed,
each its own MCP server at https://plugins.surfsense.com/<id>/mcp. The host
serves a folder under its id with underscores as hyphens, since a package
cannot hold a hyphen, reserves scraping/ for the code they will share, and
lints plugins/remote/ with ruff's ASYNC rules, since its plugins share each
process's event loop.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
plugins/remote/host mounts each plugin folder's MCP server at /<id>/mcp and
answers /health; without proprietary/ it serves only the Apache-2.0 plugins.
A plugin's access hook runs before its server sees a request, and
proprietary/license_check applies contract 2 with Keygen's validate-key: a
five-minute cache, a day's grace while Keygen is down, and 401, 403 or 503
otherwise. The check is license_check/, since license/ beside LICENSE is one
path on macOS and Windows. CI checks plugins/remote/ on its own.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>