SurfSense/docs/architecture/plugins.md
Anish Sarkar 0eb0656a7b feat(web): a page for every plugin, replacing the scraper pages
Each listed plugin gets a page at /plugins/<id>, built from its list entry
as the app's plugin page is: about, tools and details. SurfSense's own come
from a copy of the app's first-party list, the registry's from the signed
list, so a newly listed plugin gets its page without a change here. Cards
open the page and show the name with the app's verified mark.

The per-scraper marketing pages and the [slug] route are removed. Their
addresses redirect permanently to the plugin's page, and /reddit and
/instagram, which the app does not list, to /plugins. Homepage components
no page imported are removed with them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 16:55:21 +05:30

59 KiB
Raw Permalink Blame History

Plugins

A plugin is a remote MCP server whose tools the agent can use. The user connects one of SurfSense's own plugins from the list packaged into the app, a verified or community plugin from the signed registry, or any server by its URL, in Settings → Plugins, signs in with a key, the license or an account, chooses which of its tools the agent may use and which ask first, and the agent's opencode calls the server itself, under rules SurfSense writes each turn (agent). Plugins in Basic threads, and what else is not built, are the proposal.

Code: surfsense_local/backend/modules/plugins/, surfsense_local/frontend/src/features/plugins/ Decisions: ADR 0051, ADR 0053

SurfSense's own plugins

lists/first_party/plugins.json lists them, packaged into the API with their icons in icons/ beside it (bundling/api.spec), so GET /plugins/first-party answers with no network or consent. Each entry names its icon in icon, by file name, and the tools its server offers in tools, which its page shows before it is connected and the plugin host's tests check against each server (plugin host); Web Crawl's is the web app's web.svg, and Google Maps' its flat vector pin, since the web app's file embeds a 4 MB raster. It holds SurfSense's scraper plugins, one per platform: Google Search, Web Crawl, YouTube, TikTok, Google Maps, Amazon, Walmart and Indeed. Reddit and Instagram are written but left out until they can read anonymously again (plugin host). Each is its own server at https://plugins.surfsense.com/<id>/mcp, served by the plugin host, so the user connects, pauses and mentions each apart, and one consent for plugins.surfsense.com covers them all. The website's plugin pages read a byte-for-byte copy, first-party-plugins.json, so a change here is copied there in the same change.

  • The check. Every entry follows the schema both lists share (list_entry.py): its id, an https URL with no credentials, hosts naming the URL's host and no loopback, and an access_note when access is external. A first-party entry carries no verification, its icon, when it names one, is a plain file name present in icons/, it has an about of up to 600 characters, it lists its tools, each an MCP tool name once, and the month it was added as YYYY-MM, and one with auth: license sits on https://plugins.surfsense.com (first_party_list.py). A bad entry fails naming its field, and a unit test checks the packaged list, so it fails CI.
  • Connecting. POST /plugins/first-party/{id}: an id not listed is 404; one connected already, or whose URL is, is 409 plugin_already_connected; a host not allowed is 403 egress_disabled, as below. The server's tools are listed with the license, and the plugin is stored with source: first_party and credential_kind: license. A 401 or 403 still connects it, its check saying why, since a missing license is the server's to report; any other failure is 502 plugin_unreachable.
  • The license. Every request to a license plugin's server carries Authorization: License <key>, the key read from the imported license each time and never stored on the plugin (plugin_headers.py, license_key.py). Its expiry is not checked here: the server decides. The key goes only to a URL on the plugin host this build reaches; a plugin connected elsewhere is sent no key.
  • In an agent turn. opencode is given each plugin on the plugin host with x-surfsense-text-only: 1, which asks the host to put a call's items in the text alone, since opencode reads only the text when there is any, and no other server is sent it (turn_plugins.py). A call to one may go 300 seconds without progress, against 200 for every other server; when opencode gives up on one, its MCP error -32001, the app ends the thread's calls on the host through POST /cancel, as Stop does, since opencode's own cancel cannot reach a host that keeps no session; the host ends every call of the thread at once, so the cancel waits until no other call of the turn runs there (stop_plugin_calls.py, agent).
  • A developer's plugin host. SURFSENSE_LOCAL_PLUGIN_HOST_URL, such as http://127.0.0.1:8000, moves every listed URL and host on plugins.surfsense.com to a plugin host run locally (plugin_host.py), and the license key with them.
  • A custom plugin never takes a first-party id: one named Amazon is amazon-plugin.

Third-party plugins

