|
| 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 MCP servers view rail renders `LocalMcpRailSection` |
| 54 | + (`packages/ui/src/features/local-mcp/LocalMcpRailSection.tsx`), listing the |
| 55 | + user's local servers annotated "Available in cloud" / "Relayed via your |
| 56 | + machine" / "Built into cloud runs" / "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 (resolved for Codex via the relay)**: codex-acp hard-fails a |
| 118 | +session when any configured MCP server is unreachable, and the sandbox agent |
| 119 | +server does no reachability pruning. So imported (direct-URL) servers only go |
| 120 | +into the sandbox config for the Claude adapter — the backend gates |
| 121 | +`get_imported_mcp_server_configs` on `runtime_adapter in {claude, unset}` as |
| 122 | +belt-and-braces. For Codex runs the client instead routes importable servers |
| 123 | +through the **relay** (`partitionLocalMcpServersForRun` in |
| 124 | +`packages/core/src/local-mcp/localMcpImport.ts` puts them in |
| 125 | +`relayed_mcp_servers` when the run's adapter is codex): the loopback relay |
| 126 | +endpoint always answers codex's reachability probe, and the desktop executes |
| 127 | +the server from local config — public-URL servers included. Desktop-only |
| 128 | +servers relay for every adapter. Net effect: a GPT user keeps all their local |
| 129 | +servers, at the cost of one desktop hop per call. `relayed_mcp_servers` is |
| 130 | +capped at 20 (matching the backend), and desktop-only servers are kept ahead |
| 131 | +of importables when the cap bites, since they have no other transport. |
| 132 | + |
| 133 | +## Auth: header staleness and rotation |
| 134 | + |
| 135 | +Headers are captured at launch. For servers whose tokens expire mid-run, the |
| 136 | +rotation mechanism already exists on the sandbox side: the `refresh_session` |
| 137 | +command (`refreshSessionParamsSchema`, |
| 138 | +`packages/agent/src/server/schemas.ts`) pushes a fresh `mcpServers` list into |
| 139 | +a running session, and the Claude adapter tears down and rebuilds the query |
| 140 | +with the new list (`refreshSession` in |
| 141 | +`packages/agent/src/adapters/claude/claude-agent.ts`). |
| 142 | + |
| 143 | +What's missing is the client half — today the desktop never sends |
| 144 | +`refresh_session` for cloud runs (`sendCommandInput` in |
| 145 | +`packages/core/src/cloud-task/schemas.ts` stops at `set_config_option`). |
| 146 | +Design: |
| 147 | + |
| 148 | +1. Add `refresh_session` to the core cloud-task command schema and a |
| 149 | + `CloudTaskService` method that posts it through the existing |
| 150 | + `/runs/{run}/command/` endpoint with the full replacement `mcpServers` |
| 151 | + list (the agent server treats the list as authoritative; an empty list is |
| 152 | + a no-op by design, so "remove every imported server" cannot be expressed — |
| 153 | + acceptable for rotation). |
| 154 | +2. Django: allow `refresh_session` through the command endpoint's method |
| 155 | + allowlist, apply the same validation as `imported_mcp_servers`, forward |
| 156 | + verbatim, and do not persist the params (they contain fresh credentials). |
| 157 | +3. Desktop trigger: re-read `~/.claude.json` when it changes (the |
| 158 | + workspace-server already has file watchers) and push the updated list to |
| 159 | + active cloud runs. |
| 160 | + |
| 161 | +**Why this ships as a documented follow-up rather than code**: static headers |
| 162 | +in `~/.claude.json` carry no expiry metadata, and OAuth-backed servers |
| 163 | +managed by Claude Code keep their tokens in Claude's credential store — not |
| 164 | +in `mcpServers.headers` — so those servers aren't importable this way at all |
| 165 | +(they surface as headerless imports that 401 in the sandbox; the relay in |
| 166 | +[cloud-mcp-relay.md](./cloud-mcp-relay.md) covers them properly). For the |
| 167 | +static-header servers we can import, there is nothing to watch except the |
| 168 | +file itself, which is the trigger described above. |
| 169 | + |
| 170 | +## Follow-ups |
| 171 | + |
| 172 | +- Codex local config (`~/.codex/config.toml` `mcp_servers`) as a second |
| 173 | + source; needs a TOML parser, skipped for now. |
| 174 | +- Project-scoped `~/.claude.json` servers for cloud runs (repository → local |
| 175 | + checkout mapping). |
| 176 | +- `${VAR}` environment-variable expansion in header values (Claude Code |
| 177 | + expands these at session start; the import currently forwards them |
| 178 | + literally, so such servers will 401 until expanded). |
| 179 | +- The `refresh_session` client path described above. |
0 commit comments