Skip to content
This repository was archived by the owner on Aug 6, 2026. It is now read-only.

Commit cbe1e08

Browse files
feat(local-mcp): cloud-run import wiring, composer UX, and relay design 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
1 parent 3a4f1b5 commit cbe1e08

12 files changed

Lines changed: 586 additions & 2 deletions

File tree

‎docs/cloud-mcp-import.md‎

Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
# Importing local MCP servers into cloud task runs
2+
3+
Status: client side implemented behind the `posthog-code-local-mcp-import`
4+
feature flag. The Django side (spec below) is not implemented; until it lands,
5+
the backend ignores the extra creation-payload field and cloud runs behave as
6+
before.
7+
8+
## Problem
9+
10+
A local task run gets all of the user's MCP servers: the PostHog MCP plus
11+
`MCPServerInstallation` records (built by `AgentAuthAdapter.buildMcpServers` in
12+
`packages/workspace-server/src/services/agent/auth-adapter.ts`), and — for the
13+
Claude adapter — the user's own servers from `~/.claude.json`
14+
(`loadUserClaudeJsonMcpServers` in
15+
`packages/agent/src/adapters/claude/session/mcp-config.ts`).
16+
17+
A cloud run's sandbox only gets what the backend bakes into the agent server's
18+
`--mcpServers` flag at spawn (`remoteMcpServerSchema` in
19+
`packages/agent/src/server/schemas.ts`: `http`/`sse` + `url` + `headers`). The
20+
sandbox never reads `~/.claude.json`, so tasks that need the user's own MCP
21+
servers (Grafana, Sentry, internal tools, ...) force the user back to local
22+
runs.
23+
24+
This document covers **import**: forwarding url-based servers that are
25+
reachable from the public internet. Servers that are not importable (stdio, or
26+
private-network URLs) need the desktop **relay** — see
27+
[cloud-mcp-relay.md](./cloud-mcp-relay.md).
28+
29+
## What the client does
30+
31+
1. **Read** — `LocalMcpService`
32+
(`packages/workspace-server/src/services/local-mcp/local-mcp.ts`) reads
33+
`~/.claude.json` through `loadUserClaudeJsonMcpServerEntries` (extracted
34+
from the Claude adapter's loader so both share one parser) and normalizes
35+
each entry to a `LocalMcpServerDescriptor` (`@posthog/shared`). stdio `env`
36+
values are dropped at this boundary — they routinely hold secrets the
37+
renderer has no use for.
38+
39+
2. **Classify** — `LocalMcpImportService`
40+
(`packages/core/src/local-mcp/localMcpImport.ts`) classifies each server:
41+
42+
| Server | Availability | Why |
43+
| --- | --- | --- |
44+
| `http`/`sse` with a public URL | `importable` | The sandbox can reach it directly. |
45+
| `http`/`sse` on localhost, RFC1918, CGNAT (100.64/10, incl. Tailscale IPs), link-local, IPv6 ULA, `.local`/`.internal`/`.lan`/`.home`/`.home.arpa`/`.ts.net`, or a dotless intranet name | `requires_desktop` | Only reachable from the user's machine or network. |
46+
| `stdio` | `requires_desktop` | A local process; nothing to forward. |
47+
| Unparseable URL / non-http(s) scheme / unrecognized shape | `unsupported` | Can't run anywhere. |
48+
49+
The heuristic errs toward private: a public server misclassified as private
50+
just stays desktop-only, while the reverse would ship an unreachable server
51+
(or leak headers) to the sandbox.
52+
53+
3. **Show** — the cloud task composer renders `LocalMcpServersButton`
54+
(`packages/ui/src/features/task-detail/components/LocalMcpServersButton.tsx`),
55+
a list of the user's servers annotated "Available in cloud" / "Requires
56+
your machine" / "Not available in cloud".
57+
58+
4. **Send** — importable servers are included in the run-creation payload
59+
(`imported_mcp_servers` in `buildCloudRunRequestBody`,
60+
`packages/api-client/src/posthog-client.ts`) in exactly the shape the agent
61+
server's `remoteMcpServerSchema` accepts, so the backend can pass them
62+
through to `--mcpServers` without transformation.
63+
64+
Project-scoped (`projects[cwd].mcpServers`) entries are currently only picked
65+
up when a `cwd` is passed; cloud task creation selects a GitHub repository
66+
rather than a local checkout, so cloud runs import user-scoped servers only.
67+
Mapping repository → local checkout to include project-scoped servers is a
68+
follow-up.
69+
70+
## Wire format
71+
72+
`POST /api/projects/{project_id}/tasks/{task_id}/runs/` gains one optional
73+
field:
74+
75+
```json
76+
{
77+
"imported_mcp_servers": [
78+
{
79+
"type": "http",
80+
"name": "grafana",
81+
"url": "https://mcp.grafana.example.com/mcp",
82+
"headers": [{ "name": "Authorization", "value": "Bearer ..." }]
83+
}
84+
]
85+
}
86+
```
87+
88+
`type` is `"http" | "sse"`. `headers` may be empty.
89+
90+
## Django-side spec (not implemented in this repo)
91+
92+
The `posthog/posthog` repo owns run creation and sandbox provisioning. To
93+
support the field:
94+
95+
**Validation** (reject the run creation with 400 on violation):
96+
97+
- ≤ 20 servers; `name` non-empty, ≤ 64 chars, unique within the list.
98+
- `url` must parse, scheme `http`/`https`, host must not be loopback /
99+
RFC1918 / link-local / CGNAT / IPv6 ULA (re-validate server-side; the
100+
client's classification is a UX aid, not a security boundary). This matters
101+
because the sandbox egresses from PostHog infrastructure: a private URL
102+
here is a user-controlled SSRF vector against whatever the sandbox network
103+
can reach.
104+
- Each header value ≤ 4 KB; whole field ≤ 32 KB serialized.
105+
- Names must not collide with the reserved `posthog` server or with the names
106+
of the project's `MCPServerInstallation`-derived servers; on collision the
107+
imported server is dropped (installations win) and the run is still created.
108+
109+
**Storage**: header values are credentials (`Authorization: Bearer ...`).
110+
Store them like other run secrets — encrypted at rest, write-only (never
111+
echoed back from the run detail API), and excluded from logs/analytics.
112+
113+
**Spawn**: append the validated list to the `--mcpServers` array after the
114+
PostHog MCP and installation-derived servers. No other transformation — the
115+
payload shape is already `remoteMcpServerSchema`.
116+
117+
**Adapter caveat**: codex-acp hard-fails on unreachable MCP servers, which is
118+
why the desktop prunes them for local Codex sessions
119+
(`filterReachableMcpServers` in
120+
`packages/workspace-server/src/services/agent/agent.ts`). The sandbox agent
121+
server does no such pruning. Either restrict `imported_mcp_servers` to
122+
`runtime_adapter == "claude"` initially, or add an equivalent reachability
123+
probe to the sandbox before session start for Codex runs.
124+
125+
## Auth: header staleness and rotation
126+
127+
Headers are captured at launch. For servers whose tokens expire mid-run, the
128+
rotation mechanism already exists on the sandbox side: the `refresh_session`
129+
command (`refreshSessionParamsSchema`,
130+
`packages/agent/src/server/schemas.ts`) pushes a fresh `mcpServers` list into
131+
a running session, and the Claude adapter tears down and rebuilds the query
132+
with the new list (`refreshSession` in
133+
`packages/agent/src/adapters/claude/claude-agent.ts`).
134+
135+
What's missing is the client half — today the desktop never sends
136+
`refresh_session` for cloud runs (`sendCommandInput` in
137+
`packages/core/src/cloud-task/schemas.ts` stops at `set_config_option`).
138+
Design:
139+
140+
1. Add `refresh_session` to the core cloud-task command schema and a
141+
`CloudTaskService` method that posts it through the existing
142+
`/runs/{run}/command/` endpoint with the full replacement `mcpServers`
143+
list (the agent server treats the list as authoritative; an empty list is
144+
a no-op by design, so "remove every imported server" cannot be expressed —
145+
acceptable for rotation).
146+
2. Django: allow `refresh_session` through the command endpoint's method
147+
allowlist, apply the same validation as `imported_mcp_servers`, forward
148+
verbatim, and do not persist the params (they contain fresh credentials).
149+
3. Desktop trigger: re-read `~/.claude.json` when it changes (the
150+
workspace-server already has file watchers) and push the updated list to
151+
active cloud runs.
152+
153+
**Why this ships as a documented follow-up rather than code**: static headers
154+
in `~/.claude.json` carry no expiry metadata, and OAuth-backed servers
155+
managed by Claude Code keep their tokens in Claude's credential store — not
156+
in `mcpServers.headers` — so those servers aren't importable this way at all
157+
(they surface as headerless imports that 401 in the sandbox; the relay in
158+
[cloud-mcp-relay.md](./cloud-mcp-relay.md) covers them properly). For the
159+
static-header servers we can import, there is nothing to watch except the
160+
file itself, which is the trigger described above.
161+
162+
## Follow-ups
163+
164+
- Codex local config (`~/.codex/config.toml` `mcp_servers`) as a second
165+
source; needs a TOML parser, skipped for now.
166+
- Project-scoped `~/.claude.json` servers for cloud runs (repository → local
167+
checkout mapping).
168+
- `${VAR}` environment-variable expansion in header values (Claude Code
169+
expands these at session start; the import currently forwards them
170+
literally, so such servers will 401 until expanded).
171+
- The `refresh_session` client path described above.

0 commit comments

Comments
 (0)