GET /plugins/registry lists the registry's entries, fetched at most every five minutes and only while third-party plugins are allowed; while they are off it answers third_party_off: true and fetches nothing (routes.py, registry_read.py). Each entry carries its name, description, author, verification and verified for a plugin proven to be run by its author, server_host, the host every call goes to, and sign_in_hosts, an OAuth plugin's other hosts, access and access_note, auth_type with a token entry's auth_title and auth_description, hosts, homepage, privacy_policy, about, tools (null when the entry lists none), added, has_icon and connected; the answer carries fetched_at and removed.

  • Connecting. POST /plugins/registry/{id}, with {token} for a token entry, {headers} with a value for each header a headers entry names, or {oauth_client} for an oauth entry with user_client, which without it is kept signed out needing the user's own app, 201, sending nothing to the provider (below): third-party plugins off is 403 third_party_off; an id not listed is 404; one connected already, or whose URL is, is 409 plugin_already_connected; any of the entry's hosts not allowed is 403 egress_disabled naming them all. An oauth entry then starts a sign-in, 202 (below). Any other lists the server's tools over the entry's transport, with the key as Authorization: Bearer, or each named header's value after its entry's prefix, a header the entry does not name being 422 plugin_header_not_allowed (entry_headers.py), refused as a server added by URL is, and is stored with source: registry, its publisher and entry, and a key as credential_kind: token or headers as credential_kind: headers. Editing a registry plugin's headers fills the same named headers and keeps its entry (registry_plugin.py).
  • Removed. A maintainer moves an entry to removed, often after a report through the Report a plugin issue form. A plugin connected from the registry whose id is in removed keeps its row and key, but list_tools() leaves it out, and its row carries removed_reason. A custom plugin with the same id is untouched.
  • A custom plugin never takes a registry id, a removed one included (custom_plugin.py).

Connecting a server

POST /plugins/custom with {url, name, headers?, token?}, in this order:

  1. headers, a list of {name, value}, are checked before anything else (header_rules.py): at most 10, each name an HTTP token of at most 64 characters, given once whatever its case, and none the transport sets (Host, Content-Length, Content-Type, Accept, Connection, Transfer-Encoding, Cookie, Mcp-Session-Id, MCP-Protocol-Version, Last-Event-ID); each value non-empty, at most 4 KB, with no line break. A refusal is 422 plugin_header_not_allowed, naming the header, never its value. A bare token is one header, Authorization: Bearer <token>.
  2. The URL is put in one spelling: scheme and host lowercased, no default port, trailing slash or fragment (server_url.py), so one server is never connected twice.
  3. A name already in use is 409 plugin_name_taken, and a server already connected is 409 plugin_already_connected, each with name. A name becomes the id, lowercase words joined by hyphens; surfsense is never one, since it is the agent's own server.
  4. A server off this computer not on HTTPS is 422 plugin_needs_https: the agent reaches remote servers only through a TLS tunnel (agent).
  5. A host not allowed is 403 egress_disabled with hosts and purpose: "plugin", so the prompt words its question for a plugin's server, and the host is recorded off for Settings › Network (egress).
  6. SurfSense lists the server's tools with the official MCP SDK's client, sending the headers, over Streamable HTTP, then over the older HTTP+SSE when the POST is refused with 400, 404 or 405, as the spec's backward compatibility says (either_transport.py); the transport that answered is recorded on the entry, transport: streamable_http or sse. A 401 to a request with no headers, from a server that asks for OAuth, saves the plugin signed out instead, 201, once the authorization server's hosts are allowed; nothing is registered and no sign-in starts until Connect on its row, POST /plugins/{id}/sign-in (below). Every failure is then one outcome, the same for every connect path, with none of the server's own text (connect_refusal.py): any other 401 or 403 is 422 plugin_needs_sign_in; a 410 is 422 plugin_gone, an address its server retired, as DeepWiki did its /sse for /mcp; another 4xx, or an answer that is not MCP, such as a web page, is 422 plugin_not_mcp; a network failure, a timeout or a 5xx is 502 plugin_unreachable. When neither transport answers, an SSE address that answered anything but 400, 404 or 405 exists, so its answer is the one judged: a key it asks for, an address retired, a server down. Otherwise the Streamable HTTP refusal is. A refusal's HTTP status wins over a JSON-RPC error in its body, so a 401 carrying one still asks for a key.
  7. The plugin is stored with its headers, names and values together, encrypted as one credential, credential_kind: headers (shared/secrets.py, headers_credential.py); the names alone are also kept in its entry's auth, so they survive disconnecting. Every tool it listed starts on.

The MCP client

SurfSense lists a server's tools itself, to connect it and to notice tools that change; it never calls one, which the agent's runtime does, through the gateway (agent) (mcp_client/session.py). It speaks Streamable HTTP, and the older HTTP+SSE for a plugin whose entry records transport: sse, through the official MCP SDK, pinned to mcp 2.3.x (transports.py); the SDK refuses an SSE endpoint event on another origin. A server started by a command cannot be connected.

  • Each listing opens its own connection and closes it, since the SDK's tasks must close where they opened; nothing stays open between listings.
  • tools/list is followed page by page through its cursor.
  • A network error, 408, 429 or 5xx is retried twice, 0.2 and 0.4 seconds apart (retries.py). A server that forgot the session is listed once more on a new one.
  • A refusal keeps its HTTP status, an SSE stream's refused GET included, and the reason in its body, which is how a license refusal reaches the checks, and a 403's insufficient_scope challenge keeps the scopes it names (scope_challenge.py).

Signing in with OAuth

