feat(local-mcp): import local MCP servers into cloud task runs#3231
Open
richardsolomou wants to merge 12 commits into
Open
feat(local-mcp): import local MCP servers into cloud task runs#3231richardsolomou wants to merge 12 commits into
richardsolomou wants to merge 12 commits into
Conversation
|
Merging to
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 |
This was referenced Jul 7, 2026
|
React Doctor found no issues in the changed files. 🎉 Reviewed by React Doctor for commit |
Contributor
|
Reviews (1): Last reviewed commit: "feat(local-mcp): read and classify local..." | Re-trigger Greptile |
…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
cbe1e08 to
4874bad
Compare
…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
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
2 tasks
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
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
…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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
Cloud sandboxes only get the MCP servers baked into
--mcpServersat spawn. The user's~/.claude.jsonservers never reach them, forcing local runs for any task that needs one.Changes
posthog-code-local-mcp-import, and the MCP servers view lists them with their availability in a "Local" rail section.posthog-code-local-mcp-importflag (docs/cloud-mcp-relay.md, now implemented): the sandbox exposes them as loopback streamable-HTTP endpoints (McpRelayServer) and forwards each JSON-RPC request as anmcp_requestevent over the durable stream; the creating desktop executes it against the real server from local config (McpRelayService— env/headers never cross the wire) and answers via anmcp_responsecommand. Only the creating client relays, only for names the run declared; requests are deduped by id and expire after 60s; endpoints answer 503 when the desktop goes quiet.docs/cloud-mcp-import.mdspecs the Django half of the import and header rotation.How did you test this?
pnpm typecheck,pnpm lint,biome lint packages/core(zero restricted imports), and the host-boundary check are green; remaining full-suite failures reproduce on a clean checkout (sandbox environment).Automatic notifications