Navigácia
ELOWEN · Dokumentácia

Posledná aktualizácia: 20. októbra 2026

Prechádzať dokumentáciu · MCP bridge and tools

Tento článok zatiaľ nie je preložený. Je dostupný v angličtine.

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 and paired-computer stdio use one Elowen-owned ManagedMcpTransport (plugins/mcp/lib/managedTransport.mjs) over the existing authorized Sandbox stream, lease and verified cancellation. It retains already-scanned byte chunks, searches for newlines only in incoming data and assembles each complete frame once before UTF-8 decoding and JSON-RPC parsing. The existing 16 MiB limit counts frame bytes before the newline, including whitespace. Invalid JSON-RPC or an oversized frame emits a redacted ManagedMcpProtocolError and closes through the existing cancellation path. Host framing stays in PI. Native request options supply timeouts and aborts.

For a selected local target, createManagedSession uses the same sandbox.prepareExecution({projectRef, cwd, leaseKind:'mcp', command:{type:'argv',file,args}}) contract. start() exposes stdin/stdout/stderr and closed; no process runs on the daemon. The lease is scoped to one operation, including initialization, and is cancelled with verified closure before release on success, abort or failure. A later turn opens another operation on its own admitted connection rather than retaining a previous turn's client. Local execution also admits the existing lsp lease kind. Disconnect keeps typed LOCAL_EXECUTION_LOST, including cleanup aggregation, and unknown mutations are never replayed. Stored central command environments remain refused for project execution; configure credentials on the target. Binary resource export uses local file operations in the selected working directory, with the existing 16 MiB bound, and never exports into the daemon's plugin data directory. Stored SSE rows are diagnostic-only, never connected or declared as tools, and report transport: 'sse' with auth.state unsupported. 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 management API declares complete patterns in provides.apiRoutes and ctx.registerApiRoute. GET and POST use servers; PATCH and DELETE use servers/:name. The management actions use reconnect (rediscover one server) and transfer (move one server between the instance and personal sets). OAuth actions use oauth/sign-in/:name and oauth/sign-out/:name, and the callback uses the exact oauth/callback path. Handlers read req.params.name, decoded exactly once by the host, with an empty req.path. Missing names and undeclared suffixes return 404 before a handler runs. Scope and account authorization still run inside the matching handler. The public /hooks/mcp/oauth/client-metadata route retains its prefix routing and rejects a nonempty remainder itself.

The OAuth endpoints are:

  • 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 registerSurfaceFact('brainStatus', fn), without an extra server fetch.

ReadMcpResource awaits persistResourceBlob for host binary exports. This plugin-owned function exclusively creates a new file and decodes base64 in 64 KiB input batches, retaining incomplete quartets between batches. Node's standard decoder supplies the bytes, including its permissive base64 and base64url behavior. Writes are asynchronous and the returned path and byte count are published only after closing the completed file. Failed exports remove their own partial file; an exclusive-open collision leaves the existing file intact. There is no host export size cap. The response already contains the whole base64 string, but the export does not allocate a second full decoded payload. Managed exports still use writeGuestFile through Sandbox with their existing 16 MiB export limit.

Connection discovery and native response ownership

plugins/mcp/lib/connectRetry.mjs owns NEW connection/discovery through canonical createRetryController. Registration subscribes before the initial strict policy read and registers cleanup before awaiting it; invalid/unavailable policy is not replaced by defaults. Each server episode pins a policy for the original attempt plus maxRetries. Typed network, closed, timeout and HTTP 408/429/5xx failures may retry; authentication, authorization and protocol/application failures block.

Each physical HTTP NEW connect/discovery attempt injects its own public fetch into StreamableHttpTransport. Only the last POST response in that attempt supplies a hint through the shared retryAfterMs parser to a matching typed McpHttpError for HTTP 408/429/5xx. The shared owner respects that server minimum even above the 60000 ms exponential cap; absent, invalid or nonmatching hints retain the existing backoff. The SDK receives the SAME original Response, without body consumption/cloning or changes to status, headers or signal. Native authentication retains that response; GET streams and DELETE teardown remain SDK-owned and do not replace the attempt's POST hint.

Concurrent connects share one retained episode, including success, blocked and exhausted outcomes. Ordinary tool/resource calls do not replenish it. Explicit owner-scoped reconnect, specification mutation or removal closes and awaits the old episode; a new connection adopts the latest policy. Shutdown disposes the policy subscription and awaits attempt/client cleanup. Managed/local stdio leases and operation cancellation remain separate; there is no outer tools/call retry.

Discovery completes only after initialize, all advertised tools/list pages (or absent tools capability), and cache persistence. Internal setServerState keeps canonical connectionState with the MCP server/account target, monotonic generation/sequence and local-owner authority id mcp-connect-discovery. publicServerState copies it, changing only authority.kind to server-observation for GET servers, scoped management outputs and brainStatus. Authority id, target, generation/sequence, transport/readiness, the full retry payload and timestamps, observedAt and freshness retain their producer values; reads do not refresh or rebase them. discoveryReady records only the current session's completed discovery and is withdrawn on reconnect, failure or close. Stdio may then be connected/ready. HTTP is observed connected but readiness stays unknown: completed discovery does not prove native response-stream health.

HTTP DTO sdkResponseStream names retryOwner: 'sdk'; retryNumber, maxRetries, scheduledAt, nextAttemptAt and delayMs are all null. For stdio the field is null. This is ownership metadata, not fabricated canonical retry progress. API 64's shared ConnectionStatus presents observed NEW connect retry progress and invokes existing scoped manual reconnect, without counting down server retry timestamps against the browser's clock. HTTP unknown readiness is not painted ready in cards, details or the rail.

The native response owner is immutable @earendil-works/pi-mcp 1.3.0, not the official MCP SDK. Its public reconnect options receive policy baseDelayMs, maxDelayMs and maxRetries, but received frames reset its consecutive counter and server SSE retry hints override native delay. These mappings do not establish an exact global finite maximum or schedule. Native GET with Last-Event-ID alone resumes responses; accepted tools/call POSTs are never replayed.

This is a bounded native exception: synthetic response exhaustion is an ordinary McpError(-32603) without typed provenance or public error/close notification. Generic -32603, error prose and private internals cannot establish transport loss. Native response readiness/exhaustion proof remains deferred; canonical HTTP readiness remains unknown, including after that synthetic failure. There is no second native retry counter/loop or dependency patch to disguise this limit.

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/serverSpec.mjs (one transport authority for stored and submitted specifications), 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.

Daemon MCP REST bridge

The authenticated /mcp endpoint exposes one tool, elowen_request. Its request shape is shared with ElowenApi: a method, a daemon-relative path and an optional JSON-object body.

{ "method": "GET", "path": "/plugins/my-plugin/api/status" }

src/mcp/server.ts creates a stateless server and transport for each request. src/mcp/tools.ts owns the single declaration and forwards calls through callElowenApi with the MCP client's bearer token. REST routes retain their own account, Project and plugin authorization; a tool call cannot widen the caller's rights. Tool listing does not load the plugin registry. Plugins expose their existing authenticated API routes through this bridge rather than contributing a second MCP tool registry.

src/shared/elowenRequest.ts owns the common request schema. It accepts GET, HEAD, POST, PUT, PATCH and DELETE; paths are limited to 4096 characters and must resolve on the daemon's own origin. The body, when present, is a JSON object. A call performs one REST request with the existing no-timeout policy and propagates a non-success response as an error. Listing performs no REST request. The external MCP bridge described above remains owned by the bundled mcp plugin.