A registry entry with auth: oauth, or a server added by URL that asks for it, signs in with the user's account in their browser (plugin_sign_ins.py). A server added by URL is stored when it is added, and a registry plugin once the sign-in's page is ready, signed out (check_failure: sign_in) with no tools and no token (awaiting_sign_in.py), so a sign-in declined, abandoned or cancelled leaves it in Yours, needing a sign-in; the sign-in finishing fills in that row. Neither is stored before the authorization server's hosts are allowed, since a host still to be allowed retries the same request.

  1. Discovery. The server's 401 names its protected resource metadata (RFC 9728), whose first authorization server is the one used; a server with none but /.well-known/oauth-authorization-server on its origin is its own (oauth_discovery.py). A refusal that names neither is not OAuth.
  2. Consent. The authorization server's host is asked about before any request reaches it: 403 egress_disabled with purpose: "plugin". Every request of the sign-in goes through a client that refuses a host not allowed before it leaves (consented_client.py), and the host joins the plugin's hosts, so readiness and the plugin proxy require it too.
  3. The page. The MCP SDK's OAuth client runs registration, PKCE and the code exchange (sign_in_flow.py). The connect route answers 202 with {id, plugin_id, authorization_url} once the URL is ready, within 60 seconds, or 502 plugin_sign_in_failed (start_sign_in.py). The screen opens it in the browser and polls GET /plugins/sign-ins/{id}, waiting, connected or failed with why, as a code the screen has its own words for (sign_in_failure.py) and in a sentence; DELETE abandons it. A sign-in lives ten minutes in the API's memory, so a restart ends it, and a new one for the same plugin cancels the earlier.
  4. The way back. The state sent is <port>.<nonce>, the API's port and the SDK's own state (sign_in_state.py). GET /plugins/oauth/callback answers only a state this API issued; a provider's error ends the sign-in, access_denied as declined. The tools are then listed with the new token, on Streamable HTTP or the older SSE as adding a server lists them, the transport that answered recorded, the plugin stored, and the browser's page says it is signed in or why not (callback_page.py).

Who SurfSense signs in as (sign_in_client.py). A packaged app is one public client, https://plugins.surfsense.com/oauth/client.json, a client ID metadata document whose redirect is the plugin host's callback page, which forwards the browser to the API's port (plugin registry); a server that does not take such documents gets dynamic registration with the same redirect. A server whose registration refuses a public client, as those listing only client_secret_* token methods do, is registered once more as a confidential client, client_secret_post, its secret issued to this install by that registration and kept in the credential (start_sign_in.py). An unpackaged API, or one with SURFSENSE_LOCAL_PLUGIN_HOST_URL, registers dynamically with its own http://127.0.0.1:<port>/plugins/oauth/callback (RFC 8252). An entry's auth.client_id is used as a client registered in advance, and its auth.scopes are asked for. No client secret ships.

The user's own app (own_app.py). Before registering, the authorization server's metadata is read (registration_offer.py): a provider publishing one with neither a registration_endpoint nor, for a packaged build, client ID metadata documents registers no app, GitHub's among them, and needs the user's own app at once, with no registration sent only to fail. A provider publishing none is tried, as the spec has a client guess /register. A registration that fails is read the same way: with a registration_endpoint, the provider refused SurfSense, since it may take only apps it approved; otherwise it registers no one. Either way the plugin is kept, a new one stored signed out and a saved one marked, its entry's auth.needs_own_app saying which, needs_client or refused (own_app_needed.py): adding answers 201 with needs_client, read from the metadata with nothing registered, and signing in answers 422 plugin_oauth_needs_client or, as only registering finds it, plugin_oauth_refused. A sign-in that finishes clears it. Each refusal SurfSense handles is logged as one line of its own, not as the SDK's error with a traceback (handled_refusals.py). Each carries the redirect_uri this build signs in with, also at GET /plugins/oauth/redirect-uri. The user registers an app with the provider and gives oauth_client: {client_id, client_secret?, scopes?} to a sign-in or a registry connect. It is used as a client registered in advance, sending its secret with client_secret_post; the id and scopes go on the entry's auth, marked own_app, so it is told from a registry entry's own client_id, the secret only into the credential, so a refresh and a sign-in again reuse it. A registry entry with auth.user_client: true is kept needing it, needs_client, before anything is sent.

The credential. The tokens, their expiry, the client signed in as, the token endpoint and the resource indicator are one encrypted JSON blob in the row's credential, credential_kind: oauth (oauth_credential.py). Only the access token leaves the app, as Authorization: Bearer (plugin_headers.py); none is logged or returned.

Refreshing (token_refresh.py), one at a time per plugin, since providers rotate refresh tokens. Before an agent turn, a token that expires within 30 minutes (agent); and a listing the server refuses with 401, at a check or Refresh tools, is refreshed once and listed again (listed_after_refresh.py). A refused refresh, or none possible, records check_failure: sign_in, and an OAuth plugin so marked is not ready. A token endpoint that cannot be reached, or whose host is off, leaves the sign-in as it was.

Signing in again. POST /plugins/{id}/sign-in signs the same row in again, so its tool settings stay, reusing the client an earlier sign-in registered when its redirect still matches, and the user's own app with its secret; {oauth_client} switches to another. A plugin that does not sign in with an account is 422 plugin_not_oauth.

