A manager on the p2 server owns every MCP connection and process. Session workers hold one view each and lease the locations their conversations work at; the manager runs the owner's mcp.json servers there (stdio per session and location, or shared; HTTP shared by default) and offers their tools as deferred Durable tools found through tool_search. Stdio servers on an environment start through its pi-env daemon, one connection per environment shared by its instances, through cmd.exe on Windows. A project's .pi/mcp.json, read by the worker at each location, may add stdio servers that run there and switch the owner's servers on or off; the manager refuses everything else. A lease gets servers only on environments its session works on.
41 KiB
MCP for Durable and p2: work plan
This file is the brief for agents implementing MCP (Model Context Protocol) support for p2, the Durable-based app in
apps/p2. Every work package (WP) below is self-contained: it says where the work happens, what to build, how to test
it, and when it is done. Read sections 1 to 4 before any work package, then only the package you are given.
The human-readable plan with background lives at https://radius.earendil.com/artifact/01m6em2r8gercbx2rn2y8mwwpq (revision 4). Where it and this file differ, this file wins.
1. Repository and rules
Everything is in earendil-works/pi (local checkout ~/Development/pi-mono, branch main): the packages under
packages/ (env, mcp, durable, coding-agent, chord, ...) and p2 at apps/p2. Until p2 moved there (commit
05dc8d239), it lived in the private repository earendil-works/pi-private, which is no longer used.
Rules (the repository's AGENTS.md overrides anything here):
- Packages and p2 change together. A fix to
packages/envorpackages/mcpfound during p2 work is part of the same tree; it still gets its own commit and changelog entry. - Several agents may work in the same checkout. Stage explicit paths only; never
git add -A,git stash,git reset --hardorgit checkout .. - After code changes:
npm run checkfrom the repository root (formatting, lint, types), and after p2 changes alsocd apps/p2 && npm run check(p2 is outside the root check). It must be clean except for errors you can show are unrelated and pre-existing. If it fails on missing modules or stale generated model data, runnpm install --ignore-scriptsandnpm run hydrate:model-datafirst (this does not change tracked files). - Tests: run the package's own tests, never the whole suite. From the package directory:
node "$(git rev-parse --show-toplevel)/node_modules/vitest/dist/cli.js" --run test/<file>.test.ts. p2's tests usenode --test(seeapps/p2/package.json). - The pi-env daemon is Rust:
cd packages/env/daemon && cargo fmt --check && cargo clippy --locked --all-targets -- -D warnings && cargo test --locked. Also lint for Windows:cargo clippy --locked --all-targets --target x86_64-pc-windows-msvc -- -D warnings. Rebuild the debug daemon (npm run build:daemoninpackages/env) before running pi-env's TypeScript tests. - Public packages get a
CHANGELOG.mdentry under## [Unreleased](### Added,### Changed,### Fixed). - Commit only when asked, one commit per work package, message
feat(<scope>): .../fix(<scope>): .... - No
any, no inline imports, erasable TypeScript only, no runtime checks of values TypeScript already guarantees; validate only at trust boundaries (wire input, configuration files, data from another process).
2. What we are building
p2 gets MCP servers. The design, in short:
- One manager on the p2 server owns every MCP connection and process. Session workers never spawn an MCP server or hold a credential. They talk to the manager through one Chord object per worker principal.
- Placement. A stdio server runs on the p2 host or on an execution environment from p2's
envs.json(an SSH machine with the pi-env daemon). On an environment, the manager starts it through the daemon'sprocessoperation (WP1, done). - Instances. Stdio servers run once per session and location by default;
"shared": truein the owner's configuration makes one process for everybody. Streamable HTTP servers are shared by default. - Configuration. The owner's
<P2_DATA_DIR>/mcp.json(trusted) plus a project's<cwd>/.pi/mcp.json, which the worker reads at its location and sends to the manager (untrusted, restricted, see WP7). - Tools. Workers turn the servers' tools into Durable tools named
mcp__<server>__<tool>, deferred (found throughtool_search) or callable only from code mode. - OAuth. Only HTTP servers use MCP OAuth. Sign-in links point at p2 (
/mcp/sign-in/<id>); when one is opened, p2 picks the redirect from the request's principal: loopback (http://127.0.0.1:<port>/callback) for a browser on the p2 host, p2's/mcp/callbackon the Radius or public origin otherwise, and pasting the final redirect URL always works.
worker host (session s1) server host
┌──────────────────────────────────┐ ┌──────────────────────────────────────────────────────┐
│ MCP worker object │ hold as │ p2.mcp: remote view, one per principal │
│ · reads .pi/mcp.json per ├─────────►│ worker variant ──┐ │
│ location via its ExecutionEnv │ worker │ ▼ │
│ · contributes Durable tools │ {s1} │ p2.mcp.manager: local, host lifetime │
└──────────────────────────────────┘ │ instances, leases, OAuth, pi-env connections │
│ owner variant ◄── web panel, TUI │
└───────┬──────────────────┬──────────────────┬────────┘
stdio on the p2 host stdio on env "gpu" Streamable HTTP
3. Status and order
| WP | Where | What | State | Depends on |
|---|---|---|---|---|
| 1 | packages/env |
daemon process operation, openProcess() |
done: 9c9ce8547, fix 032656b6c |
none |
| 2 | packages/mcp |
transport over a byte pipe | done: c158fa2ee |
none |
| 3 | packages/mcp, packages/coding-agent |
OAuth sign-in split into start/complete, moved out of coding-agent | done: bb266bf35, fix 797e122cb |
none |
| 4 | packages/mcp, packages/coding-agent |
shared naming and config helpers (optional) | done: 76c040a2a |
3 |
| 5 | p2 | gate: p2's Chord v2 port has landed; WP1-3 present | done: p2 imported at 05dc8d239, deps in "feat(p2): MCP servers on the host, on environments and from projects" |
1-3, the port |
| 6 | apps/p2/plugins/mcp |
manager, view, worker object; owner config; host stdio and HTTP | done: "feat(p2): MCP servers on the host, on environments and from projects" | 2, 5 |
| 7 | apps/p2/plugins/mcp |
environment placement; project mcp.json |
done: "feat(p2): MCP servers on the host, on environments and from projects" | 1, 6 |
| 8 | apps/p2/plugins/mcp |
OAuth: flows, routes, loopback, pasting; web and TUI | open | 3, 6 |
| 9 | apps/p2/plugins/mcp, plugins/codemode |
code mode namespaces, progress, first-turn admission | open | 6 |
Section 4.5 lists where the built APIs of WP2-4 differ from sections 5-7, section 4.6 where WP6-7 differ from sections 9-10.
4. Facts every package relies on
4.1 WP1 (done): the daemon's process operation
In packages/env (commit 9c9ce8547):
- Daemon:
packages/env/daemon/src/process.rs; protocol inpackages/env/docs/protocol.md(section onprocess). - Client:
packages/env/src/process.ts, exported from@earendil-works/pi-env:
export function openProcess(connection: Connection, options: ProcessOptions): Promise<RemoteProcess>;
export interface ProcessOptions {
cwd: string;
argv: readonly string[]; // run directly, no shell
env?: Record<string, string>; // added to the daemon's environment
inheritEnv?: boolean; // false: env is the whole environment
onStdout: (bytes: Uint8Array) => void;
onStderr?: (bytes: Uint8Array) => void;
}
export class RemoteProcess {
readonly pid: number;
readonly exited: Promise<number>; // exit code; rejects (code "aborted") after close(), or on a lost connection
write(data: Uint8Array | string): void; // stdin, in order; dropped once stdin is closed or the program ended
closeStdin(): void; // after the input written before it
close(): void; // kills the process group
}
- A daemon released before WP1 fails
openProcesswithRemoteErrorcodenot_supported(hello.featureslacks"process"). - A lost connection kills the program (the daemon kills everything it started).
- When the program exits on its own, the daemon kills what it left running in its process group (Unix) or its job
object (Windows) before reporting the exit (
7367bc823). Known gap on Windows: a child started before the daemon assigns the program to its job can escape (documented inpackages/env/docs/protocol.md). - Windows: like
execargv commands, batch files (npx.cmd,uvx.cmd) are refused; callers must start them throughcmd.exe(WP7).
4.2 Durable pieces (public, packages/durable)
ToolRegistration(packages/durable/src/harness/types.ts):name,description,parameters(JSON Schema object),deferred?: true,callers?: ("model" | "tools")[],replay?: "safe" | "unsafe"(default unsafe),structuredOutputSchema?,outputLimits?,execute(args, api, context)returningToolExecutionResult(output,structuredOutput,isError,details).api.conversationIdandapi.envidentify the calling conversation and its execution environment.createToolSearchExtension()from@earendil-works/pi-durable/tool-search(packages/durable/src/tool-search/tool-search.ts): thetool_searchtool that loads deferred tools.createCodemodeExtension({ sandbox, namespaces })from@earendil-works/pi-durable/codemode;namespacesare{ name, description?, instructions?, prefix }(packages/durable/src/codemode/describe.ts).- A tool call whose tool is not registered right now waits until it is (
packages/durable/src/harness/agent.ts,addTools), so a reconnecting MCP server does not fail a replayed call.
4.3 pi-mcp today (public, packages/mcp)
McpClient(src/client.ts), transports insrc/transports/(stdio.ts,streamable-http.ts,in-memory.ts, theMcpTransportinterface andTransportEventsintransport.ts),toLlmContent()(src/protocol/content.ts).- OAuth in
src/oauth/:McpOAuthProviderandMcpOAuthStateStore(provider.ts; the state persistscodeVerifierandoauthState, so a sign-in can complete in another request with the same store),authorizeMcp(flow.ts),OAuthCallbackServer(callback.ts), discovery and errors. - No dependency on other pi packages, and only
cross-spawnat runtime. Keep it that way.
4.4 p2 today and after the port
- p2 runs on Chord v2; Chord v1 is deleted.
- Chord v2 design:
packages/chord/docs/design.md. The parts MCP uses:localobjects with empty params (host lifetime, §5, §6), shared store with per-principalremoteviews (§6 "Pattern"), nativesself.open(§13), routinesself.on/self.onLive(§14), changing registry contributions withself.writable(§17; the design's own example is MCP tools), flows kept by their owner (§20),ctx.incarnation(§4), Chord calls from Durable code (§24). - p2 code style for Chord v2 code:
AGENTS.md, section "Chord v2 Code Style". Use the names the port introduces (harness extension registry, host addresses, principals, HTTP routing, login flow); do not invent parallel ones. - Existing p2 precedents to copy:
apps/p2/plugins/ports(a local store plus a remote view with owner and worker variants),apps/p2/plugins/locations/server.ts(worker-only methods fenced by the worker's generation),apps/p2/plugins/terminals/main.ts(the server opening its own pi-env connections per environment),apps/p2/plugins/envs(environment configuration, SSH options, host keys),apps/p2/plugins/login(provider OAuth withremote: principal.access !== "local"),apps/p2/plugins/radius/relay-access.ts(requests relayed through Radius),apps/p2/plugins/powershell/worker.ts(a deferred tool offered per conversation),apps/p2/src/runtime/principals.ts(OwnerPrincipal.accessis"local" | "relay" | "device"). After the port these files move or change; find their v2 equivalents.
4.5 As built (WP2-4): differences from sections 5-7
- WP2:
PipeTransport,McpPipe,McpPipeHandlers,PipeTransportOptionsfrom@earendil-works/pi-mcp; pi-env'sRemoteProcessis anMcpPipewithout an adapter. Shared framing insrc/transports/lines.ts. - WP3:
@earendil-works/pi-mcp/oauthexportsstartMcpSignIn,completeMcpSignIn,listenForLoopbackSignIn,waitForLoopbackSignIn,responseFromRedirectUrl,createMcpAuthProvider,McpSignInCancelledError; filessign-in.ts,loopback.ts,auth-provider.ts,client-settings.ts. Differences:storeis anMcpOAuthServerStore(withwithRefreshLock); start and the auth provider take the lock, never call them while holding it.startMcpSignInreturns{ kind: "authorized" }or a plain-dataMcpSignInRedirect(authorizationUrl,redirectUrl,state,scope?, all strings): store it with the flow.completeMcpSignIn({ ...sameOptionsAsStart, authorization, response }): pass start'sredirectUrl(notauthorization.redirectUrl), the kept start result, and a response withstate(checked). A sign-in replaced by a newer one is refused.clientMetadataDocuments: { baseUrl, listsRedirectUrl(url) }; without it, dynamic registration only (p2 remote).- Start refreshes with the stored client first, whatever the redirect URI;
authorizeMcphasrefreshOnly, so a failed refresh never disturbs a pending sign-in (3ffb210d9: the refresh uses the app'sredirectUrlwhen the documents list it; passredirectUrltocreateMcpAuthProvidertoo). - The store's
withRefreshLockthrowsMcpRefreshLockBusyErrorwhen the lock stays held (start then goes to the browser step); other lock errors fail the sign-in. Aborting ends the wait for the lock.
- WP4: from
@earendil-works/pi-mcp:createMcpToolName,mcpNamespace,normalizeMcpInputSchema,limitMcpContent(content, saveOutput?),MCP_OUTPUT_MAX_BYTES,validateMcpServerConfig(name, raw, options?)returning{ kind: "stdio" | "http", config }(unknown fields kept asunknown) or{ kind: "invalid", error },isMcpServerOverride. Apps validate their own fields (p2:shared,runOn, project rules) on the result."cimd"is refused unlesscheckMetadataDocumentCallbackis passed (p2 should not pass it).
4.6 As built (WP6-7): differences from sections 9-10
- Leases, not load/unload: the worker variant has
setLocations({ locations, projects? })(every location the session's conversations work at; leases elsewhere end) andcall({ location, server, tool, args }); the manager hassetLocationsandreleaseLeaseswith the caller's session, generation and incarnation. Worker state:serversanderrors(the session's leases' project refusals). - Project files as text: the worker sends
<cwd>/.pi/mcp.jsonas read ({ location, text }, at most 256 KiB, or{ location, error }when unreadable; absent: none); the manager parses and checks it (project.ts) at each placement. A project override may also turn on an owner server that is"enabled": false. - Where sessions work: a lease gets servers only on an environment its session works on as the server records it
(the catalog's session, the subagent store's own locations). This fences stale or confused workers to their machines,
not a compromised worker (see
McpManager's doc comment). - Environments: connections through the envs plugin's SSH manager (
EnvManager.connection), one per environment and revision; an instance on an environment that is its location's machine gets the location's cwd as its base and root, otherwise the remote home. Windows: thecmd.execommand line goes inP2_MCP_COMMAND(the daemon quotes argv itself and has no verbatim mode). - Not watched: a changed project
mcp.jsonis read again at the worker's nextsetLocations(a commit, a move, the view re-activating), not when the file changes.
5. WP2: pi-mcp transport over a byte pipe
Where: packages/mcp.
Why: p2 runs stdio MCP servers on remote machines through openProcess() (WP1). pi-mcp must not depend on pi-env,
so it gets a transport over an abstract pipe that RemoteProcess satisfies without an adapter class.
Build:
-
src/transports/lines.ts(new): the newline-delimited JSON-RPC framing now insideStdioTransport.handleStdout(src/transports/stdio.ts): accumulate bytes, split at\n, strip a trailing\r, skip blank lines, enforcemaxMessageBytes(defaultDEFAULT_MAX_MESSAGE_BYTES), parse withparseJsonRpcMessage, report errors. Plus the bounded stderr tail (maxStderrBytes, default 64 KiB). One class, used by both transports. -
src/transports/stdio.ts: uselines.ts. Behavior must not change;test/stdio.test.tsmust pass unmodified. -
src/transports/pipe.ts(new), exported fromsrc/index.ts:/** A running program's stdio, as `@earendil-works/pi-env`'s `RemoteProcess` provides it. */ export interface McpPipe { write(data: Uint8Array | string): void; closeStdin(): void; /** Kill the program (and what it started). */ close(): void; /** Settles when the program has ended, whatever the reason. */ readonly exited: Promise<unknown>; } export interface PipeTransportOptions { /** Start the program; `onStdout`/`onStderr` receive its output from then on. */ open(handlers: { onStdout(bytes: Uint8Array): void; onStderr(bytes: Uint8Array): void }): Promise<McpPipe>; maxMessageBytes?: number; maxStderrBytes?: number; onStderr?: (chunk: string) => void; /** After closing stdin, how long the program may take to exit before `close()` kills it. Default 2000. */ closeTimeoutMs?: number; } export class PipeTransport extends TransportEvents implements McpTransport { ... }start()callsopen; a rejection rejectsstart(). Onceexitedsettles, an incomplete trailing message is an error (as inStdioTransport), thenemitClose().send()writesJSON.stringify(message) + "\n"; afterclose()or the program's end it throwsMcpConnectionClosedError.close(): MCP's stdio shutdown:closeStdin(), wait forexitedup tocloseTimeoutMs, thenclose().stderrgetter with the tail, likeStdioTransport.
-
README (
packages/mcp/README.md): a short section with the pi-env example:const transport = new PipeTransport({ open: (handlers) => openProcess(connection, { cwd, argv, ...handlers }), }); -
CHANGELOG.md:### Addedentry.
Tests: test/pipe.test.ts (new):
- with an in-memory fake
McpPipe: framing (split chunks, several messages in one chunk,\r\n, blank lines), the size limit, an incomplete message at exit,sendafter close, the shutdown order (stdin closed first;close()only after the timeout; not at all when the program exits in time),startrejecting whenopenrejects; - end to end: a
McpClientoverPipeTransportwhoseopenspawnstest/fixtures/stdio-server.mjswithnode:child_process(a 20-line adapter in the test) completesinitialize,tools/listandtools/call.
Done when: tests pass, test/stdio.test.ts unchanged and green, npm run check clean, README and changelog
updated. pi-mcp still has no dependency on pi-env.
6. WP3: OAuth sign-in split into start and complete, moved into pi-mcp
Where: packages/mcp/src/oauth (new code) and
packages/coding-agent/src/extensions/mcp (callers).
Why: p2 must run the same sign-in as pi mcp login, but the authorization code reaches p2 later and in another
request (its /mcp/callback route, a pasted URL, or a loopback server it runs). Today
signInMcpServer in packages/coding-agent/src/extensions/mcp/oauth.ts opens its own loopback server and waits.
Read first: packages/coding-agent/src/extensions/mcp/oauth.ts in full, packages/coding-agent/src/core/mcp-servers.ts
(McpOAuthConfig, isLoopbackRedirectUri, isClientMetadataDocumentCallback), its callers
packages/coding-agent/src/extensions/mcp/index.ts (signIn, around runtime.signInMcpServer) and runtime.ts, and
packages/mcp/src/oauth/*.
Move into packages/mcp/src/oauth/sign-in.ts (new; export from src/oauth/index.ts):
Today in coding-agent oauth.ts |
In pi-mcp | Change |
|---|---|---|
McpOAuthSettings, callbackSettings, mergeScopes, timedFetch |
same names | none |
offersClientMetadataDocument, clientMetadataDocument, callbackId |
same | the document base URL (today https://pi.dev/oauth) and the application name (today APP_NAME) become options |
responseFromRedirectUrl |
exported | none |
createMcpAuthProvider |
exported | McpOAuthServerStore.withRefreshLock stays an interface; pi-mcp gets no lockfile dependency |
signInMcpServer |
split | see below |
The split:
/** First half: refresh if possible, else prepare the browser step. */
export async function startMcpSignIn(options: {
serverUrl: string;
store: McpOAuthStateStore;
settings: McpOAuthSettings;
redirectUrl: string; // chosen by the caller: loopback, or an https callback route
clientMetadataDocuments?: { baseUrl: string }; // omitted: dynamic registration only
clientName: string;
challenge?: OAuthChallenge;
signal?: AbortSignal;
}): Promise<{ kind: "authorized" } | { kind: "redirect"; authorizationUrl: URL; redirectUrl: URL; state: string }>;
/** Second half: exchange the code. `response` from a callback server, a route, or `responseFromRedirectUrl`. */
export async function completeMcpSignIn(options: {
serverUrl: string;
store: McpOAuthStateStore;
settings: McpOAuthSettings;
redirectUrl: string; // the one passed to start (the flow may have made it server-specific)
response: { code: string; iss?: string };
clientMetadataDocuments?: { baseUrl: string };
clientName: string;
signal?: AbortSignal;
}): Promise<void>;
- Keep every rule of today's function: a fresh
stateper sign-in; keep or drop the registered client depending on its redirect URI andclientRegistration; step-up scope (stepUpScope,mergeScopes); the server-specific redirect path for Client ID Metadata Documents (/callback/<callbackId>); timeouts throughtimedFetch; cancellation viasignalreported asMcpSignInCancelledError(move that class too). OAuthCallbackServerstays in pi-mcp; the loopback-listening part of today's function (listenForCallback,waitForAuthorizationResponse) becomes a small helper in pi-mcp,waitForLoopbackSignIn(...), or stays in coding-agent if only it uses it. Decide by whether p2 needs it: p2 does (loopback for local browsers), so move it.- coding-agent keeps:
McpOAuthCredentialStore(mcp-auth.json,proper-lockfile,AuthStorageBackend, legacy keys), the callback page HTML,pi mcp login//mcpUI.signInMcpServerin coding-agent becomes a thin wrapper: start, show the URL, wait for loopback or pasted URL, complete.
Tests:
- pi-mcp:
test/sign-in.test.ts(new) with an authorization server built ontest/helpers.ts(listen,readBody,closeServers) the waytest/oauth.test.tsbuilds its servers, or on coding-agent'spackages/coding-agent/test/suite/mcp-oauth-server.tsmoved into pi-mcp's tests: start then complete in two calls with one store; complete with a pasted URL; refresh path returnsauthorized; metadata document chosen only when the server supports it and the redirect is one the documents list; a mismatchedstateor redirect path is rejected; cancellation. - coding-agent: unchanged and green:
test/mcp-oauth-refresh.test.ts,test/suite/agent-session-mcp-oauth.test.ts,test/mcp-command.test.ts,test/mcp-extension.test.ts.
Done when: pi-mcp exports the functions above; coding-agent's behavior is unchanged (its tests pass without
edits beyond imports); both changelogs updated (packages/mcp/CHANGELOG.md Added, packages/coding-agent/CHANGELOG.md
nothing user-visible, so no entry unless behavior changed).
7. WP4 (optional): shared naming and config helpers
Where: packages/mcp (new src/naming.ts, src/config.ts), callers in
packages/coding-agent/src/core/mcp-servers.ts and src/extensions/mcp/tools.ts.
Why: p2 must name tools exactly like pi, so tool_search ranking and code mode namespaces behave the same.
Build:
createMcpToolName(server, tool, isTaken)andmcpNamespace(server)(today in coding-agentextensions/mcp/tools.tsandcore/mcp-servers.ts), the input-schema normalization (object type,propertiespresent) and the result size limit (MCP_OUTPUT_MAX_BYTES,limitMcpContent) without pi-tui rendering.- Parsing and validation of the standard
mcpServersshape: stdio (command,args,env,cwd), HTTP (url,headers),oauth,enabled. pi-only fields (exposure,toolExposure,auth.provider,autoEnableCodemode) stay in coding-agent, which extends the shared parser.
Done when: coding-agent imports these from pi-mcp with no behavior change (its MCP tests pass); pi-mcp tests cover the moved functions.
If skipped, WP6 copies createMcpToolName and mcpNamespace into p2 and notes the duplication.
8. WP5 (gate): p2 on Chord v2, packages present
Not MCP work, done. Before WP6:
- p2 imports Chord v2 (
rg -l "@earendil-works/chord/old" apps/p2finds nothing). - WP1-3 (and 4) are in the tree p2 builds against.
- Add
"@earendil-works/pi-mcp": "<current version>"and"@earendil-works/pi-env"(if p2 does not have it yet) toapps/p2/package.json; refresh the lockfile withnpm install --package-lock-only --ignore-scripts; commit lockfile changes withPI_ALLOW_LOCKFILE_CHANGE=1.
9. WP6: manager, view and worker object (host stdio and HTTP)
Where: apps/p2/plugins/mcp/ (new plugin; register it where the port registers built-in
plugins). Tests in apps/p2/test/p2/mcp*.test.ts.
Files (one concept per file, per "Chord v2 Code Style"):
| File | Content |
|---|---|
contract.ts |
the p2.mcp.manager local contract, the p2.mcp remote contract, schemas (Location, Instance, Tool, ProjectConfig, CallResult, SignInFlow) |
config.ts |
reading and validating <P2_DATA_DIR>/mcp.json (trust boundary), following changes |
instances.ts |
the supervisor native: instances keyed per section 9.2, leases, connect, reconnect with backoff, tool-list changes |
server.ts |
the facet: serves the manager and the view |
worker.ts |
the worker object: holds the view, loads locations, contributes the Durable extension |
tools.ts |
MCP tool → Durable ToolRegistration |
9.1 Contracts
const Location = obj({ env: union([str(), nul()]), cwd: str() }); // p2's location; env null: the p2 host
const Instance = obj({
id: str(),
server: str(),
source: oneOf("owner", "project"),
location: union([Location, nul()]), // null: shared, or HTTP
runsOn: union([literal("host"), obj({ env: str() })]),
status: oneOf("starting", "connected", "sign-in", "failed"),
error: opt(str()),
tools: arr(Tool), // name, title, description, inputSchema, annotations
});
export const McpManager = local("p2.mcp.manager", {
version: 1,
params: {},
members: {
instances: arr(Instance),
load: method(obj({ session: str(), location: Location, project: union([ProjectConfig, nul()]) }), Loaded),
unload: method(obj({ session: str(), location: Location })),
release: method(obj({ session: str() })),
call: method(obj({ session: str(), instance: str(), tool: str(), args: JsonObject }), CallResult),
restart: method(obj({ instance: str() })),
// sign-in members: WP8
},
});
export const Mcp = remote("p2.mcp", {
version: 1,
hostAddress: /* the server, as the port addresses it */,
params: {},
variants: {
worker: {
state: { servers: arr(Instance) },
methods: { load, unload, call }, // session filled in by the view from the principal
},
owner: {
state: { instances: arr(Instance) },
methods: { restart },
},
},
});
- The view's factory returns the worker variant for a worker principal, the owner variant for an owner. Guests and other principals get no access: authority checks live in the view's methods (design §7).
- Worker calls carry the caller's incarnation (
ctx.incarnation); the manager records the incarnation that loaded each lease and refuses calls from an older one. serversis a derive over the manager'sinstancesfiltered to the principal's session plus the shared instances the session uses.
9.2 Instances
| Kind | Default | Key | Notes |
|---|---|---|---|
stdio, shared unset |
per session | (session, location, server, config hash) |
roots: the location's cwd |
stdio, "shared": true |
opt-in, owner config only | (server, runsOn, config hash) |
configured roots; state shared by all sessions |
| Streamable HTTP | shared | (server, config hash) |
"shared": false: one per session, still in the server |
- The configuration hash covers command, args, env, cwd and URL.
- Leases: each view holds leases on the instances it loaded.
unloadreleases one location; when the view ends (its worker's reference went with the link, afterlinger), a native of the view releases all of them throughrelease. Unshared instances close with their last lease; shared instances stay while any lease or owner entry needs them. - Reconnect: a lost connection reconnects with exponential backoff (1 s doubling to 60 s); tools stay listed while
reconnecting (status
starting), calls fail with a clear error. A tool-list-changed notification refreshestools. - Shutdown: closing an instance closes its client; stdio follows MCP's shutdown (stdin closed, then kill), which
StdioTransportandPipeTransportimplement.
9.3 Host stdio and HTTP in this package
- Host stdio:
StdioTransportfrom pi-mcp (runOn: "host", and"location"for local sessions). - HTTP:
StreamableHttpTransportwith headers from the owner config (${VAR}from the p2 host's environment, owner config only). OAuth arrives in WP8; until then an HTTP server that answers 401 shows statussign-in. McpClientoptions:name: "p2", version from p2's package,rootsfrom the instance's location cwd for unshared instances (file://URL), none for shared ones.
9.4 Worker side
- A local, host-lifetime object in the worker holds
Mcp(the server sees it asworker{session}). - At start and after every relocation of the session, and for every subagent with its own location: read
<cwd>/.pi/mcp.jsonthrough that location'sExecutionEnv(readTextFile; absent file:project: null), callload. Callunloadfor locations nothing works at any more. - Contribute one Durable
Extensionthrough the harness extension registry as aself.writable;self.on(view.servers, ...)rebuilds it. Tools:- name via
createMcpToolName(WP4, else copied), description from the MCP tool,parametersnormalized to an object schema; deferred: true(found throughtool_search);execute: locate the calling conversation (api.conversationId, through the harness's environments), pick the instance for that location, callview.callfrom Durable code (design §24's helper), convert the result withtoLlmContent(), passstructuredContentthrough asstructuredOutputonly if the tool declares an output schema,isErrorfrom the MCP result; replay staysunsafe.
- name via
- Register
createToolSearchExtension()(nothing installstool_searchin p2 today).
9.5 Owner configuration (<P2_DATA_DIR>/mcp.json)
{
"mcpServers": {
"filesystem": { "command": "npx", "args": ["-y", "@modelcontextprotocol/server-filesystem", "."] },
"browser": { "command": "npx", "args": ["-y", "@playwright/mcp"], "shared": true, "runOn": "host" },
"tools-on-gpu": { "command": "/opt/tools/mcp", "runOn": { "env": "gpu" } },
"docs": { "url": "https://example.com/mcp", "headers": { "Authorization": "Bearer ${DOCS_TOKEN}" } }
}
}
runOn: "location" (default for unshared stdio: where the conversation works), "host" (default for shared), or
{ "env": "<name in envs.json>" }. Until WP7, "location" on a remote session and { env } show status failed with
"environments arrive in WP7".
Tests (apps/p2/test/p2/mcp.test.ts, node --test), with test/fixtures/stdio-server.mjs-style fixture servers
copied or referenced from pi-mcp:
- owner config loads; invalid entries are reported and skipped;
- a session loads; its worker gets the tools; a call returns the result;
tool_searchfinds the tool; - two sessions get two processes for an unshared server and one for a shared server;
- unload and view end close unshared instances; shared ones survive;
- an older worker incarnation's call is refused;
- a crashed server reconnects with backoff and its tools come back;
- a guest gets nothing.
Done when: the tests pass, npm run check clean, and a manual run (throwaway session under /tmp, separate data
dir and port, per "No hand tests on real sessions" in apps/p2/docs/handoff.md) shows an MCP tool used by the agent.
10. WP7: environment placement and project configuration
Where: apps/p2/plugins/mcp/ (instances.ts, new placement.ts, config.ts).
10.1 Placement on environments
- For
runOn: "location"on a remote session and for{ env }, the manager opens one pi-envConnectionper environment, built from the envs plugin's configuration, SSH options and known hosts, asapps/p2/plugins/terminals/main.tsdoes withsshConnection(...). One connection per environment, shared by all instances there; closed when no instance uses it. - Start the server with
openProcess(connection, { cwd, argv, env, onStdout, onStderr })behind pi-mcp'sPipeTransport(WP2). openProcessfailing withnot_supported: statusfailed, error "the pi-env daemon on is too old; update it" (pi-env deploys its packaged daemon by hash, so a p2 update usually fixes it).- Windows environments (
nodePlatform(await connection.info()).platform === "win32"): resolve the program; if it is not an.exe/.com(for examplenpx, which isnpx.cmd), run it as["cmd.exe", "/d", "/s", "/c", <quoted command line>]. Quote likecross-spawndoes for cmd (escape^&|<>()%!"meta characters). Unit-test the quoting. runOn: "location"on a local session uses the host path (WP6).
10.2 Project configuration
The worker sends <cwd>/.pi/mcp.json with load; the manager validates it at that boundary. Project entries are
untrusted (a repository wrote them, and the agent can edit them). Rules:
| Rule | Why |
|---|---|
stdio runs at its location only; runOn and shared are refused |
the agent can already run commands there; anywhere else a repository would gain code execution on the p2 host or another machine |
no ${VAR} from the p2 host's environment |
a project could otherwise put the host's secrets in a header or argument; variables in env resolve at the location |
no auth.provider, no reuse of another server's OAuth client |
as in pi: a repository does not choose where a credential goes |
an entry without command/url/type only overrides enabled of the owner entry with the same name |
as in pi (packages/coding-agent/src/extensions/mcp/config.ts) |
| HTTP entries: refused in this WP | open decision (section 14); the p2 host would make the requests, so a project could reach addresses only the host can see |
Tests: placement on a local "environment" through the local daemon (localDaemon(), as pi-env's tests do) so no
SSH is needed: a server on an environment starts, its tools appear, calls work, the connection is shared by two
instances, closing the last instance closes the connection; project rules each refuse what they should; Windows
quoting unit tests.
11. WP8: OAuth flows, routes, loopback, pasting; web and TUI
Where: apps/p2/plugins/mcp/ (new sign-in.ts, routes in server.ts, web.tsx,
tui.ts). The web UI is p2 code again (apps/p2/plugins/*/web.tsx, apps/p2/src/web), ported with p2.
Credential store: <P2_DATA_DIR>/mcp-auth.json, private (0600), keyed by server name and URL, with a refresh lock
(proper-lockfile, already a p2 dependency) implementing pi-mcp's withRefreshLock. Tokens never leave the server.
Flow (a Chord flow kept by the manager, design §20: start → id; state with the current step; answer; cancel):
-
An HTTP instance gets
McpOAuthAuthorizationRequiredError→ statussign-in. The tool error tells the model to ask the user to sign in and gives the link from the worker'ssignIn. -
signIn({ instance })(worker and owner variants) starts a flow and returns{ id, link: "/mcp/sign-in/<id>" }. The link is relative, so it opens on the origin the viewer uses. -
Route
GET /mcp/sign-in/<id>(owners only): read the request's principal and choose the redirect:Principal Redirect URI Code arrives through Registration owner,access: "local"http://127.0.0.1:<port>/callback(pi-mcpOAuthCallbackServeron the p2 host)the loopback server pi.dev Client ID Metadata Documents when supported, else dynamic registration owner,access: "relay"https://<cortex origin>/mcp/callback(fromradius/relay-access.ts)route GET /mcp/callbackdynamic registration owner,access: "device"on an allowed public hosthttps://<P2_PUBLIC_URL host>/mcp/callbackroute GET /mcp/callbackdynamic registration Then
startMcpSignIn(WP3) and answer 302 to the authorization URL.authorized(a refresh worked): redirect back to p2 with a success page. -
GET /mcp/callback?code&state&iss(owners only): find the flow bystate,completeMcpSignIn, reconnect the instance, show a success page. The flow is done; its state shows it. -
Pasting: the owner variant's
answer({ id, step, redirectUrl })usesresponseFromRedirectUrlthen completes. The UI always offers it (anssh -Lport forward lookslocalbut the browser is elsewhere).
UI:
- Web (
plugins/mcp/web.tsx): an MCP panel next to Providers: servers with status and errors, tools count, restart, sign in (opens the link), sign out, paste the redirect URL while a flow waits. - TUI (
plugins/mcp/tui.ts): the same as a command/overlay; opening the link uses the launcher's open-URL grant. - Sign-in links in the transcript are ordinary relative links.
Tests: a fake authorization server and MCP HTTP server (the one WP3's tests use): a 401 makes status sign-in;
the sign-in route as a local principal starts a loopback flow; as a relay/device principal it uses /mcp/callback;
the callback completes and the tools appear; a wrong state is refused; pasting completes; guests are refused on both
routes; tokens refresh without a browser. Verify by hand once through Radius that the cortex origin's cookie is sent on
the redirect back (section 14).
12. WP9: code mode namespaces, progress, first-turn admission
Where: apps/p2/plugins/codemode/ (namespaces), apps/p2/plugins/mcp/.
- Namespaces: one
CodemodeNamespaceper server (name= server,prefix=mcpNamespace(server) + "__",descriptionfrom the server'sinstructionsininitialize, if any) fed intocreateCodemodeExtensionfrom theserversstate, through a registry the MCP plugin contributes to or a getter read at use (as codemode already does for its sandbox). - Progress: Chord calls return one result. Default: no MCP progress in the first version. If wanted: the manager
writes progress per call into a state document the worker follows and forwards through Durable's
api.output()ordetails(). - First turn: decide whether p2's admission check waits for MCP servers still
starting(default: wait up to 10 s for instances of the session's location, then go on; deferred tools appear later throughtool_search).
13. Things that are easy to get wrong
- A fix to
packages/envorpackages/mcpfound during p2 work is its own commit, with its package's changelog entry. - Do not run MCP processes in session workers. Everything that spawns, connects or holds a token is in the manager.
- Do not trust project
mcp.json: validate at the manager, apply section 10.2. - Tool names must match pi's (
createMcpToolName), including the hash suffix for long names. - Windows:
npx/uvxare batch files; host stdio usescross-spawnthroughStdioTransport(fine), environments need thecmd.exewrapping (WP7). - The daemon kills a remote server when its connection drops; reconnect logic must expect that and restart it.
- Never print tokens, cookies or sign-in URLs with codes in logs.
14. Open decisions (defaults to use until decided)
| Decision | Default in this plan |
|---|---|
| Per session or per agent | per session and location; one-shot agents share their session's instance |
| Project HTTP servers | refused |
| Remote client registration | dynamic registration; per-brain metadata documents from Radius only if needed later |
| Host stdio transport | StdioTransport (cross-spawn); the local daemon only if a reason appears |
| MCP progress | not in the first version |
| First-turn admission | wait up to 10 s for the session's starting instances |
| Radius cookie on the redirect back | verify by hand in WP8; if it is not sent, relayed browsers fall back to pasting |