Skip to content

feat(local-mcp): import and relay local MCP servers into cloud runs#3231

Open
richardsolomou wants to merge 25 commits into
mainfrom
posthog-code/local-mcp-classification
Open

feat(local-mcp): import and relay local MCP servers into cloud runs#3231
richardsolomou wants to merge 25 commits into
mainfrom
posthog-code/local-mcp-classification

Conversation

@richardsolomou

@richardsolomou richardsolomou commented Jul 7, 2026

Copy link
Copy Markdown
Member

Problem

Cloud sandboxes only get the MCP servers baked into --mcpServers at spawn; the user's ~/.claude.json servers never reach them, forcing local runs for any task that needs one. Backend half: posthog/posthog#68954.

Changes

  • Local servers are read and classified (importable / requires your machine / built into cloud runs / unsupported); the MCP servers view shows availability in a "Local" rail.
  • Desktop-only servers (stdio / private URL) relay into cloud runs (docs/cloud-mcp-relay.md): the sandbox exposes loopback streamable-HTTP endpoints and forwards JSON-RPC over the durable stream; the creating desktop executes against the real server, so env and headers never leave the machine.
  • Authorization boundary: only the creating client answers, only for names the run declared; requests dedupe by id, expire after 60s, and a run's designation evicts at terminal status.
  • Relayed tools/calls are approved on the desktop before executing, since they run with the user's local privileges. The desktop is the enforcement point (a compromised sandbox can't be trusted to prompt) and it's adapter-neutral (codex has no per-MCP-call approval hook). A harness that also prompts (claude always-ask) isn't double-prompted: that answer routes through sendCommand into a consume-once pass or run-scoped always-allow for the matching call.
  • Importable (public-URL) servers go into the sandbox config for claude, but relay for codex runs (partitionLocalMcpServersForRun) since codex hard-fails on unreachable servers and the loopback relay always answers its probe. Desktop-only servers relay for every adapter.
  • Private-URL heuristics err toward private (the reverse ships auth headers to an unreachable sandbox server) and share the SSRF range tables with the web-fetch check in @posthog/shared.
  • Task creation blocks with a toast until classification finishes loading, so a fast submit right after opening cloud mode cannot drop the run's local servers.

How did you test this?

  • Vitest across classification, the codex/claude adapter partition, relay correlation/liveness/caps, the mcp_request/mcp_response authorization boundary, and the desktop approval gate (allow, allow-always, deny-with-feedback, expiry, and the harness-prompt-consumes-pass path).
  • End to end against feat(tasks): import and relay client MCP servers into cloud task runs posthog#68954 with the Docker sandbox provider: relayed a local stdio MCP server into a cloud run and got a real tool response back through the desktop.
  • pnpm typecheck, pnpm lint, biome lint packages/core (zero restricted imports), and the host-boundary check are green.

Automatic notifications

  • Publish to changelog?
  • Alert Sales and Marketing teams?

Created with PostHog Code

@trunk-io

trunk-io Bot commented Jul 7, 2026

Copy link
Copy Markdown

Merging to main in this repository is managed by Trunk.

  • To merge this pull request, check the box to the left or comment /trunk merge below.

After your PR is submitted to the merge queue, this comment will be automatically updated with its status. If the PR fails, failure details will also be posted here

@github-actions

github-actions Bot commented Jul 7, 2026

Copy link
Copy Markdown

React Doctor found no issues in the changed files. 🎉

Reviewed by React Doctor for commit a919b70.

@greptile-apps

greptile-apps Bot commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

Reviews (1): Last reviewed commit: "feat(local-mcp): read and classify local..." | Re-trigger Greptile