More access. A listing the server refuses with 403 and error="insufficient_scope", or an agent's tool call the gateway sees refused so (agent), records check_failure: sign_in and the scopes it named as the entry's auth.needs_scopes, which the screen reads as Needs more access; signing in again asks for them with the scopes asked before, and a sign-in that succeeds clears them.

Tables

Table Holds
installed_plugins id, kind (remote), source (first_party, registry or custom), the unique url, the entry it was connected from, enabled, connected_at, its sign-in as credential_kind (token, headers, license or oauth) and credential_ciphertext (a registry plugin's key, a custom plugin's headers, or an OAuth sign-in; a license's key is never stored), and its last check: checked_at, and check_failure (unreachable, sign_in, or for a refused license needs_license, license_expired, license_revoked or license_invalid; null when the server answered), and disconnected_at, set while the user has disconnected it
plugin_tools one row per tool: its listing (title, description, input_schema, annotations), position, the user's enabled and approval (ask or always), exposure (always direct; deferred is kept for a tool search not built), first_seen_at, and change (added or changed) while a tool switched off on its own waits to be allowed; removed with its plugin
plugin_settings one row: third_party_on

Ready tools

list_tools() in gateway/listing.py is the one answer to which tools may be used now: a tool switched on, of a plugin switched on and not disconnected, whose hosts are all allowed, not removed from the registry, not an OAuth plugin signed out, not a plugin whose license the plugin host refused, and, unless SurfSense publishes it, only while third-party plugins are allowed. needs_approval() in gateway/approval.py says which ask: a first-party read-only tool never does, a tool that does not say it is read-only and does not say it is harmless (destructiveHint absent or true) always does, and any other asks unless set to Always allow.

These two are the gateway today: it decides readiness and approval, and calls no tool. list_tools() returns ReadyTool records that name no engine (ready_tool.py); the agent reads them each turn. Their qualified_name, <plugin>__<tool>, waits for the chat engine; opencode names a tool <plugin>_<tool> itself (agent).

Tools added later. Each listing after the first is recorded by recorded_tools.py: a new tool starts off, a tool whose description changed or whose annotations became less safe is switched off, and a tool the server dropped is deleted. The agent lists again before a turn at most every five minutes, and in the background after a turn (agent).

Checks

Every listing of a server is a check, recorded on its row (plugin_check.py): connecting, the agent's listing before a turn when the last check is five minutes old, which the turn waits on for at most 3 seconds, a listing still running then being recorded when it ends, the agent's listing after a turn, in the background, when the last check is over 200 seconds old, so the next turn rarely waits (changed_tools.py, agent), and Refresh tools. The agent's checks list a plugin one at a time: a turn waits on a check of the agent's already running rather than starting another. Only ready plugins are listed, so a host the user has not allowed is never contacted. One that answers records its tools; one that fails keeps the tools last recorded and says why: a 401 or 403 as sign_in, unless its body's reason is one of contract 2's, missing_license, expired, revoked or invalid, which become the license failures; anything else is unreachable. An OAuth plugin's 401 is refreshed and listed once more first (above). A server whose recorded transport answers 400, 404 or 405 is tried on the other, since servers move from SSE to Streamable HTTP; one that answers there is recorded with its new transport (transport_moved.py).

A tool switched off by a check, because it is new or its description or annotations changed, is marked added or changed. Switching it on clears the mark: the user has allowed it as it now reads.

The license verdict

Whether the license unlocks SurfSense's paid plugins is the plugin host's to say, never the app's: the imported license file is only a hint of when to ask (license_verdict/). POST /plugins/license/check asks GET /license on the plugin host once, for every paid plugin connected, and applies the one answer to all of them: missing_license, expired, revoked or invalid become their license failure, and a valid license clears one. A host without /license is no answer, like one out of reach: it serves 2.2.1 and earlier apps, which learn the license from their listings, so it is deployed before an app that asks.

  • When it asks. At once after a license is imported, replaced or removed (Settings › License calls it). With if_stale, on opening Settings › Plugins and before each agent turn: when no verdict came in the last five minutes, or the license file's expiry passed since the last one. The time is kept in memory, so a restart asks again.
  • What it never does. No paid plugin, or plugins.surfsense.com switched off in Settings › Network, asks nothing. No answer, the host out of reach or 503 license_service_unavailable, keeps every verdict as it was; so does any check that cannot reach the server, so a refused license is never turned back into a plugin that seems ready.
  • What it changes. A plugin whose license is refused is not ready, so @ and the agent leave it out, and it stays connected: the next verdict that says the license works brings it back, renewed on the server or imported again.

What a plugin can reach

A plugin's server gets the arguments of the calls opencode makes to it, and nothing else of the user's: no plugin code runs in the app, nothing it returns reaches Sources unless the user saves it (agent), and opencode's connections are tunnelled only to the hosts of a ready plugin (agent). Against text in a source or a result steering the model, the defences are approval with the exact arguments, hosts the user consented to, a changed description switching its tool off, which hides it from the model, and third-party plugins off until allowed. None of this is a sandbox.

Routes

Method Path Does
GET /plugins Every connected plugin with its tools, auth_type (none, token, headers, license or oauth), a headers plugin's header_names, transport, needs_scopes, own_app, whether it signs in with the user's own OAuth app, needs_own_app, needs_client or refused while it needs one, never_signed_in for an OAuth plugin saved before its first sign-in, with no credential yet, disconnected and removed_reason; never a key, header value or token
GET /plugins/first-party SurfSense's own plugins from the packaged list, each with about, tools, added, has_icon and connected
GET /plugins/first-party/icons/{id} One's icon, from beside the packaged list (first_party_icon.py), sandboxed as the registry's are (icon_response.py)
POST /plugins/first-party/{id} Connects one, as above
POST /plugins/custom Adds a server by URL, as above; one that asks for OAuth is saved signed out, 201, to connect from its row
GET /plugins/registry The registry's plugins, each with connected, and the removed ones
GET /plugins/registry/icons/{id} An entry's verified icon, from the copy held
POST /plugins/registry/{id} Connects one, as above; 202 with a sign-in for an oauth entry
GET / DELETE /plugins/sign-ins/{id} A sign-in's progress, with why it failed as a code and a sentence; abandons it
GET /plugins/oauth/callback Where the browser comes back from the provider
GET /plugins/oauth/redirect-uri The callback a user registers their own OAuth app with, for this build
POST /plugins/{id}/sign-in Signs an OAuth plugin in again, keeping its tool settings; {oauth_client} as the user's own app
PUT /plugins/{id} {enabled}: off pauses a plugin, keeping its key and tool settings
POST /plugins/{id}/check Lists the server again and records the check
PUT /plugins/{id}/headers {headers}, checked as at connect: the server lists its tools with the new headers first, and only ones it accepts replace the old, recorded as a check; the tool settings stay. A refusal is the same 422 or 502 as connecting (replace_headers.py)
PUT /plugins/{id}/key {token}: the server lists its tools with the new key first, and only a key it accepts replaces the old one, recorded as a check; the tool settings stay. A refusal is the same 422 or 502 as connecting
PUT /plugins/{id}/tools/{tool} {enabled}, {approval}, or both
GET / PUT /plugins/third-party {allowed}; turning it off ends every third-party sign-in under way, so none reaches its provider after (plugin_sign_ins.py). While it is off, every route that reaches a plugin's server refuses a plugin SurfSense does not publish with 403 third_party_off before sending anything: adding by URL, connecting from the registry, Refresh tools, a new key or headers, connecting again and signing in (third_party_off.py). Removing, disconnecting and its tool settings stay, touching only this computer
POST /plugins/{id}/disconnect Drops the key or sign-in and sets disconnected; the row, its credential_kind and its tool settings stay, and its tools are not offered (disconnect.py). Refresh tools on it is 409 plugin_disconnected
POST /plugins/{id}/connect Connects a disconnected plugin that needs nothing entered, listing it as at first connect. One with a key or headers is 422 plugin_needs_key and connects again through /key or /headers; an OAuth one is 422 plugin_needs_account and connects again through /sign-in
DELETE /plugins/{id} Removes it: the row, the key and the tools go; steps it took stay in their threads' sessions (remove.py)

Settings → Plugins

plugins-settings.tsx, under Settings, says plugins work in Agentic threads and has two tabs, Yours and Browse, with Add custom plugin beside them and nowhere else. It opens on Yours when a plugin is connected, otherwise on Browse, chosen once when the list first loads.

  • Search, beside the tabs, finds plugins in both at once (search/): a plugin matches when every word of the query is in its name, id, description, author or tool names, or, for a connected one, its host and tool titles. Results list Yours, the connected plugins in the same table, then Available, the first-party and registry plugins not connected, as one-line rows rather than Browse's cards: icon, name and description, with Connect; the registry is searched only while third-party plugins are allowed, and the results say so otherwise. While the query is not empty the tabs are held and the results replace them; Escape or the clear button gives them back. Results settle 200 ms after the last keystroke, with a skeleton of both sections until then. A result opens its details or its public page in place, and Back returns to the results.
  • Yours (your-plugins.tsx) is a table of connected plugins, Plugin, Type and Status (connected-plugins-table.tsx). Type is "By SurfSense", "Third-party" or "Custom" for a server added by its URL. A third-party plugin while third-party plugins are off says "Third-party plugins are off" before anything else, with no Connect. Otherwise a plugin not connected, of any type, shows Connect in place of its status, the same Connect as its details (not-connected.ts, connect-plugin-button.tsx): disconnected, or lacking a sign-in, key, headers or OAuth app. Beside it, in grey, is why, so the user knows what Connect will ask: "Needs an OAuth app", "Needs more access", "Sign-in expired" or "Key refused"; nothing for a plugin disconnected by the user or never signed in (connect-reason.ts). An OAuth plugin's Connect opens its details, where the sign-in runs. Any other status is one state, the first that holds, red when something broke, amber when it needs a step from the user, grey when off on purpose (plugin-status.ts, status-tone.ts): "No longer listed", a failed check connecting does not fix: in red "Couldn’t connect", "License revoked" or "License not recognized"; in amber "Needs a license" or "License expired", as "No longer listed" is, "Off", then "On". "On" carries a "New tools" badge while a tool is new or changed. Each row opens the plugin's details. With nothing connected it says so, with Browse plugins and Add custom plugin (no-plugins-connected.tsx).
  • Browse (browse-plugins.tsx) holds By SurfSense, from the first-party list (first-party-plugins.tsx): each plugin with its logo, its description, "License required" where access is license, and Connect, or, once saved, its status as Yours shows it and in the same colour, which opens its details there (saved-plugin-status.tsx); connecting ends in a toast wherever it starts, Browse, Add custom plugin, Connect on a disconnected plugin in Yours, a key or a sign-in: "{plugin} connected", including once an account's sign-in finishes in the browser, or "Couldn’t connect {plugin}" with the reason in the app's words and Report issue (connect-toasts.ts). Then Third-party (third-party-plugins.tsx), its header holding Allow third-party plugins, off by default. Turning it on first asks, saying other people run those servers, what a tool is sent goes to its publisher, and SurfSense does not review changes to them; turning it off asks nothing. While it is off nothing offers to reach a third-party server: no Connect in Yours or on a plugin's details, and Add custom plugin holds Connect, saying a server added by URL is a third-party plugin, beside the same switch and consent (third-party-held.ts). While it is off the section is one sentence; while it is on it holds the registry's list once they are allowed (registry-plugins.tsx), dated "Updated" with fetched_at. While plugins.surfsense.com is off it says the list comes from there, with Allow, which raises the egress prompt on the click rather than at load. Each entry shows its icon or its name's first letter (plugin-icon.tsx), name with a Verified checkmark or a Community badge beside it, its description, "By" its author, an external entry's note, and Connect, or its status once saved. A token entry's Connect opens a dialog for the key, labelled in its publisher's words (registry-key-dialog.tsx); a headers entry's, a field for each header it names, labelled with its label or name and its description (registry-headers-dialog.tsx). An oauth entry's Connect saves it and opens its details in Yours, where the sign-in runs; a user_client entry's opens them with Set up OAuth app.
  • Icons. Each of SurfSense's plugins and each registry plugin shows the icon beside its list; any other shows the first letter of its name (plugin-icon.tsx).
  • Add custom plugin opens one screen, as Claude's own connector dialog does (add-plugin-dialog.tsx): Name, Server URL, Advanced settings folded, and Add. Add puts the server in Yours and the dialog closes on the Yours list, its toast saying how it ended: "{plugin} connected", or "{plugin} added." with what Connect will ask, a sign-in or an OAuth app: one needing nothing, or the key it was given, works at once, and one asking for OAuth waits for Connect on its row, as Claude's custom connectors do, which signs in or sets up the user's own OAuth app. Advanced settings holds the connection editor, the same fields as Edit connection (connection/): Headers, rows of a name and hidden value, up to 10, written as the user would in any MCP client: the first named Authorization, its value hinted "Bearer your-key", and renamed for a server reading its own header, such as x-api-key. Only rows with a value are sent, and a value needs a name (connection-headers.ts, header-rows.tsx). The user's own OAuth app is never among them. A refusal shows above them in the app's words by its code (refusal-message.ts), and plugin_needs_sign_in opens Advanced settings, since a key fixes it. A provider whose metadata names no registration, as GitHub's, is added as "Needs an OAuth app", and one that refuses SurfSense is found at Connect, which then opens the same three steps. The add form never asks for an OAuth app; a provider SurfSense registers with by itself, Linear's or Notion's, never needs one. A host not allowed raises the egress prompt and retries once allowed. Connecting a key or no-auth plugin from Browse stays on Browse.
  • A sign-in started by Connect, Add custom plugin or Sign in opens the provider's page in the browser and shows "Waiting for sign-in…" with Cancel, polling each second until it connects or fails, saying why in the app's words for the API's code, in the API's own sentence for a code the app has no words for, and "The sign-in didn’t finish." once the API has forgotten the sign-in (use-plugin-sign-in.ts, sign-in-progress.tsx, sign-in-failure-text.ts). Every one is followed by Settings › Plugins itself and shown where it started, a row in Yours or a plugin's details, so leaving that row or page does not end it (opened-sign-in.ts); Connect in Browse opens the plugin's details in Yours with its sign-in running (open-plugin.ts); Cancel leaves the plugin saved, "Not connected". Leaving Settings › Plugins abandons it. Electron opens only a URL shaped as SurfSense's authorization request: response_type=code, S256 PKCE, SurfSense's state, and one of its two callbacks as redirect_uri (external-url.ts).
  • A plugin's public page opens only from its entry in Browse, in place of the list, with Back (plugin-public-page.tsx). One page serves first-party and registry plugins, built from the list entry alone, so it is the same whether the plugin is connected or not: its icon and name, beside which a third-party plugin shows a checkmark when it is verified or a small Community badge otherwise, each with a tooltip saying what it means, as on its card (publisher-mark.tsx), and its publisher, "By SurfSense" or "By" its author, with Connect, or its status once saved; Connect on a community plugin first asks, naming who runs it and that the user's requests and sign-in pass through their server (community-reminder-dialog.tsx); About, its about, a fuller account than its card's description, which never stands in for it; Tools, its listed tool names as chips in two columns, with no switch; and Details: Publisher, who runs it, SurfSense or its author; Authentication (None, SurfSense license, API key, or "Your {plugin} account" for one that signs in with an account), Connects to, its server's host, Signs in with, an OAuth plugin's other hosts, Added, the month it was added, and its homepage and privacy policy as links that open at once in the browser, by the plugin's id (egress). One Connect serves every listed plugin, on its row and its page (connect-listed-plugin.tsx). A connected plugin's settings are in Yours, below.
  • A plugin's details, opened from its row in place of the list, with Back (plugin-details.tsx, plugin-details-card.tsx): its icon and name, its type as Yours shows it, one action, and a menu of more actions beside it. The action is Connect while the plugin is not connected, disconnected or with a sign-in or key it lacks, and Disconnect otherwise (plugin-main-action.tsx). Connect does what the plugin needs (connect-plugin-button.tsx): an OAuth plugin signs in (sign-in-again-button.tsx), asking again for scopes it needs more of; one whose provider takes only registered apps opens Connect {plugin} with your own app; a custom plugin with headers opens its header rows, their names read back and every value typed again, since a saved one is never shown (edit-connection-button.tsx); a registry plugin with headers, a field for each header its entry names (edit-headers-button.tsx); one with a key, or an open server now refusing without one, a dialog for the key that says when the server refuses it (replace-key-button.tsx); any other connects at once. Disconnect asks first, then drops the key or sign-in and keeps the plugin, with its tool settings, in Yours. The menu (plugin-actions-menu.tsx) holds Refresh tools; Edit connection on a connected custom plugin with headers or none, or Edit headers on a connected registry one with headers, the same dialogs saving in place; Change OAuth app on one signed in with the user's own app, own_app in its entry's auth; License settings, opening Settings › License, on a refused license; for a custom plugin Submit to the registry, which opens the registry's submission form in the browser with its name and URL filled in (registry-submission.ts); and Remove, which asks first, then deletes it and returns to the list. A disconnected plugin's menu holds only Remove. Under them is one status line, with no buttons (plugin-status-line.tsx): the state in its colour, and at most one reason behind an info button beside it, shown on hover or focus and naming the button for a screen reader. The action that needs a reason says it again where it matters, as Connect's own-app dialog does. The states: "Disconnected", "No longer listed" with the registry's reason, "Not used" for any third-party plugin while third-party plugins are off, first and with no Connect, Disconnect or menu item that would reach its server, only Remove, "Connected", "Off", "Not connected", "Couldn’t connect", "Needs a new key", "Needs more access", "Needs sign-in", "Needs an OAuth app", "Needs a license", "License expired", "License revoked" or "License not recognized". "Needs an OAuth app" gives why, the provider registering no app or refusing SurfSense, and "Not used", "Not used until third-party plugins are allowed". The user's own app is shown only where it is needed, never in Add custom plugin: Connect {plugin} with your own app, the three steps it takes, most of them outside SurfSense: register an OAuth app with the provider; give it the callback address, copied in one click, with a note in a developer build that the address changes each time the app restarts (callback-address.tsx); and paste the Client ID and secret, with Scopes folded away since most providers take them from the server (own-app-dialog.tsx, own-app-fields.tsx). A sign-in that a provider refuses that way marks the plugin and opens the same dialog, saying why. What concerns its tools sits with them, apart from the status line: a plugin with no tools listed says "Connect to see its tools." before its first sign-in, "Sign in to see its tools." after, or "This server lists no tools."; when a check switched tools off, the tools say some are new or changed and stay off until allowed, and each such tool is marked New or Changed. Its tools are split into Read-only and Can make changes, each group folding away, with a count and a menu of the state its tools share, Always allow, Needs approval or Blocked, which sets every tool in it that the approval rules allow when picked, leaving the rest as they are, and is closed when no tool in the group can take it, and Custom, checked while they differ (tool-group.tsx). Each tool shows its name, its description only to a screen reader, and three icons, Always allowed, Needs approval and Blocked, one write each (tool-permission.ts); Blocked switches it off. An icon the rules close is dimmed and its tooltip says why: Always allowed on a tool that always asks, Needs approval on a first-party read-only one (tool-kind.ts mirrors needs_approval()).
  • Turning a plugin on or off happens in an Agentic chat's composer: + › Plugins lists every connected plugin that is not disconnected, each with a switch, under "Applies to all Agentic chats", since the switch is the plugin's enabled for every chat (plugins-menu.tsx). A third-party plugin's switch is held off while third-party plugins are off. Above them, Add plugin › opens Settings › Plugins on Browse (Browse plugins) or with the Add custom plugin dialog (Add custom plugin), and Manage plugins opens it as it is. In a Basic chat, Plugins is held, its hint saying plugins work in Agentic chats.

Tests

  • Backend: tests/integration/plugins/routes/ connect real MCP servers on loopback (tests/mcp_test_server.py) through the routes: each refusal, a key that reaches the server and never comes back out, the switches, disconnecting with the tool settings kept and connecting again with nothing, a key or a sign-in, removing, the third-party switch, checks: a server gone down, a refused key, and tools marked added or changed until allowed, replacing a key: one accepted keeps the tool settings, one refused is not saved, headers (test_plugin_headers.py): a key in x-api-key, several headers, each refused name or value, replaced or refused in place, and asked for again after disconnecting, the transport (test_plugin_transport.py): an SSE-only server found and checked over it, its headers, a server answering neither, a web page or another 4xx as no MCP server, a 401 with a JSON-RPC body, and a connected server moved to the other transport, the user's own app and more access (test_oauth_own_app.py): a provider without registration refused with its callback, one refusing a public client registered as a confidential one, one refusing registration altogether, signing in, refreshing and signing in again with the app's secret, and a 403 insufficient_scope asked for at the next sign-in, and SurfSense's own plugins against a stand-in plugin host (test_first_party_plugins.py): the list with no network, the license key on every request, a missing or expired license connecting with its reason, a license imported later used at the next check, no key once the plugin is off the plugin host, and a custom plugin kept off a first-party id. test_oauth_sign_in.py signs in against a stand-in authorization server and an MCP server that answers only its tokens (tests/oauth_stand_in.py): a custom server and a registry plugin, a client registered in advance with its scopes, a state the app did not issue, a provider's refusal, a sign-in declined, refused, abandoned or cancelled keeping the plugin, each failure named by its code, needing a sign-in from its row, the authorization server's host asked about first, a packaged app signing in as SurfSense's client, a refused token refreshed once, a refused refresh marking the plugin, signing in again keeping tool settings, and no token in a log line or a response; test_sign_in_client.py holds which client each build signs in as. The registry's tests are in plugin registry. tests/unit/plugins/lists/ checks the packaged list and each rule an entry breaks. tests/integration/plugins/gateway/ holds readiness and tools added later, and tests/unit/plugins/ the MCP client, the approval rules and URL spelling. The agent's side is in agent.
  • Frontend: plugins-settings.test.tsx drives the screen against a stand-in API: listing, adding in one click, a key sent as Bearer or in a custom header, Advanced settings opened by a refusal, a server that is not MCP, an OAuth sign-in and the user's own app from Add custom plugin, Edit connection, a refusal, each tool's permission, a group's menu and folding, the plugin switch, disconnecting and connecting again from the row or with a key, removing, the third-party consent, the check's status with Refresh tools, replacing a refused key, tools marked New or Changed, SurfSense's plugins listed and connected, each license status with License settings, the tab it opens on, each row's type and status, a plugin's details and Back, the empty state, one Add custom plugin button, searching both lists with connected plugins first, the tabs held while searching, a skeleton while results settle, third-party plugins left out while off, a result's page and Back, Browse's order with the switch in Third-party, the registry fetched only once allowed and only with its host allowed, its entries listed and connected with a key or a sign-in in the plugin's details, a user_client entry saved needing an OAuth app, a saved entry's status opening its details, Cancel and a failed sign-in with why, in the app's words, the API's for a reason it has no words for, and once the API has forgotten it, a server by URL that signs in, Sign in, and a removed plugin's reason. external-url.test.ts holds which sign-in pages Electron opens. plugin-step-line.test.tsx holds a plugin step's name, its whole result and Save to Sources.

Known gaps

  • Only remote MCP servers, reached over HTTP. A local server started by a command, such as npx or uvx, cannot be added: local servers come with bundles.
  • A server on the older SSE transport only, behind OAuth, that refuses the POST with 405 and asks for a sign-in only on its stream cannot be signed in to: a sign-in starts from the POST's 401. Of 13 providers probed on 20 Oct 2026, none does; Asana's, Atlassian's, Square's, Intercom's, PayPal's, Webflow's and Neon's /sse all answer the POST 401 and sign in.
  • A server that reports missing access inside a tool's result, rather than as a 403 insufficient_scope, is not seen as needing more access.
  • A key in a custom plugin's URL is kept as typed, stored in the clear with the URL; nothing warns.
  • OAuth sign-in has been tried only against a stand-in authorization server, never a live provider.
  • The tests stand in for the callback page's forward to the API by calling the API's callback themselves; the deployed page is not exercised by any test.
  • A server added by URL whose authorization server is on another host raises a second consent prompt, for that host, after the one for the server's own.
  • A removed plugin's reason shows only on its connected row; the Third-party list leaves removed entries out and does not show removed.
  • A check runs at connect, before or after an agent turn, and on request, never on a timer, so a server that went down is seen only then.
  • A tool whose server changed it and takes more than 3 seconds to list is offered as recorded, as with a server that cannot be listed, to every turn that starts before its listing ends, for the whole of each such turn, even once the late check has switched it off.
  • No activity view: a plugin's recent calls are kept in each thread's opencode session, and the screen does not gather them yet.
  • Plugins reach Agentic threads only; Basic threads get none until the chat engine's router step (proposal).
  • A server's notifications/tools/list_changed is not read; a changed tool is seen at the next check.
  • Settings › Network lists a plugin's hosts without the plugin's name.
  • No organisation policy can turn plugins off or hold third-party plugins off (proposal).