The registry is the list of third-party plugins, verified and community, the app offers once third-party plugins are allowed. Publishers add an entry by pull request to plugins/registry/plugins.json; CI checks it, a maintainer connects it on dev and reviews it, and CI signs the list on dev; the release pull request carries the signed list to main. A small container serves the signed list, its icons and SurfSense's OAuth files at plugins.surfsense.com, and the app fetches it, verifies the signature against keys it compiles in, and keeps the last good copy. What the app does with an entry, connecting and signing in, is in plugins.
Code: plugins/registry/, surfsense_local/backend/modules/plugins/lists/registry/, .github/workflows/ (plugins-registry-*.yml)
Decisions: ADR 0053
| Path | Holds |
|---|---|
plugins.json |
The source list as authors write it: schema_version, plugins and removed. It lists DeepWiki |
icons/ |
One icon per entry that names one |
published/ |
Written by the sign job only: plugins.json, stamped, and plugins.json.sig. Empty until the first signing |
check/ |
A uv project: python -m registry_check static, sign or verify |
oauth/ |
client.json and callback.html, SurfSense's OAuth client (below) |
Caddyfile, Dockerfile |
The registry container |
.github/CODEOWNERS names maintainers for /plugins/registry/, so no change merges without one. The publisher's guide is plugins/README.md.
static checks every entry offline and prints one line per problem, naming the entry and the field (entry_rules.py, list_rules.py):
- Identity. An
idof lowercase letters, digits and hyphens,kind: remote,verificationverifiedorcommunity. The license belongs to the first-party list alone, and being in that list is what makes a plugin SurfSense's. - Verified. Nothing more than
verification: verified: it is a maintainer's judgement, which no check can make (below). Adomainan older entry carries is ignored. - Display.
nameandauthorup to 80 characters,descriptionup to 200.authoris the company or project that runs the server, never a person's name; review checks it. - Server. An
httpsurlwith no credentials.hostslists exact lowercase hostnames, none loopback, none twice, the URL's host among them. - Sign-in.
auth.typeisnone,token(with atitleanddescriptionsaying what to paste) oroauth(with an optionalclient_idandscopes). Noauthfield may carry a secret. - Access.
free, orexternalwith anaccess_note. - Pages.
privacy_policyis required, and it andhomepagearehttps.min_app_version, when given, is a version like2.3.0. - The plugin's page.
about, when given, is up to 600 characters;tools, when given, is a list of MCP tool names, each once;added, when given, is a month like2026-10. Review, not the check, makes sure every entry has anabout, lists its tools and says the month it was added. - Ids. Unique in the registry and across the first-party list, which the check reads too; an id in
removedis never listed again, and each removed entry gives areason. Unknown fields are allowed, so a newer app can add them. - Icons (
icon_rules.py). A plain file name inicons/,.svgor.png, at most 32 KB, square; a PNG at least 64×64; an SVG read withdefusedxml, with no script,foreignObject, frame or embed, no event attribute and no reference outside itself. An icon no entry uses fails.
verified is set by a maintainer in review, when they are satisfied that author runs the server, on whatever evidence convinces them: the publisher's own docs linking the server, the pull request coming from the publisher's own account, a contact they know, or a proof on the server's domain. The app shows it as a checkmark (ADR 0055). Every other entry is community. Only a maintainer's approval merges a change to the registry (CODEOWNERS), so only a maintainer sets or keeps it.
live connects to each entry a pull request adds or changes, compared with the list it is based on, with the MCP client the app uses, the official SDK at the app's minor (live/). An unchanged entry is not contacted again. A pull request's log is public, so a failure is reported as a reason of the check's own, such as "it answered 404", never with what the server answered, and the SDK's logging, which quotes a bad answer whole, is silenced (failure_reason.py). Problems fail the pull request; what MCP leaves optional only warns, so any server the app could use passes:
auth |
Problems | Warnings |
|---|---|---|
| Any | the URL cannot be reached, or does not answer MCP over Streamable HTTP | none |
none |
the server asks for sign-in; tools names a tool the server does not list, or leaves one out |
no tools in the entry, naming the server's; a tool with neither readOnlyHint nor destructiveHint |
token |
none: without a key it is expected to refuse | the server lists its tools without a key |
oauth |
no authorization server found as the app finds one (RFC 9728, then RFC 8414 on the server's origin); a sign-in host, the authorization server's or one of its endpoints', missing from hosts; an authorization server that takes neither SurfSense's client metadata document nor dynamic registration, with no client_id in the entry |
the server lists its tools without signing in |
A server behind a key or an account is not listed by CI, which holds no credentials: the maintainer connects it on dev and checks its tools. The entry's privacy_policy and homepage are not opened: a site often refuses a script, and the reviewer opens the privacy policy anyway.
sign reads the source list and writes published/plugins.json with generated_at, the UTC time with a Z, and each icon's icon_sha256, then plugins.json.sig: the bare base64 Ed25519 signature over that file's exact bytes (sign.py). One formatting, two-space JSON with a final newline, makes a re-sign of the same list byte-identical (signed_bytes.py). The key is the raw 32-byte private key in base64, read from REGISTRY_SIGNING_KEY; the source list is never rewritten. verify checks a list against a hex public key as the app does.
| Workflow | Runs | Does |
|---|---|---|
plugins-registry.yml, on a pull request |
Pull requests into main or dev that touch plugins/registry/ or the first-party list |
Fails a hand edit to published/, except from the bot or the release pull request from dev into main; runs the check's lint, format and tests, static, then live against the list the pull request is based on, and the callback page's Node tests. A manual run checks any branch, without live. A maintainer then connects the listing on dev and reviews its tools, since a server behind a key or a sign-in is not listed by CI |
plugins-registry.yml, on dev |
Pushes to dev that change plugins.json or icons/, or a manual run on dev |
Its sign job, the only one with write access to the code: runs static, never live, so a third-party server that is down never holds up signing; signs with the REGISTRY_SIGNING_KEY secret, verifies with the public key the app compiles in, and commits published/ to dev itself. Nothing pushes to main: the release pull request carries it there, and the registry container is built from main |
The registry is served by a container of its own beside the plugin host, on the same hostname, so updating the list never restarts the plugins. It is pinned Caddy 2.11.4 serving files copied into the image, read-only, as an unprivileged user, with a health check (Caddyfile):
| Path | Serves | Cache |
|---|---|---|
/plugins.json, /plugins.json.sig |
The signed copies from published/; the source list is never served |
5 minutes |
/icons/<name>.svg or .png |
An icon, with a CSP that sandboxes it and runs nothing | 5 minutes |
/oauth/client.json |
SurfSense's client ID metadata document | 5 minutes |
/oauth/callback |
callback.html, never framed |
none |
/health |
ok |
none |
Any other path is 404, and any method but GET or HEAD is 405. docker build -t surfsense-registry plugins/registry builds it.
SurfSense signs in to OAuth plugins as one public client (plugins). client.json is its client ID metadata document: client_id is its own URL, its one redirect is https://plugins.surfsense.com/oauth/callback, its grants authorization_code and refresh_token, and it authenticates with none.
callback.html forwards the browser to the app. The state the app sends is <port>.<nonce>; the page reads exactly one state, checks its shape and that the port is 1024 to 65535, and replaces the location with http://127.0.0.1:<port>/plugins/oauth/callback and the whole query, the provider's error included. Anything else shows "This sign-in link isn't valid". Its CSP allows only its own inline script and style, by hash, and it sends no referrer.
current_registry.py holds the one answer to which list the app offers.
- When it fetches. Only through
GET /plugins/registryor connecting from it, only while third-party plugins are allowed andplugins.surfsense.comis allowed, and at most every five minutes; a failed or refused fetch also waits five minutes. Readers that must not reach the network, readiness, a plugin's row, icons and custom ids, read the copy held (delisted.py,listed_ids.py). - Consent.
host:plugins.surfsense.comis built in, off by default (egress). A refusal carriespurpose: "plugin_registry", so the prompt asks about fetching the list (registry_consent.py). - Verifying. The list and its
.sigare fetched fromplugins.surfsense.com(registry_fetch.py) and kept only if the signature verifies with one of the public keys compiled intoregistry_keys.py, a tuple so a release can trust an old and a new key while CI's is rotated. A list with anotherschema_version, or agenerated_atolder than the copy held, is ignored and the copy kept, so an old list cannot bring back a removed plugin. - Entries dropped (
registry_document.py,offered_entry.py): one that does not parse; one claiming SurfSense's license (auth: licenseoraccess: license), so no fetched file reaches the license key; one taking a first-party id; one whosemin_app_versionis above this build's, read from the package's metadata (app_version.py, copied into the frozen API bybundling/api.spec); one listed twice or also inremoved. Each is logged and the rest are kept. - The cache.
{data_dir}/plugin-registry/holds the list, its signature and when it was fetched, written last so a copy cut short reads as none (registry_cache.py). Offline the copy is listed with its date; with no verified copy, listing is502 registry_unavailable. - Icons. After a new list, each icon is fetched into the cache's
icons/and kept only if its SHA-256 matches the entry'sicon_sha256(registry_icons.py).GET /plugins/registry/icons/{id}serves only a matching copy, never fetching, with a CSP that sandboxes it; anything else is404and the screen shows the name's first letter. - Development. An unfrozen API reads the checkout's
plugins/registry/plugins.jsonand icons, unsigned and with no consent (registry_origin.py).SURFSENSE_LOCAL_PLUGIN_HOST_URLmoves entries onplugins.surfsense.comto the local plugin host, as it does the first-party list (served_entry.py).
- Check: from
plugins/registry/check,uv run pytest.tests/hold each entry rule and icon rule, unique ids across both lists, removed entries, the live check against real MCP servers from the SDK on loopback (live_servers.py): tools matching or not, missing hints, a server down or with no MCP, sign-in asked where none is declared, a key server, OAuth with its hosts listed or not, no authorization server, no way to register and aclient_idor client metadata document instead, unchanged entries left alone, and an answer carrying credentials never reaching the output; and signing: a list verifies, one changed byte or another key fails, the stamps, stable formatting, and the source never rewritten. The sign job alone also checks the committed list against the production key.oauth/tests/callback.test.mjsruns the callback page: a valid state forwarded, a provider's error forwarded, each malformed state going nowhere, and a CSP that loads nothing. - App:
test_registry.pyruns the routes against a stand-in plugin host signing with a test key (tests/registry_stand_in.py): nothing fetched while third-party plugins are off, the five-minute refresh, a bad signature, an older list and an unknown schema each ignored, the cached copy offline, no copy, entries only the first-party list may hold dropped, the host's consent, icons, the checkout read unsigned, connecting, removed plugins, and custom ids.test_registry_compiled.pychecks the production key is compiled in and the host is built in.
- No workflow builds the registry image:
plugins.surfsense.comis deployed outside CI, so a merged list reaches it only when someone deploys. - A delisting reaches the app only when Settings → Plugins lists the registry; nothing fetches it before an agent turn.
min_app_versionhas not been tried in a frozen build; only a test thatbundling/api.speccopies the package's metadata guards it.