Comment thread packages/core/src/local-mcp/localMcpImport.test.ts
@richardsolomou richardsolomou changed the title feat(local-mcp): read and classify local MCP servers for cloud availability feat(local-mcp): bring local MCP servers to cloud task runs (import + relay design) Jul 7, 2026
@richardsolomou richardsolomou changed the title feat(local-mcp): bring local MCP servers to cloud task runs (import + relay design) feat(local-mcp): import local MCP servers into cloud task runs Jul 7, 2026
@richardsolomou richardsolomou force-pushed the posthog-code/local-mcp-classification branch from cbe1e08 to 4874bad Compare July 7, 2026 13:58
richardsolomou added a commit to PostHog/posthog that referenced this pull request Jul 7, 2026
Backend half of PostHog/code#3231 (importing the user's local url-based
MCP servers into cloud task runs).

- Both run-creation endpoints (POST .../run/ and .../runs/) accept an
  optional write-only imported_mcp_servers list: {type: http|sse, name,
  url, headers: [{name, value}]}, already in the agent server's
  --mcpServers entry shape. Validation: <=20 servers, names <=64 chars,
  unique, "posthog" reserved, header values <=4KB, whole field <=32KB,
  and a server-side is_url_allowed SSRF re-check (the client's
  public/private classification is UX, not a security boundary).
- Stored in a new encrypted TaskRun.imported_mcp_servers column
  (EncryptedJSONStringField) rather than the plain state JSON, since
  header values carry credentials; never returned by the API.
- start_agent_server merges the stored entries into --mcpServers after
  the PostHog MCP and MCP Store installations (existing names win).
  Claude-only for now: codex-acp hard-fails on unreachable servers and
  the sandbox does no reachability pruning.

Generated-By: PostHog Code
Task-Id: 7c87c3a4-0be6-475e-a8ec-269140ded301
richardsolomou added a commit to PostHog/posthog that referenced this pull request Jul 8, 2026
Backend half of PostHog/code#3231 (importing the user's local url-based
MCP servers into cloud task runs).

- Both run-creation endpoints (POST .../run/ and .../runs/) accept an
  optional write-only imported_mcp_servers list: {type: http|sse, name,
  url, headers: [{name, value}]}, already in the agent server's
  --mcpServers entry shape. Validation: <=20 servers, names <=64 chars,
  unique, "posthog" reserved, header values <=4KB, whole field <=32KB,
  and a server-side is_url_allowed SSRF re-check (the client's
  public/private classification is UX, not a security boundary).
- Stored in a new encrypted TaskRun.imported_mcp_servers column
  (EncryptedJSONStringField) rather than the plain state JSON, since
  header values carry credentials; never returned by the API.
- start_agent_server merges the stored entries into --mcpServers after
  the PostHog MCP and MCP Store installations (existing names win).
  Claude-only for now: codex-acp hard-fails on unreachable servers and
  the sandbox does no reachability pruning.

Generated-By: PostHog Code
Task-Id: 7c87c3a4-0be6-475e-a8ec-269140ded301
…bility

First slice of bringing local MCP servers to cloud task runs.

- Extract loadUserClaudeJsonMcpServerEntries from the Claude adapter's
  mcp-config loader (scope-tagged entries, shared parser) and export it
  from @posthog/agent.
- New workspace-server LocalMcpService: reads ~/.claude.json (user +
  project scope), normalizes each server to a LocalMcpServerDescriptor,
  and drops stdio env values at the boundary. Exposed via a new
  host-router localMcp.list procedure.
- New core LocalMcpImportService: classifies each server as importable
  (public url), requires_desktop (stdio, or localhost/RFC1918/CGNAT/
  link-local/ULA/.local/.internal/.ts.net/etc. private URL), or
  unsupported — with the sandbox-shaped remote config attached for
  importable ones.
- Desktop DI wiring: main container loads the workspace-server module;
  renderer binds the workspace-client slice and loads the core module.

Vitest coverage for the loader extraction, the descriptor
normalization, and the public/private URL heuristics.

Generated-By: PostHog Code
Task-Id: 7c87c3a4-0be6-475e-a8ec-269140ded301
…gn doc

Consolidates the rest of the local-MCP-to-cloud work into this PR:

- Cloud run creation payload gains imported_mcp_servers (behind the
  posthog-code-local-mcp-import flag): the user's importable local
  servers, in the shape the sandbox agent server's
  remoteMcpServerSchema accepts. Threaded TaskInput ->
  useTaskCreation -> prepareTaskInput -> TaskCreationSaga ->
  createTaskRun. The backend ignores the field until the Django half
  (specced in docs/cloud-mcp-import.md) lands.
- Cloud composer shows a LocalMcpServersButton listing local servers
  annotated "Available in cloud" / "Requires your machine" / "Not
  available in cloud".
- docs/cloud-mcp-import.md: Django-side spec (validation, secret
  storage, --mcpServers merge, codex reachability caveat) and header
  rotation design via the existing refresh_session command.
- docs/cloud-mcp-relay.md: Part 2 design (stdio + private-URL servers
  relayed to the desktop over the durable event stream, mirroring the
  question-relay pattern); implementation gated on review, behind
  posthog-code-mcp-relay.

Generated-By: PostHog Code
Task-Id: 7c87c3a4-0be6-475e-a8ec-269140ded301
…servers

Cleanups from a /simplify pass:

- Move the McpServerConfig -> LocalMcpTransport normalization into the
  agent's mcp-config.ts beside the parser; the public export now
  returns the stable @posthog/shared descriptor type instead of
  leaking the Claude SDK config shape, and the workspace-server
  service becomes a one-line delegate. Drops the duplicate
  ClaudeJsonMcpScope type in favor of LocalMcpServerScope.
- Slim LocalMcpCloudClassification to what consumers read: drop the
  derivable transportType and the never-rendered scope; collapse the
  two identical invalid_url branches into a parseHttpUrl helper.
- useTaskCreation now self-fetches importable servers (matching the
  additionalDirectories pattern) instead of TaskInput threading them
  through props — ChannelHomeComposer cloud tasks now get them too.
  The hook moves to features/local-mcp/, returns a stable empty-array
  identity, and loses its unreachable service guard.

Generated-By: PostHog Code
Task-Id: 7c87c3a4-0be6-475e-a8ec-269140ded301
Address review feedback: every entry in PRIVATE_HOST_SUFFIXES now has a
covering row in the isPrivateHostname table — removing .home from the
constant no longer passes silently.

Generated-By: PostHog Code
Task-Id: 7c87c3a4-0be6-475e-a8ec-269140ded301
…vers view

The composer plug button was easy to miss and added toolbar clutter;
the MCP servers view is where users already reason about their servers.
The installed rail gains a "Local" section listing ~/.claude.json
servers with their cloud availability, filtered by the same search box.
Import behaviour at task creation is unchanged (useTaskCreation still
self-fetches); only the visibility surface moved.

Generated-By: PostHog Code
Task-Id: 7c87c3a4-0be6-475e-a8ec-269140ded301
A local server named "posthog" (mcp.posthog.com is public, so it
classifies importable) would go into the run-creation payload, and the
API rejects the whole run with a 400 because the sandbox already
provides a server under that name. Classify reserved names
(case-insensitively, mirroring the server check) as a new "built_in"
availability with no remote config, shown as "Built into cloud runs".

Generated-By: PostHog Code
Task-Id: 7c87c3a4-0be6-475e-a8ec-269140ded301
Part 2 of docs/cloud-mcp-relay.md, behind posthog-code-mcp-relay:
stdio and private-URL servers (classified requires_desktop) become
usable from cloud sandboxes by relaying MCP JSON-RPC over the durable
Django-brokered channel to the creating desktop, which executes each
request against the real server from local config.

- Sandbox: McpRelayServer starts loopback streamable-HTTP endpoints per
  designated server (per-run bearer, 64KB request cap, notification
  fire-and-forget, 503 after 2min without desktop activity), correlates
  mcp_request events to mcp_response commands by requestId, mirroring
  the permission relay. New --relayMcpServers flag (names only).
- Desktop: workspace-server McpRelayService owns one real connection
  per (run, server) resolved by name from raw ~/.claude.json entries
  (env/headers never cross the wire), verbatim JSON-RPC with id
  remapping, 256KB response cap, lazy respawn after crashes.
