HOMELAB-1769: feat(mcp-bridge): version-control the LD1.1-validated pi<->sandbox MCP seam #50
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "plane/HOMELAB-1769-mcp-bridge"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
What
Lands the pi MCP-bridge extension in
Homelab/pi-extensionsas the newmcp-bridge/subdirectory. The extension exposes an MCP server's tools as native typed pi tools (mcp_<name>); against the in-cluster agent-sandbox MCP server that is the 8-tool sandbox lifecycle:create_sandbox,get_sandbox_status,execute_command,upload_file,download_file,list_files,file_exists,delete_sandbox.The source is a byte-identical copy of the live install at
~/.pi/agent/extensions/pi-mcp-bridge/(index.ts,schema.ts,package.json,tsconfig.json,package-lock.json), verified withdiffagainst the spike snapshot. No refactor, no behaviour change, nonode_modulescommitted.Why
The bridge is the durable seam validated by the LD1.1 verdict. It currently lives only under
~/.pi— functional but unversioned, unreviewable, and not fleet-distributable. Per the source-of-truth stack it belongs in Forgejo with pinned deps, house docs, CI, and the normal review/landing flow.Spike evidence
.rpiv/artifacts/research/homelab-1669/bridge-proof-2026-09-30/in thehomelabrepo:summary.md(verdictBRIDGE-WORKS),lifecycle-transcript.jsonl,lifecycle-report.md,lifecycle-stderr.log,tools-list-raw.json. Result: 8 tools registered, 11mcp_*calls, 0 failures, 0 schema rejections, 0 leftover claims, ~2m15s. Design:.rpiv/artifacts/research/homelab-1747/pi-mcp-bridge-design.md.Files
mcp-bridge/index.ts,schema.ts,package.json,tsconfig.json,package-lock.json@modelcontextprotocol/sdk1.31.0mcp-bridge/README.mddocs/mcp-bridge.mdREADME.md.forgejo/workflows/ci.yamlmcp-bridgejob (npm ci+npm run typecheck)The four server-side normalisations (documented, not code changes)
These are agent-sandbox MCP server behaviours, not bridge behaviour — the bridge forwards arguments verbatim and rewrites nothing. They are documented because the server's own tool descriptions mislead on all four (spike
spike-run-2026-09-30/summary.md§3.1–3.4):SANDBOX_BASE_DIR(/workspace). Upstreamlstrip("/")s then rejoins, so/workspace/xlands at/workspace/workspace/x. All four file tools agree with each other whileexecute_commandcannot see the file — a silent-failure trap.list_filesroot ispath="".path="."resolves to/list/..and fails.execute_commandis argv-only (shlex.split+shell=False): no pipes/;/&&/globs. Shell syntax needs an explicitsh -c '…'. Two distinct timeouts are documented: the tool'stimeout(default 60 s, server-side) vs the bridge's MCP request timeout (default 600 000 ms).SandboxNotFoundstatus:Sandbox claim '…' is not found in namespace '…'. The bridge surfaces it as a thrown pi tool error, which is the correct seam — callers must match the text.Ownership, timeout, reconnect (all re-verified against the source)
ensure_session_ownsserver-side); a session can only delete what it created; there is no admin/reaper tool, so an orphan lives until itsshutdown_after_secondsTTL. Therefore one client per pi process: created insession_start, closed insession_shutdown.PI_MCP_BRIDGE_TIMEOUT_MSdefaults to 600 000 ms and is passed toclient.callToolper call (re-read at call time, unlike URL/token which are read at connect time) because a cold-pool claim can block for minutes and the SDK default is 60 s.ensureConnection()is single-flight and only opens a client when none is held. The documented hazard holds: a reconnect is a new MCP session and orphans everything the previous session owned until TTL.bridge-proof-2026-09-30/summary.md§3.5 claims the lazy reconnect also covers "port-forward started after pi". It does not — if the server is unreachable atsession_start,registerMcpToolsnever runs, so nomcp_*tools exist in that session and there is nothing to call lazily. The code's lazy reconnect only covers a client that died after registration. The README documents the code's actual behaviour; the spike summary is the stale document. The bridge also does not emit a dedicated reconnect warning — each open logsconnected to <url> (session <8 hex>…), so a second such line is the tell that prior claims are now orphans.list_files403 disposition (minor finding)With no sandbox claimed in the session,
list_filesreturnsisError403: the upstream path resolves the session's sandbox by a cluster-scopesandboxclaimsLIST while the MCP service account holds a namespaced Role (infra-core/core/manifests/agent-sandbox-mcp/rbac.yaml, deliberately not a ClusterRole — see its own rationale comment). In-session calls never hit it; the lifecycle run did not.Disposition: documented in
mcp-bridge/README.mdunder "Known limitations", no RBAC or manifest change in this PR. Widening the Role is a cluster-config decision owned bysandbox-provisioning-contracts.md§5 in theinfra-corerepo, and that file explicitly warns against widening without such a decision. A follow-up ticket for the RBAC/upstream-path decision is the right home for a fix.Verify
npm ciinmcp-bridge/(97 packages, pinned lockfile) andnpm run typecheck/npx tsc --noEmitclean locally.AGENT_SANDBOX_MCP_URL=http://127.0.0.1:19999/mcp pi -p --no-session -ne -e ./mcp-bridge/index.ts …produced[pi-mcp-bridge] connect failed for http://127.0.0.1:19999/mcp: fetch failedand the model still answered — i.e. the copy resolves./schema.tsand@modelcontextprotocol/sdkfrom its ownnode_modulesand degrades gracefully, as documented.git clone/symlink into~/.pi/agent/extensions) and re-run the LD1.1 lifecycle proof against the agent-sandbox MCP server (create → status → argv exec →sh -cexec → upload/download hash agreement → list/exists → delete → gone-signal), asserting 0 leftover claims.mcp-bridgejob (npm ci+ typecheck) plus PR-title gate.Out of scope / follow-ups
schema.tssanitizer, which the pinned server never even exercises (its schemas are flat). A vitest suite for the sanitizer + a fake MCP transport would be a worthwhile follow-up ticket, not this landing.list_files403 RBAC decision (above), and the design-noted lazymcp_connectactivation if the bridge ever ships always-on (prompt-bloat risk #4).Reviewer note: this is a version-control landing of already-proved code, so the review gold is in the README's claims — every claim in it was checked against
index.ts/schema.tsand the spike evidence.Landing review — 2 fresh-context reviewers
Reviewers ran as independent
pi -p --no-sessionsubprocesses (omniroute/deepseek-flash), no implementer context. Briefs built from primary sources only:git diff origin/main...origin/plane/HOMELAB-1769-mcp-bridge@32f2bba, the HOMELAB-1769 acceptance criteria, repo conventions (root README, sibling extension shape,.forgejo/workflows/ci.yaml), the design doc, and the spike evidence.index.ts/schema.ts) · security · lockfileVerdicts
Independently confirmed on the positive side
sha256sum+diff -r):index.ts,schema.ts,package.json,tsconfig.json,package-lock.jsonare byte-identical to~/.pi/agent/extensions/pi-mcp-bridge/and to the spike snapshot.node_modules/is absent and untracked.registry.npmjs.orgwithsha512-integrity, 0file:/git+/link:deps, 0hasInstallScript, root deps matchpackage.jsonexactly.npm ci→ 97 packages,npm run typecheck→ exit 0 (run in a temp copy).docs/mcp-bridge.md(112 lines) matches the sibling docs-page shape; the repo-docs pages have no frontmatter convention, so its absence is correct.BLOCKER (B) / CONCERN (A) — same finding
mcp-bridge/README.md"Reconnect semantics", troubleshooting row;docs/mcp-bridge.mdGotchasensureConnection()returns the heldconnectionunconditionally (index.ts:142);connectionis assigned once (:149) and cleared only insession_shutdown(:247);executenever resets it. A client that dies mid-session is therefore never reopened —callTooljust throws. So these claims are false: "the reconnect path triggers when the held client dies mid-session", "a secondconnected toline … is the signal that the session id changed", and the troubleshooting row "MCP bridge is not connected…= the client is gone and reopening it failed" (that branch is effectively unreachable after registration). The PR body's "honest deviation" only half-corrects the spike: neither the "port-forward started after pi" case nor the "client died after registration" case is covered.Adjudication: the code matches the design intent (
pi-mcp-bridge-design.md:287— "hold one client for the process lifetime, never per-call reconnect"), so the README is the stale document. Fix is docs-only — code stays byte-identical to the LD1.1-validated install. A docs correction is in flight on this branch.CONCERNs (posted, merging allowed)
index.ts:42-45,244-249connection/shuttingDownare process-global, butsession_shutdownis per-session. pi-swarm-suite spawns in-process childAgentSessions, so a child's shutdown closes the parent's only MCP client and latchesshuttingDown=true, breaking allmcp_*calls for the parent. Siblings gate this —pi-otel-telemetry/index.ts:504,981explicitly handles "module shared by every in-process session". Code fix required → follow-up ticket (not this landing).schema.ts(no test file)schema.tsis the package's only pure logic and has zero coverage, while every gated sibling ships vitest. Follow-up ticket.index.ts:117new URL(url)sits outside thetry(:121), so a malformedAGENT_SANDBOX_MCP_URLrejectssession_startinstead of logging the documented non-fatal warning. Doc now states the actual behaviour; code fix → follow-up.index.ts:197-204details.rawBlocks/structuredContentare returned untruncated whilecontentis capped at 200 000 chars — a chatty server can still push unbounded data into session history. Documented; optional follow-up.Suggestions (documented, not blocking)
$defs/definitionsstripped at every depth (a property literally nameddefinitionsis dropped);normalizeToolNamedoesn't lowercase so CamelCase tools are silently skipped (README only mentions hyphens); localanyshim instead ofimport type { ExtensionAPI }weakens the CI typecheck; shutdown-during-connect leaks the client;module: NodeNext+./schema.tsspecifier diverges from the houseBundlershape;resolveRefonly handles$defs/definitions-rooted pointers despite its comment; unboundederrorText(); no explicit connect timeout; install path~/.pi/agent/extensions/mcp-bridgevs the livepi-mcp-bridgename.Merged —
ec706fbBlocker resolved before merge; docs-only fix pushed as
1901c59and CI re-run green.1901c59(pre-merge) — 8/8 green.ec706fb(post-mergemainpush) — re-validates the merged tree;pr-titlecorrectly skipped on push.plane/HOMELAB-1769-mcp-bridgekept (repo convention: mergedplane/*branches stay on origin).mcp-bridge/index.tssha2560898b214…is identical onorigin/mainand in the live install at~/.pi/agent/extensions/pi-mcp-bridge/.What the fix commit changed
mcp-bridge/README.md+docs/mcp-bridge.mdonly (46+/30−): the "Reconnect semantics" section, the two troubleshooting rows, the "Divergence from the spike write-up" paragraph, the config "read timing" bullet, and the docs-page Gotchas now state that the bridge never reopens a dead client — a dead client surfaces as a transport/SDK error, recovery is a manual pi restart//reload(which is a new MCP session and orphans the previous session's sandboxes until TTL), and theconnected toline appears once, atsession_start. Two additional behaviours are now documented in "Known limitations": a malformedAGENT_SANDBOX_MCP_URLthrows outside the connecttry/catch, and truncation coverscontentbut notdetails.Follow-ups filed (the concerns/suggestions that are not docs)
AgentSessions currently close the parent's client and latchshuttingDownnew URL()outside the try/catch, no connect timeout, untruncateddetails, unboundederrorText(),anyAPI shimschema.ts+ two demonstrable bugs (definitionsproperty dropped at any depth; CamelCase tool names silently skipped)list_files403 RBAC decision (namespaced Role vs cluster-scope LIST)GitOps continuation: none applicable — this repo has no downstream build/rollout pipeline. The live install at
~/.pi/agent/extensions/pi-mcp-bridge/remains the operational copy and the merged tree is the version-controlled source of truth; no symlink swap or reinstall was performed (operator choice).