NAVIGATION
ELOWEN DOCUMENTATION

Last updated: 10 October 2026

Browse documentation · MCP bridge and tools
Developer reference

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 reports needs-sign-in with a disconnected or disabled status, including after reload.
  • POST /plugins/mcp/api/oauth/callback accepts 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-metadata is the public client metadata document, a read-only route with no bearer authentication. It answers 503 unless 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 mcp plugin: plugins/mcp/index.mjs (tools, API routes, status), plugins/mcp/lib/oauth.mjs (OAuth state, callbacks), plugins/mcp/lib/managedSession.mjs and plugins/mcp/lib/managedTransport.mjs (managed stdio), and plugins/mcp/serverPresentation.mjs (shared labels). Core only supplies seams: the external tool option in src/plugins/api.ts, the McpListControl seam with bridgeSnapshot() in the same file, and the runner snapshot in src/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 mcp plugin 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: registerMcpTool and PluginMcpTool in src/plugins/api.ts, with PluginMcpRequest bound to the caller's token there as well.
  • Gate: the strict deny-by-default check in src/plugins/registry.ts refuses a name that is not snake_case or not declared in provides.mcpTools. mcpToolsFor filters the live list by the caller's granted plugins.
  • Composition: src/mcp/server.ts merges these tools with core's own toolset, which lives in src/mcp/tools.ts. Core's elowen_request escape hatch is core, not a plugin contribution.
  • Absence: a plugin that is disabled, or that declares no tool, leaves tools/list without 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 for registerMcpTool before documenting a consumer.