- Core: CloudTaskService intercepts mcp_request stream events, enforces
  in-memory designation (only the creating client, only declared
  names), dedupes requestIds, drops expired requests, executes through
  a host-bound executor seam, replies via the command path, and closes
  a run's connections when its watcher stops.
- Security posture: tools on relayed servers always ask, regardless of
  permission mode — server-level needs_approval default in the claude
  adapter plus a force-relay/deny gate in the cloud permission handler.
- Creation: relayed_mcp_servers (names only) threaded through
  taskInput → saga → api-client; rail label flips to "Relayed via your
  machine" when the flag is on.

Tests: 11 sandbox correlation/liveness cases, 10 desktop executor
cases, full core suite green.

Generated-By: PostHog Code
Task-Id: 7c87c3a4-0be6-475e-a8ec-269140ded301
Import and relay ship as one feature, so posthog-code-mcp-relay is
gone: everything rides posthog-code-local-mcp-import. The relayed-names
list derives from the same flag-gated server list the import uses, so
the extra flag check was already redundant, and the rail's
requires_desktop label becomes "Relayed via your machine"
unconditionally.

Generated-By: PostHog Code
Task-Id: 7c87c3a4-0be6-475e-a8ec-269140ded301
The relay endpoint gated on a desktopSeenAt timestamp bumped only by
direct SSE attaches and command POSTs to the sandbox. But in the
durable-ingest topology the desktop reads the run's stream through the
agent-proxy and never connects to the sandbox, so desktopSeenAt stays
null and every relay request 503s — the agent drops the relayed server
at session start and never reaches it.

Use the same hasReachableClient signal the permission relay uses (a
direct SSE viewer OR an active durable event stream). Relay endpoints
only exist when a desktop designated servers at creation, so this is
the correct gate. Added debug logs on the 503 and mcp_request paths for
observability.

Generated-By: PostHog Code
Task-Id: 7c87c3a4-0be6-475e-a8ec-269140ded301
An MCP client connects to each server once at session start. That
handshake fires in the ~2s window before any client attaches to the
run (the event relay/desktop connects a beat after the sandbox boots),
so every relay endpoint 503'd and the client dropped the server for the
whole run — intermittently, depending on whether its retry happened to
land after the client attached. That's the "works once, then not"
flakiness.

Only 503 once a client has been reachable and then went away (a genuine
mid-run desktop disconnect). Before the first client ever attaches,
buffer the request — broadcastEvent already buffers-and-replays events
until an SSE controller attaches, so it resolves within the timeout
once the client is there.

Generated-By: PostHog Code
Task-Id: 7c87c3a4-0be6-475e-a8ec-269140ded301
A relayed server's real connection can be a slow-to-spawn stdio
process on the user's machine. Relaying the MCP `initialize` handshake
made connection establishment wait on a cross-machine round trip plus
that spawn, blowing the client's init timeout so the server was dropped
for the whole run ("no slack tool"). As a pass-through proxy the relay
now answers `initialize` (and the paired `initialized` notification)
locally — advertising tools capability and echoing the client's
protocol version — so the connection is instant; `tools/list` and tool
calls still relay for real with their generous per-request timeouts.

Also log received `mcp_response` commands in the sandbox so the
desktop's response behavior is diagnosable from the readable side, not
just the (often invisible) Electron main-process logs.

Generated-By: PostHog Code
Task-Id: 7c87c3a4-0be6-475e-a8ec-269140ded301
…ng it

Answering initialize locally regressed the relay: desktop main.log
showed servers connecting and executing with the relayed handshake, but
stopped connecting once the synthetic-initialize build shipped (the
streamable-HTTP session the client sets up on initialize wasn't honored
by a locally-fabricated response, so tools/list never followed). Revert
to relaying initialize through to the real server, keeping the liveness
and startup-buffering fixes that made the round trip work end to end.

