MCP bridge and tools
External MCP bridge
The bundled mcp plugin owns external server management and contributes tools through
registerTool(..., { external: true }). Its persisted server rows, grants, names,
deferral rules and runner mcpSnapshot remain the authority. A snapshot declares tools
without connecting; first use acquires the existing live client. There is no second
client registry or OAuth credential file.
The bridge imports only public @earendil-works/pi-mcp exports. McpClient,
StdioTransport and StreamableHttpTransport own host protocol and process handling.
Managed stdio uses one Elowen-owned ManagedMcpTransport (plugins/mcp/lib/managedTransport.mjs)
over the existing authorized Sandbox stream, lease and verified cancellation. Native request options supply timeouts and aborts.
Stored SSE rows are diagnostic-only, never connected or declared as tools, and report
auth: { state: 'unsupported', transport: 'sse' }. New server input accepts only
stdio and http. After a successful native handshake, host and managed
connections call listTools only if McpClient.serverCapabilities.tools is
advertised. Servers with resources only remain usable with zero bridged tools;
an advertised tools capability may also legitimately return an empty list.
MCP's CLI picker and web surfaces share localized auth/status labels and the
stdio administrator rule through plugins/mcp/serverPresentation.mjs, using
canManageInstance from the server-list response. Its JSDoc types are checked
with the importing browser sources by tsconfig.plugins-web.json. CLI reconnect
retains its OAuth-pending result reporting; the page offers sign-in before reconnecting.
The MCP control exposes only bridgeSnapshot() for delegated runner handover.
The add and reconnect API responses return { server, verifiedInProject: boolean };
add retains its editable server projection. The boolean is
operation-local provenance: true only after successful managed stdio verification
and transient-client cleanup. A failed verification throws instead of returning
that receipt. Disabled adds and host connections return false. It is never stored,
replayed, or added to list/status DTOs. The one current publicServerState remains
the connection authority: verified managed servers are still disconnected because
each operation opens its own client. The CLI picker, MCP page and management tools
use the receipt to distinguish verification from a persistent connection. Single
results explain this distinction; bulk results count verified and connected servers
separately. No additional connections, polling or persistent state are introduced.
GET /plugins/mcp/api/servers adds an optional server description, normalized to
an empty string when absent, and auth with state none, signed-in,
needs-sign-in, error or unsupported. Optional scopes are display-only.
Descriptions are strings of at most 2048 characters. Credentials never enter
this DTO, stored specifications, snapshots, brain status, or model context.
The authenticated, prefix-routed OAuth API is:
POST /plugins/mcp/api/oauth/sign-in/<name>with{ scope: 'personal' | 'instance' }returns exactly{ authorizationUrl }.POST /plugins/mcp/api/oauth/sign-out/<name>with the same scope clears the tokens, client registration, pending callback and live client, returning{ signedOut: true }. A known OAuth server retains only its authorization requirement and reportsneeds-sign-inwith a disconnected or disabled status, including after reload.POST /plugins/mcp/api/oauth/callbackaccepts JSON{ state: string, code?: string, error?: string, iss?: string }from the authenticated browser page. Success is HTTP 200{ server: string }. Failures are 4xx{ error: 'expired' | 'invalid_state' | 'denied' | 'failed', server?: string }, without provider prose or secrets. The old GET callback route does not exist. The state is bound to the initiating account, server row, owner and revision; pending states expire after ten minutes. One atomic delete claims a known state before checking the caller or outcome, including a wrong account or denied consent.GET /hooks/mcp/oauth/client-metadatais the public client metadata document, a read-only route with no bearer authentication. It answers503unless the instance public web URL is HTTPS. Elowen serves it only for servers that advertise client metadata documents and have no registration endpoint; the document URL is the client ID.
The provider's redirect URI is the trusted instance public origin plus
/p/mcp/oauth/callback, derived only from ctx.publicWebUrl(). The plugin page
reads the provider's query values and posts them once through the existing
authenticated API proxy, which carries the browser's Elowen session cookie.
The daemon route is /plugins/mcp/api/oauth/callback, so the browser proxy URL is
/api/plugins/mcp/api/oauth/callback. Declare it with path: 'oauth/callback' in
ctx.registerApiRoute; the plugin never adds the outer /api/ itself.
The daemon returns JSON, not a redirect. No public hook or host impersonation is used.
The callback validates RFC 9207 iss as an optional string and forwards it unchanged
to PI's native authorization-response issuer check. A matching issuer permits code
exchange. A mismatched issuer, or a missing issuer when the authorization server
promised it, returns failed before exchange. A non-string issuer returns
invalid_state. Every outcome consumes the known state; no issuer is invented,
normalized, or inferred from discovery metadata or response prose.
Scope and name resolve together through the existing ownership checks. Instance
management is administrator-only; personal management cannot open another account's
bag. The public callback URL comes only from ctx.publicWebUrl(). Public HTTP
servers need no callback URL; a challenged OAuth flow requires it.
McpOAuthProvider, adaptOAuthProvider and authorizeMcp own discovery,
registration, PKCE, issuer verification, refresh and step-up scope union. Native
state is saved in the host's encrypted secret bag under owner, server name and the
standard URL parser's normalized URL. Keying and comparisons use the same normalized
form; stored server specifications need no migration. The native store load also
projects the normalized URL so PI recognizes existing state. Its envelope additionally
fences server row identity and revision, including
delete followed by same-name recreation. Operations capture row identity and revision
before waiting for the credential lock, then re-check them after acquisition.
Delete, URL change and ownership transfer
clear credentials and callbacks under the same host lock. Same-URL edits retain
tokens under the new revision but cancel pending consent. Account removal uses the
existing host credential cascade and drops callbacks belonging to the actor.
The bridge wraps the complete native unauthorized flow in bag.withLock, re-reading
state while holding the cross-process lock. Daemon and runner therefore share one
rotating-token refresh, not just an in-process promise. Lock acquisition and OAuth
network flows each have a 15-second deadline; supplied fetch abort signals are preserved.
HTTP failures are projected from typed status fields to safe messages containing the
server name and status, never the response body or original error/cause. This applies
to tool calls, lazy connection and resource reads/listing. Remote tool and protocol
errors set PI's result-level isError; details contains metadata, not a second
isError flag. Endpoint error bodies are never returned or logged. With no stored OAuth state,
auth is none; authorization-required leaves the server available for explicit
sign-in. The rail receives only the validated administrator-only auth.state
projection through registerBrainStatusProvider, without an extra server fetch.
Owning code and limits
- The bridge lives in the
mcpplugin:plugins/mcp/index.mjs(tools, API routes, status),plugins/mcp/lib/oauth.mjs(OAuth state, callbacks),plugins/mcp/lib/managedSession.mjsandplugins/mcp/lib/managedTransport.mjs(managed stdio), andplugins/mcp/serverPresentation.mjs(shared labels). Core only supplies seams: theexternaltool option insrc/plugins/api.ts, theMcpListControlseam withbridgeSnapshot()in the same file, and the runner snapshot insrc/plugins/mcpSnapshot.ts. - Minimal use: nothing to add for a plugin author. The bridge is the only producer of external tools, and a second bridge would duplicate its client registry, so do not build one.
- Absence: with the
mcpplugin disabled, no external server tools are declared, and sub-agent runners receive no bridged tool definitions. - Limits: stdio servers are restricted to instance administrators, SSE is unsupported, and OAuth needs a configured public HTTPS web URL for challenged servers.
- Cost: a managed stdio operation opens its own client and Sandbox lease, and each lock or OAuth network flow is bounded to 15 seconds.
MCP tools
registerMcpTool contributes to Elowen's own authenticated MCP server. It is
not the plugin that connects external MCP servers. The host composes this live
registry into each /mcp request, but no production plugin in the bundled or
registry trees currently contributes a tool. Until there is a producer, plugin
tools are absent from the server; do not use this seam for the external MCP
bridge.
MCP names are lowercase snake_case and must be in provides.mcpTools.
The input schema is a Zod raw shape. The handler receives parsed arguments and
a request function bound to the calling MCP client's token:
ctx.registerMcpTool({
name: "my_tool",
description: "Reads plugin status.",
inputSchema: {},
async run(args, request) {
return request("GET", "/plugins/my-plugin/api/status");
}
});
The handler cannot act with broader rights than the MCP client. The live tool list is rebuilt from the current plugin registry.
Owning code and limits
- Contract:
registerMcpToolandPluginMcpToolinsrc/plugins/api.ts, withPluginMcpRequestbound to the caller's token there as well. - Gate: the strict deny-by-default check in
src/plugins/registry.tsrefuses a name that is not snake_case or not declared inprovides.mcpTools.mcpToolsForfilters the live list by the caller's granted plugins. - Composition:
src/mcp/server.tsmerges these tools with core's own toolset, which lives insrc/mcp/tools.ts. Core'selowen_requestescape hatch is core, not a plugin contribution. - Absence: a plugin that is disabled, or that declares no tool, leaves
tools/listwithout its tools. Nothing is cached, so a reload or disable applies to the next request. - Consumer: none in the bundled
plugins/tree or the registry tree at the time of writing. Check again with a literal search forregisterMcpToolbefore documenting a consumer.