mcpjam tunnel: expose local MCP servers through the relay from the CLI#2638
Conversation
…servers POST /v1/projects/:projectId/tunnels creates the named server record if missing, mints a relay grant (every mint rotates the secret and revokes the previous grant at the edge), and persists the bearer URL onto the record — converting stdio records to http — so evals/chatboxes can target the tunnel. existed/previousUrl/previousTransportType are derived from a pre-read so callers can warn before clobbering hand-configured servers. The close route revokes the grant and never touches the record. Grant plumbing (validateGrant/fetchRelayGrant/close) is extracted from routes/mcp/tunnels.ts into services/tunnel-grants.ts, shared by both surfaces; web route behavior is unchanged. https://claude.ai/code/session_0188QJZL15MSGhjRU69523cF
PlatformTunnelGrant carries the whole connection credential (bearer URL with ?k=, connectToken, relayWsUrl) plus existed/previous* so surfaces can warn on overwrites. create_tunnel/close_tunnel are non-read operations reusing the shared project resolution; they are exported (and packaging-asserted) but intentionally NOT added to the MCP tool surfaces. https://claude.ai/code/session_0188QJZL15MSGhjRU69523cF
…m tunnel mcpjam tunnel http://localhost:9090/mcp --id my-server [--project ref] mcpjam tunnel --id everything [--project ref] -- npx -y @modelcontextprotocol/server-everything Three pieces per session: - relay-client.ts: CLI-local port of the hosted relay client (third hand-mirrored copy of tunnel-edge/src/protocol.ts; constants and close codes byte-aligned, logger injected). - local-bridge.ts: 127.0.0.1 bridge serving only the tunnel's path scope. HTTP targets get a streaming reverse proxy (prefix stripped, only ?k= dropped, SSE unbuffered); stdio targets get one persistent MCPClientManager child behind a stateless streamable-HTTP facade (initialize answered from the real handshake, notifications -> 202, everything else passed through verbatim, GET -> 405). - tunnel-session.ts: close-code policy. 4000 re-creates the grant (the create route is the rotation path) with a rapid-loop cap; 4001/4002 exit 1 (takeover usually lands as 4002 since mints disconnect at the edge); first Ctrl-C closes the bridge and best-effort revokes the grant, second force-quits. The server record intentionally outlives the session: callers fail fast at the edge until the tunnel is re-run. https://claude.ai/code/session_0188QJZL15MSGhjRU69523cF
Including the deliberate MVP trade-off: the tunnel bearer URL (plaintext ?k= secret) is persisted on the server record, mitigated by rotation on every create and revocation on close. https://claude.ai/code/session_0188QJZL15MSGhjRU69523cF
Re-running a tunnel reuses the slug, so the record's previous URL differs only by the rotated ?k= secret — the normal rotation path, not an overwrite the CLI should warn about. Compare endpoints ignoring the query before including previousUrl. https://claude.ai/code/session_0188QJZL15MSGhjRU69523cF
✅ Snyk checks have passed. No issues have been found so far.
💻 Catch issues earlier using the plugins for VS Code, JetBrains IDEs, Visual Studio, and Eclipse. |
|
Preview deployment for your docs. Learn more about Mintlify Previews.
💡 Tip: Enable Workflows to automatically generate PRs for you. |
Internal previewPreview URL: https://mcp-inspector-pr-2638.up.railway.app |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 52787a696f
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| import { | ||
| closeTunnelOperation, | ||
| createTunnelOperation, | ||
| type CreateTunnelResult, |
There was a problem hiding this comment.
Pin the SDK version that contains the tunnel exports
This new runtime import depends on createTunnelOperation/closeTunnelOperation, but the CLI package still declares @mcpjam/sdk as ^1.16.0 while the pre-change 1.16.0 SDK did not export these symbols. In a published/non-workspace install, npm can satisfy the range with the already-published SDK and the CLI will fail during module initialization before any command runs. Please bump/pin the SDK dependency to a version that includes these exports, or bundle the dependency.
Useful? React with 👍 / 👎.
| await withTimeout( | ||
| this.deps.closeGrant(this.grantResult), | ||
| CLOSE_GRANT_TIMEOUT_MS, |
There was a problem hiding this comment.
Abort the revoke request when the grace period expires
When the close API is slow or unreachable, this races closeGrant against a 5s timer but leaves the underlying PlatformApiClient fetch and its timeout running. After logging that revocation timed out, the pending request/timer can still keep the Node process alive until the global API timeout (30s by default), so the first Ctrl-C path does not actually finish after the advertised grace period. Pass an abort signal into the close operation or otherwise cancel the request when this timeout wins.
Useful? React with 👍 / 👎.
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
WalkthroughThis PR adds end-to-end tunnel support: a relay WebSocket client and binary framing, a local bridge (HTTP proxy + stdio JSON‑RPC facade), a TunnelSession orchestrator (grant mint/rotation, remint policy, shutdown), CLI command wiring and output modes, inspector backend grant services and v1 routes, SDK platform client and operations, OpenAPI updates, and comprehensive tests covering protocol, proxying, session lifecycle, races, and routes. Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 7
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@cli/src/lib/tunnel/local-bridge.ts`:
- Around line 191-200: In handleFacadeJsonRpc, don't treat missing or non-string
method values as notifications; validate that method is a string and only
short-circuit to 202 for explicit notification methods that start with
"notifications/". Replace the current if (!method ||
method.startsWith("notifications/")) branch with a check that: if typeof method
!== "string" then return an invalid-request JSON-RPC error envelope (code
-32600) tied to the incoming id (or null if id is absent), otherwise if
method.startsWith("notifications/") return null (no response). Use the existing
id, method, params variables and produce the proper error structure instead of
silently accepting malformed envelopes.
In `@cli/src/lib/tunnel/tunnel-session.ts`:
- Around line 123-133: On startup failure paths where a grant has already been
created (the block that calls this.deps.createGrant and assigns
this.grantResult), perform a best-effort revoke of that minted grant before
rethrowing: if this.grantResult exists, call and await a revoke method on the
deps (e.g. this.deps.revokeGrant(this.grantResult) or
this.deps.revokeGrant?.(this.grantResult)) inside a try/catch that swallows/logs
any revoke error, then proceed to cleanup and rethrow the original error; apply
the same change to the similar failure block around the second occurrence (lines
216-231) so both paths revoke the latest grant on failure.
In `@cli/tests/tunnel-bridge.test.ts`:
- Around line 100-102: The finally blocks currently call server.close() and
return immediately, causing races; change each to await the server shutdown by
replacing plain server.close() with an awaited close promise—e.g. await new
Promise(resolve => server.close(resolve)) or await events.once(server,
'close')—so the test waits for the 'close' event (apply this change for the
server.close() calls in the finally blocks and mirror the pattern used in the
unreachable-upstream case).
In `@cli/tests/tunnel-session.test.ts`:
- Around line 31-40: The test helper's connect() currently just throws when
behavior.failOnConnect is set, so it never invokes the handshake-race callback;
update connect() (or immediately after throwing) to call the onPermanentFailure
callback with the same reason and a close code (e.g. 1002) to simulate the
permanent-close race path; specifically modify the Mock/Stub class's
constructor/ connect() logic (referencing connect(), onPermanentFailure,
behavior.failOnConnect, and permanentFailure) to invoke onPermanentFailure
(synchronously or via setImmediate/nextTick to model a race) when failOnConnect
is configured so the test actually covers the handshake-race branch.
In `@docs/reference/openapi.json`:
- Around line 2088-2138: The TunnelGrant schema currently omits
connectTokenExpiresAt and secretVersion from its required list; update the
OpenAPI schema so both "connectTokenExpiresAt" and "secretVersion" are included
in the "required" array for the TunnelGrant object (leave their property
definitions unchanged) so generated clients and validations treat them as
mandatory fields.
In `@mcpjam-inspector/server/services/tunnel-grants.ts`:
- Around line 75-82: The error-handling path currently calls response.json()
unguarded which will throw on non-JSON bodies; wrap the JSON parsing in a
try/catch and if parsing fails fall back to response.text() (or a default
message) and include response.status/response.statusText in the thrown Error so
callers see a meaningful tunnel failure; apply the same robust parsing to both
places around validateGrant (the response.json() before validateGrant) and the
similar block (lines 102-109) — mirror the safer approach used in
closeTunnelGrant to avoid SyntaxError leaks.
- Around line 65-73: Multiple Convex HTTP calls (fetchRelayGrant,
fetchRotationGrant, reportTunnelClosure, closeTunnelGrant, fetchProjectServers)
can hang forever; add a shared timeout wrapper function (e.g.,
convexFetchWithTimeout or withTimeoutFetch) in tunnel-grants.ts that uses
AbortController and a configurable TIMEOUT_MS constant, starts a timer that
aborts the fetch and clears the timer on completion, and throws a clear timeout
error; replace direct fetch(...) calls in the listed functions to call this
wrapper (preserving URL, options, method, headers and returning the same
response semantics) and ensure the timer is cleaned up to avoid leaks.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: acedf726-c383-417e-b737-bc04043a4b19
⛔ Files ignored due to path filters (1)
package-lock.jsonis excluded by!**/package-lock.json
📒 Files selected for processing (25)
cli/package.jsoncli/src/commands/tunnel.tscli/src/index.tscli/src/lib/tunnel/local-bridge.tscli/src/lib/tunnel/relay-client.tscli/src/lib/tunnel/tunnel-session.tscli/tests/tunnel-bridge.test.tscli/tests/tunnel-command.test.tscli/tests/tunnel-session.test.tsdocs/cli/overview.mdxdocs/cli/reference.mdxdocs/reference/openapi.jsonmcpjam-inspector/server/routes/mcp/tunnels.tsmcpjam-inspector/server/routes/v1/__tests__/tunnels.test.tsmcpjam-inspector/server/routes/v1/index.tsmcpjam-inspector/server/routes/v1/tunnels.tsmcpjam-inspector/server/services/relay-client.tsmcpjam-inspector/server/services/tunnel-grants.tssdk/package.jsonsdk/src/platform/client.tssdk/src/platform/index.tssdk/src/platform/operations.tssdk/src/platform/types.tssdk/tests/platform/client.test.tssdk/tests/platform/operations.test.ts
- Permanent closes that race a dial are stashed and consumed after the handshake (or flushed once live) instead of being dropped, so a dead relay can never be reported as up regardless of event ordering. - Remints are single-flight: a second 4000 while one rotation is in progress is dropped rather than minting a competing grant, and a stop/fail landing mid-remint can no longer leak a fresh connection. - Ctrl-C grant revocation aborts its in-flight request when the 5s grace period wins (a hung backend kept the process alive until the API's own 30s timeout), and the deadline timer is no longer unref'd so it fires even with no other event-loop handles. - A grant minted for a session that fails during startup or remint is now best-effort revoked instead of leaving its fresh secret live. - The stdio facade answers envelopes without a string method with a JSON-RPC -32600 instead of a silent 202 the caller would hang on. - Add a changeset releasing @mcpjam/sdk and @mcpjam/cli together so the published CLI's dependency range can't resolve to an SDK without the tunnel exports. https://claude.ai/code/session_0188QJZL15MSGhjRU69523cF
All Convex round-trips in the tunnel lifecycle (token mint, rotate, close, project-servers pre-read) now share a 15s abort deadline instead of hanging routes on a wedged backend, and upstream failure bodies that aren't JSON (proxy HTML, empty 5xx) surface as tunnel errors with the status code instead of SyntaxErrors. https://claude.ai/code/session_0188QJZL15MSGhjRU69523cF
Two grant-lifecycle holes from review: - CLI: a stop() that completes while a remint's mint is still in flight can have its revocation race AHEAD of the mint at the backend; the session now revokes the fresh grant explicitly once the mint resolves (deterministic ordering), on both the guard and dial-failure paths. - v1 route: when servers:updateServer fails after a successful mint, the grant the caller never received is now best-effort revoked before the error surfaces, instead of leaving a live secret with a stale record URL. https://claude.ai/code/session_0188QJZL15MSGhjRU69523cF
There was a problem hiding this comment.
🧹 Nitpick comments (1)
mcpjam-inspector/server/services/tunnel-grants.ts (1)
36-57: ⚡ Quick winGuard against caller-provided signals being silently overwritten.
Line 46 spreads
initthen overwritessignalwith the timeout controller's signal. If a future caller passesinit.signal, it will be silently ignored. Consider validatinginit.signalis absent or merging both signals to honor both the timeout and the caller's cancellation intent.One approach: validate init.signal is absent
export async function convexFetch( url: string, init: RequestInit = {} ): Promise<Response> { + if (init.signal) { + throw new Error("convexFetch does not support caller-provided signals; use the built-in timeout."); + } const controller = new AbortController(); const timer = setTimeout( () => controller.abort(), CONVEX_FETCH_TIMEOUT_MS );🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@mcpjam-inspector/server/services/tunnel-grants.ts` around lines 36 - 57, The convexFetch function currently overwrites any caller-provided init.signal; update it to preserve caller cancellation by detecting if init.signal exists and wiring it into the timeout controller instead of silently replacing it: create a composite AbortController (e.g., timeoutController) and if init.signal is present, add a listener so when init.signal aborts it calls timeoutController.abort(), then use timeoutController.signal in the fetch call; ensure you also forward timeoutController.abort() to respect the timeout and remove any added event listeners in the finally block to avoid leaks (refer to convexFetch, controller/timer, and init.signal).
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Nitpick comments:
In `@mcpjam-inspector/server/services/tunnel-grants.ts`:
- Around line 36-57: The convexFetch function currently overwrites any
caller-provided init.signal; update it to preserve caller cancellation by
detecting if init.signal exists and wiring it into the timeout controller
instead of silently replacing it: create a composite AbortController (e.g.,
timeoutController) and if init.signal is present, add a listener so when
init.signal aborts it calls timeoutController.abort(), then use
timeoutController.signal in the fetch call; ensure you also forward
timeoutController.abort() to respect the timeout and remove any added event
listeners in the finally block to avoid leaks (refer to convexFetch,
controller/timer, and init.signal).
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: ef6c9eb2-b280-415b-89ad-f145b00b3d29
📒 Files selected for processing (8)
.changeset/cli-tunnel-command.mdcli/src/commands/tunnel.tscli/src/lib/tunnel/local-bridge.tscli/src/lib/tunnel/tunnel-session.tscli/tests/tunnel-bridge.test.tscli/tests/tunnel-session.test.tsmcpjam-inspector/server/routes/v1/tunnels.tsmcpjam-inspector/server/services/tunnel-grants.ts
✅ Files skipped from review due to trivial changes (1)
- .changeset/cli-tunnel-command.md
🚧 Files skipped from review as they are similar to previous changes (5)
- mcpjam-inspector/server/routes/v1/tunnels.ts
- cli/src/commands/tunnel.ts
- cli/tests/tunnel-bridge.test.ts
- cli/src/lib/tunnel/local-bridge.ts
- cli/src/lib/tunnel/tunnel-session.ts
The shared deadline owns the signal slot; a caller-provided one was silently clobbered. Fail loudly until a caller actually needs both (then merge with AbortSignal.any). https://claude.ai/code/session_0188QJZL15MSGhjRU69523cF
Signal handlers were attached only after session.start() resolved, so an interrupt during grant mint / bridge bind / relay connect took the default kill path — no graceful stop, and a possibly-minted grant left live on the server record. Handlers now attach before start; a stop() landing mid-startup aborts the bring-up (revoking a grant whose mint resolved after stop's own revocation ran empty), and the command races startup against the stop outcome so the interrupt exits promptly instead of waiting out a relay connect timeout. https://claude.ai/code/session_0188QJZL15MSGhjRU69523cF
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 208f2fbfcf
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| createGrant: () => | ||
| createTunnelOperation.execute( | ||
| { project: options.project, name: options.id }, | ||
| { client }, | ||
| ), |
There was a problem hiding this comment.
Abort in-flight grant creation on shutdown
When the user hits Ctrl-C while the initial grant creation (or a remint) is still inside createTunnelOperation.execute, session.stop() can settle the command but this callback has no abort signal to cancel the SDK request. The pending fetch/timer keeps Node alive until the platform timeout, and if it eventually succeeds it can still mint a live tunnel grant after the user asked to stop before the later best-effort revoke runs. Please thread an AbortSignal through createGrant the same way closeGrant is wired so startup/remint requests are canceled on shutdown.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@cli/tests/tunnel-session.test.ts`:
- Around line 407-415: The test must assert that startBridge() was never
invoked: update makeHarness() to record bridge start attempts (e.g., increment a
bridgeStarts counter or push to harness.bridgeStarts when startBridge() is
called) and then add an assertion after resolveGrant!/await assert.rejects that
harness.bridgeStarts === 0 (or assert.equal(harness.bridgeStarts.length, 0) if
using an array) so the test verifies the local bridge was never bound; reference
the existing makeHarness() factory and the startBridge() call-site to add the
counter and assert it here.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: c9873942-124d-46f9-9aa9-5d75551f4810
📒 Files selected for processing (3)
cli/src/commands/tunnel.tscli/src/lib/tunnel/tunnel-session.tscli/tests/tunnel-session.test.ts
🚧 Files skipped from review as they are similar to previous changes (2)
- cli/src/commands/tunnel.ts
- cli/src/lib/tunnel/tunnel-session.ts
stop() now cancels the createTunnel request via an AbortSignal threaded through the session deps into the SDK operation: the pending fetch (and its 30s timeout) no longer outlives the session, and usually no grant gets minted at all. A mint whose response wins the race is still revoked by the existing stopped-guards. Also assert in tests that an interrupted startup never binds the local bridge. https://claude.ai/code/session_0188QJZL15MSGhjRU69523cF
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 847cfa5a9c
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| await convex.mutation("servers:updateServer" as any, { | ||
| serverId, | ||
| url: grant.url, | ||
| transportType: "http", |
There was a problem hiding this comment.
Serialize per-server tunnel mint/update
When two POST /v1/projects/:projectId/tunnels calls for the same server overlap, the later fetchRelayGrant revokes the earlier grant at the edge, but the earlier request can still reach this updateServer afterward and persist its now-revoked grant.url. That leaves the project server pointing evals/chatboxes at a dead bearer URL even though another CLI session may have connected successfully; the existing web tunnel route serializes this lifecycle with withTunnelLock because grant minting and URL persistence are a multi-step critical section.
Useful? React with 👍 / 👎.
Two overlapping creates for the same server could interleave so the losing request's updateServer landed last, persisting a URL whose secret the winning mint had already revoked at the edge — pointing evals and chatboxes at a dead bearer URL while the winner's session was live. Wrap mint + persist (and close) in the same per-server withTunnelLock the web tunnel routes use, with a test pinning the no-interleave ordering. https://claude.ai/code/session_0188QJZL15MSGhjRU69523cF
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 2750062. Configure here.
connectToServer can throw after spawning the child (e.g. a handshake timeout); disconnect the manager before propagating, matching the existing listen-failure cleanup. https://claude.ai/code/session_0188QJZL15MSGhjRU69523cF
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |

MCPJam Tunnel — CLI MVP
Adds
mcpjam tunnelso a developer can expose a local MCP server (HTTP URL or stdio command — parity with the web UI) and get a public relay URL, with the tunnel registered as a first-classserversrecord in their MCPJam project so evals and chatboxes can target it.Built entirely on the existing Railway relay: zero tunnel-edge changes, zero mcpjam-backend changes (contracts verified against the backend repo before implementation). When the CLI exits, the server record stays and calls fail fast at the edge; re-running revives the same slug with a fresh secret.
What's in here
Hosted inspector v1 surface
POST /v1/projects/:projectId/tunnels— creates the named server record if missing, mints a relay grant (every mint rotates the secret and revokes the previous grant at the edge), and persists the bearer URL onto the record — converting name-colliding stdio records totransportType: "http"so the platform actually uses the URL.existed/previousUrl/previousTransportTypeare derived from a pre-read so the CLI can warn before clobbering hand-configured servers (same-tunnel re-runs where only the?k=secret rotated don't warn). The response whitelists fields — the upstream grant'ssecret/secretHashnever pass through.POST /v1/projects/:projectId/tunnels/:serverId/close— revokes the grant; never touches the server record.routes/mcp/tunnels.tsintoservices/tunnel-grants.ts, shared by both surfaces (web route behavior unchanged).SDK —
createTunnel/closeTunnelclient methods pluscreate_tunnel/close_tunneloperations (non-read, shared project resolution). Exported and packaging-asserted, but deliberately not added to the MCP tool surfaces.CLI — three pieces per session:
lib/tunnel/relay-client.ts: CLI-local port of the hosted relay client (third hand-mirrored copy oftunnel-edge/src/protocol.ts; constants/close codes byte-aligned, banner names all copies).lib/tunnel/local-bridge.ts: 127.0.0.1 bridge serving only the tunnel's path scope. HTTP targets get a streaming reverse proxy (prefix stripped, only?k=dropped, SSE unbuffered). stdio targets get one persistentMCPClientManagerchild behind a stateless streamable-HTTP facade — initialize answered from the real handshake, notifications → 202 (required by TS-SDK clients), everything else passed through verbatim, GET → 405.lib/tunnel/tunnel-session.ts: close-code policy. 4000 re-creates the grant (the create route is the rotation path) with a rapid-loop cap; 4001/4002 exit 1 (takeover usually surfaces as 4002 since mints disconnect at the edge before the new socket registers); first Ctrl-C closes the bridge and best-effort revokes the grant, second force-quits.The tunnel bearer URL — plaintext
?k=secret included — is persisted toservers.url. Until now the backend stored only the secret hash and the hosted inspector's memory was the single holder of the plaintext URL. This is what lets evals/chatboxes target the tunnel, and it's the same trust level as user-pasted auth headers, but it is a changed invariant. Mitigations: every create rotates the secret and revokes the old grant; close revokes on demand. Documented in the OpenAPI description; follow-up should move this to the secrets path.Testing
browser_unavailablein computer-use/harness tests;useLocationRouter errors inApp.hosted-oauth.test.tsxfrom the Home-landing change at base) — no client code is touched here.Needs live credentials (not runnable in the dev container)
initialize+tools/listthrough the public URLsk_API key (delegated JWT) andmcpjam login(OAuth)Non-goals (MVP)
SSE/notification passthrough for stdio targets; legacy-SSE HTTP targets (edge path-scopes to
/api/mcp/adapter-http/<serverId>, andendpointevents inside response bodies can't be rewritten); status webhooks; secret-less records; multi-tunnel invocations; stdio child auto-restart; exposing tunnel ops on MCP tool surfaces.https://claude.ai/code/session_0188QJZL15MSGhjRU69523cF
Generated by Claude Code
Note
High Risk
Persists plaintext tunnel secrets on server records, overwrites/conflicts with existing server configs, and introduces long-lived relay/WebSocket infrastructure with complex failure/remint semantics—security- and data-handling sensitive despite mitigations (rotation, revoke on close).
Overview
Adds end-to-end MCPJam relay tunnels so developers can expose a local MCP server (HTTP URL or stdio command) through a public URL and have it registered as a project server for evals and chatboxes.
The SDK gains
PlatformApiClient.createTunnel/closeTunnelandcreate_tunnel/close_tunnelplatform operations. The CLI addsmcpjam tunnelwith a local bridge (HTTP reverse proxy or stdio streamable-HTTP facade), a WebSocket relay client, and session orchestration (grant mint/revoke, remint on expired tokens, graceful Ctrl-C). The hosted inspector adds v1POST .../tunnelsandPOST .../tunnels/:serverId/close, refactors grant HTTP into sharedtunnel-grants.ts, and persists the tunnel bearer URL (including plaintext?k=) onservers.url—a deliberate MVP trade-off so the platform can call the tunnel like any remote server. OpenAPI and CLI docs are updated; extensive tests cover v1 routes, bridge behavior, and session policy.Reviewed by Cursor Bugbot for commit 49868fa. Bugbot is set up for automated code reviews on this repo. Configure here.