Generated-By: PostHog Code
Task-Id: 7c87c3a4-0be6-475e-a8ec-269140ded301
…ment

broadcastEvent silently returned when this.session was still null, but the
MCP relay server starts listening (and the client subprocess can hit it with
its initialize handshake) before newSession() resolves and assigns
this.session. The earliest relay request for every configured server -
almost always the only initialize attempt an MCP client makes - was lost at
the source instead of reaching the desktop.
Import and relay classification now runs unconditionally instead of behind
a feature flag.
designateRelayedMcpServers/handleMcpRelayRequest, the requestId dedupe, and
the expiry check had no coverage, so a regression in the per-run relay
authorization check would have shipped silently. Added tests driving the
real watch()/SSE entrypoint for: undesignated server, duplicate requestId,
expired request, and fire-and-forget execution.

Also fixed relayDesignations leaking one entry per relayed run for the life
of the app session (never deleted); it now evicts once a run reaches a
terminal status. Cleaned up stopWatcher's redundant double map lookup and
named the dedupe LRU's magic 1000 cap.
getMcpToolApprovalState's always-ask branch and the permission handler's
relay-or-deny branch for relayed-server tools had no coverage — a regression
letting a relayed tool auto-approve without a reachable client would have
passed the whole suite. Added tests for both: desktop-connected relays,
durable-stream-only counts as reachable, background mode denies, no
reachable client denies rather than auto-approves, non-relayed servers are
unaffected.

Extracted the reachability check (session.hasDesktopConnected or an active
event stream) into one hasReachableClient() method — it was duplicated three
times with the same explanatory comment.
useTaskCreation read useLocalMcpCloudServers's data at submit time without
checking it had resolved. Submitting a cloud task before the (async,
~/.claude.json-backed) query settled silently shipped importedMcpServers and
relayedMcpServers empty, dropping the user's local servers with no error.

useLocalMcpCloudServers now returns { servers, isLoading } instead of a bare
array; isLoading tracks the query's own isLoading (false while disabled, so
it never blocks a caller that isn't waiting on it). useTaskCreation blocks
submit with a toast while loading; McpServersView takes the servers field
unchanged.
… sanitizeHeaders

The desktop relay executor only treated payload.id === undefined as a
fire-and-forget notification, but the sandbox's McpRelayServer treats
undefined or null as a notification. An id: null message ran as a real
request against the user's local server and its response was rejected as
an orphaned mcp_response. Aligned both checks.

Also dropped sanitizeStringRecord, a byte-for-byte copy of mcp-config.ts's
sanitizeHeaders that this file already imports from — exported and reused it
instead of maintaining two copies of the same header/env sanitizer.
isPrivateHostname (core) and web-fetch's isBlockedHost (harness) each
hand-rolled the same RFC1918/link-local/CGNAT/loopback IPv4 octet-range
table. core can't import harness or node:net, so lifted the pure
octet-range predicate into @posthog/shared and had both callers use it
instead of maintaining two copies of SSRF-adjacent range logic.

isPrivateHostname now also treats 0.0.0.0/8 and 198.18.0.0/15 as private,
picking up isBlockedHost's slightly wider range set — consistent with its
own "err toward private" design.
@richardsolomou richardsolomou force-pushed the posthog-code/local-mcp-classification branch from 857f5c0 to 363bd17 Compare July 9, 2026 23:48
richardsolomou added a commit to PostHog/posthog that referenced this pull request Jul 9, 2026
Backend half of PostHog/code#3231 (importing the user's local url-based
MCP servers into cloud task runs).

- Both run-creation endpoints (POST .../run/ and .../runs/) accept an
  optional write-only imported_mcp_servers list: {type: http|sse, name,
  url, headers: [{name, value}]}, already in the agent server's
  --mcpServers entry shape. Validation: <=20 servers, names <=64 chars,
  unique, "posthog" reserved, header values <=4KB, whole field <=32KB,
  and a server-side is_url_allowed SSRF re-check (the client's
  public/private classification is UX, not a security boundary).
