# MCP extension Persisted preferences are owned by the daemon and changed through the [settings API](settings.md). The CLI refreshes them on use and never writes settings files. Albedo's `mcp` extension connects one session to configured [Model Context Protocol](https://modelcontextprotocol.io) servers and advertises their discovered tools, resources, and prompts as ordinary model tools. It is installed but disabled by default: enable it from `/extensions`, for every session or (after `s`) just the current one. The client is albedo's own (`mcp/client.gleam`, JSON-RPC framing in `mcp/jsonrpc.gleam`, the HTTP exchanges in `mcp/http.gleam`): one process per server connection numbers requests, matches responses, and answers the server's own requests. It speaks both eras of the protocol and finds out which one a server speaks the way the 2026-07-28 revision's backward-compatibility rules say (`basic/versioning`, `basic/transports/stdio`, `basic/transports/streamable-http`): - It first sends `server/discover` as `2026-07-28`. A `DiscoverResult` naming `2026-07-28`, or an `UnsupportedProtocolVersionError` (`-32022`) whose `supported` list names it, makes the server modern. A modern server that names only versions albedo opens with `initialize` gets `initialize`; one that names neither is refused. - Any other answer means an older server, which gets the `initialize` handshake: albedo offers `2025-11-25`, accepts `2025-11-25`, `2025-06-18`, `2025-03-26`, or `2024-11-05` back, and sends `notifications/initialized` before any request (over HTTP it waits for the server to accept it). Over HTTP that is any non-2xx status without a modern error in its body, or a JSON-RPC error that is not one of `-32020`/`-32021`/`-32022`. Over stdio a server may ignore the unknown method instead, so `initialize` follows after one second without an answer; the probe stays open, and whichever of the two is answered first as its era expects decides. - To a modern server there is no handshake and no session: every request's params carry `_meta` with `io.modelcontextprotocol/protocolVersion`, `clientInfo`, and an empty `clientCapabilities`, and over HTTP every POST carries `MCP-Protocol-Version`, `Mcp-Method`, and, for `tools/call`, `resources/read`, and `prompts/get`, `Mcp-Name` (base64 between `=?base64?` and `?=` when the value is not plain visible ASCII). A tool parameter annotated with `x-mcp-header` is mirrored into `Mcp-Param-`; an HTTP tool whose annotations break the spec's rules (not on a string, integer, or boolean property reached through `properties` alone, not a token, or not unique) is left out of the catalogue with a warning. A result with `resultType: "input_required"` is an error, since albedo declares no client capabilities. Albedo declares no client capabilities, so a server's `ping` is answered and any other request it sends (`roots/list`, `sampling/createMessage`, `elicitation/create`) gets `Method not found`. List methods follow `nextCursor` for up to 100 pages. A request that times out is dropped; the server is sent `notifications/cancelled`, except a modern server over HTTP, for which closing the request's stream is the cancellation. A `notifications/tools/list_changed`, `resources/list_changed`, or `prompts/list_changed` from the server makes the connection discover the server again in the background, at most one discovery at a time (a change reported meanwhile runs it once more); a catalogue that differs is saved and refreshes the session after its next turn, as the background check below does. Over stdio these notifications arrive on the one channel. Over HTTP, once a 2025-11-25-era session is open, the client opens the optional GET stream (2025-11-25 `basic/transports`, "Listening for Messages from the Server"), and for a modern server whose capabilities say a list can change it holds a `subscriptions/listen` request open for `toolsListChanged`, `resourcesListChanged`, and `promptsListChanged` (2026-07-28 `basic/patterns/subscriptions`; it replaced the GET stream). A modern stdio server is sent the same `subscriptions/listen`. Requests on the GET stream are answered like any other. A 405 or other refusal leaves the stream closed for the connection's life; a stream that drops is reopened after the server's `retry`, or after a wait that doubles from one second up to a minute with each attempt that heard nothing, and given up after eight such attempts in a row. There is one such stream per connection, and closing the client closes it. A stdio server runs behind an Erlang port (`albedo_mcp_stdio.erl`) as newline-delimited JSON, launched by `priv/python/mcp_stdio.py`, which stays the server's parent and puts it in a process group of its own. Closing the port closes the launcher's stdin; the launcher closes the server's, gives it two seconds, then sends the group `SIGTERM`, and after two more `SIGKILL`, so a server that ignores EOF still ends (2026-07-28 `basic/transports/stdio`, "Shutdown"). A streamable HTTP server gets each message as its own POST from a worker process, through the provider transport (`openai_api/albedo_openai_transport.erl`: HTTP/1.1, pooled connections, TLS against `albedo_trust`), with `Accept: application/json, text/event-stream`. The worker reads a JSON body or an SSE stream (`openai_api/sse.gleam`) until its response arrives, passing on any request the server sends on that stream. A refusal's JSON-RPC error reaches its request as the server's error. The `Mcp-Session-Id` from `initialize` and the negotiated `MCP-Protocol-Version` go on every later request of an older server; a 404 for the session ends the client, so the next call dials again, and closing sends `DELETE` with the session id. Messages are capped at 16 MiB. Under a 2025-11-25-era session an SSE response that ends or breaks before its response is resumed (2025-11-25 `basic/transports`, "Resumability and Redelivery"): the worker remembers the last event id the stream delivered and sends a GET with `Last-Event-ID` and the session id after the stream's `retry` (250 ms when it set none, at most 30 s), reading the resumed stream the same way, until the response arrives, the request's timeout passes, or five resumptions in a row bring no new event. A stream without event ids cannot resume and its request is lost. The 2026-07-28 revision removed resumption, so a modern server's broken stream loses its request; tool calls are never retried automatically. Albedo owns configuration, credential scope, naming, and lifecycle in `albedo_mcp.erl`. ## Add servers from albedo `/mcp` lists configured servers. `n` opens one form for a new server: - **transport**: `http` (default) or `stdio`, switched with ← →. - **url** for HTTP, or **command** for stdio, written as you would type it in a shell (`npx -y @modelcontextprotocol/server-filesystem "/srv/my docs"`); quotes are honoured but no shell runs. - **name**, suggested from the URL host or the command's package until you edit it. - **bearer token** and one custom **header** for HTTP (a server that asks for OAuth needs neither; see below), or `KEY=value` **env** entries for stdio. These are optional, masked while typed, and stored by the daemon in `creds.json` (mode 0600), never in `extensions.json`. Nothing is written until the form is saved with ctrl+s (or enter on its last field). The server and its credentials are saved together and the session reloads once; if the server cannot connect, both are restored and the form stays open with the error. `enter` edits a server in the same form (a blank secret keeps the stored one, `-` removes it), `d` deletes a server and its credentials, and `e` re-enables a server switched off with `"enabled": false`. ## Sign in with OAuth An HTTP server that answers `401` asks albedo to sign in, as the MCP authorization spec describes (2025-11-25 and 2026-07-28 `basic/authorization`). `mcp/albedo_mcp_oauth.erl` holds the protocol, `mcp/sign_in.gleam` names one sign-in per server (`mcp:`) through the `LoginResolverPlugin`, and the daemon's shared sign-in flow (`albedo_oauth`, see [auth](auth.md#sign-in-api)) runs the loopback callback, the state check and the race with a pasted callback URL. - **Discovery.** The `resource_metadata` of the `WWW-Authenticate` challenge, else the well-known Protected Resource Metadata (RFC 9728) under the endpoint's path and then at the root, in the spec's order. The metadata's `resource` must be the server URL or a prefix of it; it becomes the `resource` parameter (RFC 8707) of the authorize, token and refresh requests. The first `authorization_servers` entry is asked for RFC 8414 metadata, then OpenID Connect discovery, in the spec's order for an issuer with or without a path; a document whose `issuer` differs is not used. An authorization server that does not list `S256` in `code_challenge_methods_supported` is refused. Its endpoints must be https, unless the MCP server itself is plain http. - **Scope.** A pending step-up, else the challenge's `scope`, else the resource's `scopes_supported`, plus `offline_access` when the authorization server lists it. - **Client.** A client id (and secret) the user configured for the server (`secrets.oauth_client` in the settings patch), else Dynamic Client Registration (RFC 7591, `application_type: native`, public client). The registration is kept per issuer and redirect, and reused while both match. Client ID Metadata Documents come between the two in the spec's order, but need an https URL albedo can host its metadata document at; a local daemon has none, so albedo does not use them. - **Authorization.** Authorization code with PKCE S256 and `state`; the callback listens on `127.0.0.1:33418/callback`, or an ephemeral port when that is busy (which registers again). A configured client should register that redirect. An `iss` in the response must be the issuer (RFC 9207); one the server promised and left out is refused. - **Tokens.** Kept in `creds.json` under `mcp..oauth` with the server URL they belong to, never in `extensions.json` or a log; a record for another URL is never sent. Every request carries the access token; one within a minute of expiry is refreshed first, and one the server refuses with `401` is refreshed once and the request posted again. Refreshes of one server are single-flight. A refresh the authorization server refuses marks the server `needs_sign_in` instead of trying again. A `403` with `insufficient_scope` keeps the token and records the union of the requested and challenged scopes as a step-up; the next sign-in asks for it. A server that needs a sign-in is left out of the session with a warning and a line in the MCP context, and its calls fail with "needs the user to sign in", which says the request did not run. It joins once signed in, the way a server that comes back does. Saving a server that answers `401` keeps it, needing a sign-in, instead of refusing it. The settings group reports each server's `auth` (`state` none, needs_sign_in, signed_in or signed_out, `account`, `expires_at`, `scope`, `step_up`, `reason`, `client`), never a token. A client signs in with `PUT /auth/logins/` and provider `mcp:`, and signs out with `secrets: {oauth: null}`. In `/mcp`, `ctrl+l` signs in (it opens the browser unless `ALBEDO_NO_BROWSER` is set, and shows the URL either way), `ctrl+u` signs out, and `esc` cancels a sign-in in flight. The web client's settings pane lists the servers that asked for a sign-in under **MCP sign-ins** (`web/src/lib/McpServers.svelte`), with the same actions; its sign-in opens the authorization page in a new tab and keeps a link to it. ## Configure servers Servers live in the `mcp` section of `$ALBEDO_HOME/extensions.json` (default `~/.albedo/extensions.json`), never in `config.json` with provider credentials. ```json { "mcp": { "servers": { "docs": { "type": "stdio", "command": "/usr/local/bin/docs-mcp", "args": ["--root", "/srv/docs"], "cwd": "/srv/docs", "env": { "DOCS_TOKEN": { "env": "MY_DOCS_TOKEN" } } }, "search": { "type": "http", "url": "https://mcp.example.com/mcp", "bearerTokenEnvVar": "MY_SEARCH_TOKEN", "enabledTools": ["search"], "callTimeoutMs": 30000 } } } } ``` Secrets are named, never inlined: every `env` value and HTTP header reads one process environment variable of the daemon, and a missing variable fails preparation instead of connecting without it. `enabled`, `enabledTools`, `disabledTools`, `startupTimeoutMs`, and `callTimeoutMs` are optional. A stdio server is launched as an exact executable and argument vector through Albedo's own launcher, with no shell, a scoped working directory, discarded server stderr, and an environment reduced to `HOME`, `PATH`, `TMPDIR`, and the values you named. HTTP servers may use `https` or `http`, including trusted LAN and tailnet addresses. Plain HTTP provides no transport encryption by itself: use it only on a network you trust (a tailnet encrypts its own traffic). Credentials are sent to the configured endpoint, so verify its address before adding auth. URLs carrying userinfo or a fragment are rejected. ## Tools and lifecycle Discovered capabilities are advertised as `mcp___`, so two servers that expose the same tool name stay distinct and stable. Resources and prompts, when a server offers them, appear as one `read_resource` and one `get_prompt` operation per server. Connections belong to the session composition: enabling, disabling, or reloading the extension prepares a replacement first and closes the previous connections only after a successful swap. ### Saved catalogues Preparation runs in the daemon-wide runtime actor (see [extensions](extensions.md)), so it never waits on a server it has heard from before. Each discovery is saved in the `mcp_catalogues` table, one row per server name, under a fingerprint of that server's configuration and credentials. A session opening with a saved catalogue for the current fingerprint advertises those tools and context at once and dials the server in the background. A tool call that arrives first waits for that dial, up to `startupTimeoutMs` plus `callTimeoutMs`. If the background discovery matches the saved catalogue, nothing else happens: no note, no new prompt. If it differs, the new catalogue is saved and the session refreshes after its next turn, the same way a returning server does. A server with no saved catalogue (never connected, or its configuration or credentials changed) is connected and discovered during preparation, so only the first session after a change waits on it. A server that cannot start, initialize, or be discovered during that first connection is left out of the session and named in a warning; the servers that did connect load as usual. A server with a saved catalogue that turns out unreachable stays advertised: its calls report it unavailable, and each later call dials it again. A client that drops after connecting, or that a timed-out call closed, is dialled again by the next call. Bad configuration (an invalid name, a missing credential file, a catalogue over the size limit) still fails the whole preparation, and the session keeps its previous working composition. Disabling the extension closes every connection and terminates each stdio subprocess. ### Servers that come back A server that was left out joins the session once it connects, the way a skill does after a reload: albedo prepares the extension again, appends a "capabilities changed" user turn, and keeps the cached system prompt until compaction. There is no timer. The extension probes the missing servers when the session shows activity (a submit or the end of a turn), first `retryMs` after the session prepared and then at doubling intervals, capped at twenty times `retryMs`. A server found up waits for the end of a turn and then asks the session to refresh, so it appears after the turn that follows its return, not mid-turn. `retryMs` (default `30000`) sits beside `servers` in the `mcp` section. ### Saving a server The `/mcp` form and the capability toggle stay strict: saving or turning on a server that cannot start is refused (409) and the previous settings and credentials are restored, so a mistyped command is caught where it is typed. A server that is switched off, or off for the session by a capability choice, is not tried. Tool calls may have side effects, so a failed or interrupted call is never retried automatically. Transport loss is reported as an unknown outcome, which the model must inspect before acting again. ## Editor servers over ACP The default-enabled `editor-mcp` extension exposes editor-supplied servers as model tools, independently of `mcp`. They do not appear in the Python `mcp` binding. Credentials and catalogues stay in daemon memory. See [ACP overlays](acp.md#mcp-overlays) for lifecycle and recovery. Set `"editor-mcp": false` under `enabled` in `$ALBEDO_HOME/extensions.json` to disable it by default. A session's extension choice overrides that default. Creation and attachment still validate and discover servers, but their tools are hidden and missing editor state no longer blocks execution after restart. ## From Python With the extension enabled, `await mcp.tools()` lists live operations and `await mcp.describe(name)` returns one operation and its parameter schema. `await mcp.call(name, arguments=None, **kwargs)` accepts an advertised name or `server/tool`; methods minted at kernel boot are also available as `await mcp..(**arguments)`. Results are records: check `r.isError`, because a tool-reported error does not raise. MCP responses are untrusted data; use `await asyncio.gather(..., return_exceptions=True)` to keep successful results when one call fails. Remote servers are untrusted input. Tool descriptions, resource contents, and prompt text are data, not instructions, and their schemas are bounded before they reach the model.