41 KiB
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 check. Every entry follows the schema both lists share (
list_entry.py): its id, anhttpsURL with no credentials,hostsnaming the URL's host and no loopback, and anaccess_notewhenaccessisexternal. A first-party entry carries noverification, itsicon, when it names one, is a plain file name present inicons/, it has anaboutof up to 600 characters, it lists itstools, each an MCP tool name once, and the month it wasaddedasYYYY-MM, and one withauth: licensesits onhttps://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 is404; one connected already, or whose URL is, is409 plugin_already_connected; a host not allowed is403 egress_disabled, as below. The server's tools are listed with the license, and the plugin is stored withsource: first_partyandcredential_kind: license. A401or403still connects it, its check saying why, since a missing license is the server's to report; any other failure is502 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, itsMCP error -32001, the app ends the thread's calls on the host throughPOST /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 ashttp://127.0.0.1:8000, moves every listed URL and host onplugins.surfsense.comto 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 atokenentry: third-party plugins off is403 third_party_off; an id not listed is404; one connected already, or whose URL is, is409 plugin_already_connected; any of the entry's hosts not allowed is403 egress_disablednaming them all. Anoauthentry then starts a sign-in,202(below). Any other lists the server's tools, with the key asAuthorization: Bearer, refused as a server added by URL is, and is stored withsource: registry, its publisher and entry, and a key ascredential_kind: token(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 inremovedkeeps its row and key, butlist_tools()leaves it out, and its row carriesremoved_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, token?}, in this order:
- 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. - A name already in use is
409 plugin_name_taken, and a server already connected is409 plugin_already_connected, each withname. A name becomes the id, lowercase words joined by hyphens;surfsenseis never one, since it is the agent's own server. - A server off this computer not on HTTPS is
422 plugin_needs_https: the agent reaches remote servers only through a TLS tunnel (agent). - A host not allowed is
403 egress_disabledwithhostsandpurpose: "plugin", so the prompt words its question for a plugin's server, and the host is recorded off for Settings › Network (egress). - SurfSense lists the server's tools with the official MCP SDK's client, the key as
Authorization: Bearer. A401to a request with no key, from a server that asks for OAuth, starts a sign-in instead,202(below). Any other401or403is422 plugin_needs_sign_in; anything else that fails is502 plugin_unreachable. - The plugin is stored with its key encrypted (
shared/secrets.py), and 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 is opencode's (mcp_client/session.py). It speaks Streamable HTTP only, through the official MCP SDK, pinned to mcp 2.3.x, so a server that offers only SSE, or is 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/listis followed page by page through its cursor.- A network error,
408,429or5xxis 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 and the
reasonin its body, which is how a license refusal reaches the checks.
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. Nothing is stored until the sign-in finishes, so an abandoned one leaves no row, token or registered client (plugin_sign_ins.py).
- Discovery. The server's
401names its protected resource metadata (RFC 9728), whose first authorization server is the one used; a server with none but/.well-known/oauth-authorization-serveron its origin is its own (oauth_discovery.py). A refusal that names neither is not OAuth. - Consent. The authorization server's host is asked about before any request reaches it:
403 egress_disabledwithpurpose: "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'shosts, so readiness and the plugin proxy require it too. - The page. The MCP SDK's OAuth client runs registration, PKCE and the code exchange (
sign_in_flow.py). The connect route answers202with{id, plugin_id, authorization_url}once the URL is ready, within 60 seconds, or502 plugin_sign_in_failed(start_sign_in.py). The screen opens it in the browser and pollsGET /plugins/sign-ins/{id},waiting,connectedorfailedwith why, as acodethe screen has its own words for (sign_in_failure.py) and in a sentence;DELETEabandons 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. - The way back. The
statesent is<port>.<nonce>, the API's port and the SDK's own state (sign_in_state.py).GET /plugins/oauth/callbackanswers only a state this API issued; a provider'serrorends the sign-in,access_deniedas declined. The tools are then listed with the new token, 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. 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 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. A plugin that does not sign in with an account is 422 plugin_not_oauth.
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, license or oauth) and credential_ciphertext (a pasted key 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 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.comswitched off in Settings › Network, asks nothing. No answer, the host out of reach or503 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, license or oauth), disconnected and removed_reason; never a key 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 |
Connects a server by URL, as above; 202 with a sign-in for one that asks for OAuth |
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 |
POST |
/plugins/{id}/sign-in |
Signs an OAuth plugin in again, keeping its tool settings |
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}/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} |
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 is 422 plugin_needs_key and connects again through /key; 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. Status is one state, the first that holds (plugin-status.ts): "Disconnected", with Connect in the row (connect-plugin-button.tsx), "No longer listed", a failed check ("Couldn’t connect", "Signed out", "Needs a new key", or a refused license), "Third-party plugins are off", "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" whereaccessislicense, and Connect, or "Connected"; 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 the section is one sentence; while it is on it holds the registry's list once they are allowed (registry-plugins.tsx), dated "Updated" withfetched_at. Whileplugins.surfsense.comis 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, anexternalentry's note, and Connect, or "Connected". Atokenentry's Connect opens a dialog for the key, labelled in its publisher's words (registry-key-dialog.tsx). - 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 a dialog with Name, Server URL and an optional Key, and switches to Yours once the plugin is connected; connecting from Browse stays on Browse. Each refusal is shown in the app's words by its
code(refusal-message.ts); a host not allowed raises the egress prompt and retries once allowed. While third-party plugins are off, a custom plugin's details say "Not used until third-party plugins are allowed". - A sign-in started by Connect, Add custom plugin or Sign in again 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 once the API has forgotten the sign-in "The sign-in didn’t finish. Try again.", or "The sign-in didn’t finish." under Sign in again (use-plugin-sign-in.ts,sign-in-progress.tsx,sign-in-failure-text.ts). Leaving the screen abandons it. Electron opens only a URL shaped as SurfSense's authorization request:response_type=code, S256 PKCE, SurfSense'sstate, and one of its two callbacks asredirect_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 "Connected"; 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, itsabout, a fuller account than its card'sdescription, 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, Disconnect, and a menu of more actions (plugin-actions-menu.tsx): Refresh tools and Remove. Disconnect asks first, then drops the key or sign-in and keeps the plugin, with its tool settings, in Yours. A disconnected plugin says "Disconnected", shows Connect in place of Disconnect, and its menu holds only Remove: Connect signs an OAuth plugin in again, asks one with a key for it in a Connect dialog, and connects any other at once. Remove asks first, then deletes it and returns to the list. Under them, the last check: "Connected", "Off", "Couldn’t connect", "Needs a new key", "Signed out" with Sign in again for an OAuth plugin (sign-in-again-button.tsx), or for a refused license "Needs a license", "License expired", "License revoked" or "License not recognized" with License settings, which opens Settings › License, and on "Needs a new key" Replace key, a dialog for the new key that says when the server refuses it. A plugin removed from the registry says "No longer listed:" and the reason. When a check switched tools off, the card says some tools 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 on hover, 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.tsmirrorsneeds_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
enabledfor 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, 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.pysigns 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 abandoned or cancelled leaving no plugin, 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.pyholds 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, andtests/unit/plugins/the MCP client, the approval rules and URL spelling. The agent's side is in agent. - Frontend:
plugins-settings.test.tsxdrives the screen against a stand-in API: listing, adding with a key, 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, 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 again, and a removed plugin's reason.external-url.test.tsholds which sign-in pages Electron opens.plugin-step-line.test.tsxholds 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
npxoruvx, cannot be added; whether it should be is an open question. - 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_changedis 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).