- Stored in a new encrypted TaskRun.imported_mcp_servers column
  (EncryptedJSONStringField) rather than the plain state JSON, since
  header values carry credentials; never returned by the API.
- start_agent_server merges the stored entries into --mcpServers after
  the PostHog MCP and MCP Store installations (existing names win).
  Claude-only for now: codex-acp hard-fails on unreachable servers and
  the sandbox does no reachability pruning.

Generated-By: PostHog Code
Task-Id: 7c87c3a4-0be6-475e-a8ec-269140ded301
richardsolomou and others added 3 commits July 10, 2026 03:12
The relay always-ask permission gate keyed off Claude's `_meta.claudeCode.toolName`, which codex never writes — codex populates the neutral `_meta.posthog` channel. For codex the gate silently no-op'd, so in non-asking permission modes a relayed tool could auto-run on the user's machine without a prompt. Read the server through the adapter-neutral `readMcpToolDescriptor` (with the Claude `rawInput.toolName` fallback) so the gate fires for both adapters.

Also re-append the loopback relay entries on refresh_session (Django's refresh rebuilds the list without them, whose URL and per-run bearer live only in the agent-server), and cover both fixes plus the codex `_meta.posthog` gate shape with tests.

Generated-By: PostHog Code
Task-Id: 7c87c3a4-0be6-475e-a8ec-269140ded301
Address the review-swarm reuse findings.

- Extract `parseClaudeJsonTransport` in mcp-config.ts so the relay's SDK transport builder and the host-agnostic `LocalMcpTransport` descriptor read a raw ~/.claude.json entry through one normalizer (carrying the stdio `env` the relay needs but the descriptor drops), instead of duplicating the branching and drifting on legacy bare-url/type-less entries.
- Move the IPv6-literal private-address kernel into `@posthog/shared` (`isPrivateIpv6Literal`), shared by `isPrivateHostname` and web-fetch's `isBlockedHost`. This also fixes a latent gap: `isPrivateHostname` only decoded the dotted `::ffff:1.2.3.4` form, so an IPv4-mapped loopback in the hex-group form `URL#hostname` normalizes to (`::ffff:7f00:1`) was misclassified as importable.
- Add unit tests for the shared IPv6 kernel (incl. the hex-group mapped case), the `mcp_response` exactly-one-of-payload/error refine, and `relayMcpServerNamesSchema`.

Generated-By: PostHog Code
Task-Id: 7c87c3a4-0be6-475e-a8ec-269140ded301
@richardsolomou richardsolomou requested a review from a team July 10, 2026 08:04
@richardsolomou richardsolomou self-assigned this Jul 10, 2026
@richardsolomou richardsolomou changed the title feat(local-mcp): import local MCP servers into cloud task runs feat(local-mcp): import and relay local MCP servers into cloud runs Jul 10, 2026
A relayed tools/call executes on the user's machine with their privileges, so
CloudTaskService now refuses to execute one until the user approves it —
enforced at the desktop, the real trust boundary, rather than trusting the
sandbox's own prompt (a compromised sandbox could emit mcp_request events
without asking) and adapter-neutrally (codex has no per-MCP-call approval
hook, so a harness-side gate would leave GPT runs unguarded).

The claude adapter still prompts via its always-ask path; to avoid a double
prompt, the user's answer to that sandbox prompt is routed through sendCommand
and converted into a consume-once pass (or run-scoped always-allow) for the
matching relayed call. Denials return a JSON-RPC error with the user's
feedback instead of executing. Approval state is evicted on terminal status.
Codex hard-fails a session on any unreachable MCP server, so importable
(public-URL) servers can't go into the sandbox config for GPT runs the way
they do for claude. partitionLocalMcpServersForRun routes them through the
relay instead when the run's adapter is codex: the loopback relay endpoint
always answers codex's reachability probe and the desktop executes the server
from local config, public URLs included. Desktop-only servers still relay for
every adapter. Relayed list is capped at 20 (matching the backend), keeping
desktop-only servers ahead of importables when the cap bites.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants