Skip to content

Commit 0b5987e

Browse files
feat(local-mcp): import and relay local MCP servers into cloud runs (#3231)
1 parent 4f27d54 commit 0b5987e

63 files changed

Lines changed: 4882 additions & 83 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

apps/code/src/main/di/bindings.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ import type {
1111
import type {
1212
CLOUD_TASK_AUTH,
1313
ICloudTaskAuth,
14+
MCP_RELAY_EXECUTOR,
15+
McpRelayExecutor,
1416
} from "@posthog/core/cloud-task/identifiers";
1517
import type {
1618
CONTEXT_MENU_EXTERNAL_APPS_SERVICE,
@@ -180,6 +182,10 @@ import type {
180182
} from "@posthog/workspace-server/services/local-logs/identifiers";
181183
import type { MCP_PROXY_AUTH } from "@posthog/workspace-server/services/mcp-proxy/identifiers";
182184
import type { McpProxyAuth } from "@posthog/workspace-server/services/mcp-proxy/ports";
185+
import type {
186+
MCP_RELAY_SERVICE,
187+
McpRelayService,
188+
} from "@posthog/workspace-server/services/mcp-relay/identifiers";
183189
import type { PosthogPluginService } from "@posthog/workspace-server/services/posthog-plugin/posthog-plugin";
184190
import type { ProcessTrackingService } from "@posthog/workspace-server/services/process-tracking/process-tracking";
185191
import type {
@@ -358,9 +364,11 @@ export interface MainBindings {
358364
[MAIN_AUTH_SERVICE]: AuthService;
359365
[AUTH_SERVICE]: AuthService;
360366

361-
// Auth proxy / mcp proxy
367+
// Auth proxy / mcp proxy / mcp relay
362368
[AUTH_PROXY_AUTH]: AuthProxyAuth;
363369
[MCP_PROXY_AUTH]: McpProxyAuth;
370+
[MCP_RELAY_SERVICE]: McpRelayService;
371+
[MCP_RELAY_EXECUTOR]: McpRelayExecutor;
364372

365373
// Archive / suspension host ports
366374
[ARCHIVE_SESSION_CANCELLER]: SessionCanceller;

apps/code/src/main/di/container.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import { cloudTaskModule } from "@posthog/core/cloud-task/cloud-task.module";
2323
import {
2424
CLOUD_TASK_AUTH,
2525
CLOUD_TASK_SERVICE,
26+
MCP_RELAY_EXECUTOR,
2627
} from "@posthog/core/cloud-task/identifiers";
2728
import { contextMenuCoreModule } from "@posthog/core/context-menu/context-menu.module";
2829
import {
@@ -177,9 +178,12 @@ import {
177178
import type { HandoffGitGateway } from "@posthog/workspace-server/services/handoff/ports";
178179
import { HandoffHostService } from "@posthog/workspace-server/services/handoff/service";
179180
import { LOGS_SERVICE } from "@posthog/workspace-server/services/local-logs/identifiers";
181+
import { localMcpModule } from "@posthog/workspace-server/services/local-mcp/local-mcp.module";
180182
import { mcpCallbackModule } from "@posthog/workspace-server/services/mcp-callback/mcp-callback.module";
181183
import { MCP_PROXY_AUTH } from "@posthog/workspace-server/services/mcp-proxy/identifiers";
182184
import { mcpProxyModule } from "@posthog/workspace-server/services/mcp-proxy/mcp-proxy.module";
185+
import { MCP_RELAY_SERVICE } from "@posthog/workspace-server/services/mcp-relay/identifiers";
186+
import { mcpRelayModule } from "@posthog/workspace-server/services/mcp-relay/mcp-relay.module";
183187
import { OAUTH_CALLBACK_SERVER } from "@posthog/workspace-server/services/oauth-callback/identifiers";
184188
import { oauthCallbackModule } from "@posthog/workspace-server/services/oauth-callback/oauth-callback.module";
185189
import { onboardingImportModule } from "@posthog/workspace-server/services/onboarding-import/onboarding-import.module";
@@ -620,6 +624,15 @@ container.load(skillsModule);
620624
container.load(skillsMarketplaceModule);
621625
container.load(githubReleasesModule);
622626
container.load(onboardingImportModule);
627+
container.load(localMcpModule);
628+
container.load(mcpRelayModule);
629+
// Core's cloud-task service executes MCP relay requests through this seam;
630+
// the workspace relay service satisfies the core executor interface
631+
// structurally (docs/cloud-mcp-relay.md).
632+
container
633+
.bind(MCP_RELAY_EXECUTOR)
634+
.toDynamicValue((ctx) => ctx.get(MCP_RELAY_SERVICE))
635+
.inSingletonScope();
623636
container.load(claudeCliSessionsModule);
624637
container.load(additionalDirectoriesModule);
625638
container.bind(MAIN_SLEEP_SERVICE).to(SleepService);

apps/code/src/main/trpc/router.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,9 +28,11 @@ import { githubReleasesRouter } from "@posthog/host-router/routers/github-releas
2828
import { handoffRouter } from "@posthog/host-router/routers/handoff.router";
2929
import { linearIntegrationRouter } from "@posthog/host-router/routers/linear-integration.router";
3030
import { llmGatewayRouter } from "@posthog/host-router/routers/llm-gateway.router";
31+
import { localMcpRouter } from "@posthog/host-router/routers/local-mcp.router";
3132
import { logsRouter } from "@posthog/host-router/routers/logs.router";
3233
import { mcpAppsRouter } from "@posthog/host-router/routers/mcp-apps.router";
3334
import { mcpCallbackRouter } from "@posthog/host-router/routers/mcp-callback.router";
35+
import { mcpRelayRouter } from "@posthog/host-router/routers/mcp-relay.router";
3436
import { notificationRouter } from "@posthog/host-router/routers/notification.router";
3537
import { oauthRouter } from "@posthog/host-router/routers/oauth.router";
3638
import { onboardingImportRouter } from "@posthog/host-router/routers/onboarding-import.router";
@@ -86,8 +88,10 @@ export const trpcRouter = router({
8688
handoff: handoffRouter,
8789
linearIntegration: linearIntegrationRouter,
8890
llmGateway: llmGatewayRouter,
91+
localMcp: localMcpRouter,
8992
mcpApps: mcpAppsRouter,
9093
mcpCallback: mcpCallbackRouter,
94+
mcpRelay: mcpRelayRouter,
9195
notification: notificationRouter,
9296
oauth: oauthRouter,
9397
onboardingImport: onboardingImportRouter,

apps/code/src/renderer/desktop-contributions.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { autoresearchCoreModule } from "@posthog/core/autoresearch/autoresearch.
33
import { billingCoreModule } from "@posthog/core/billing/billing.module";
44
import { inboxCoreModule } from "@posthog/core/inbox/inbox.module";
55
import { githubConnectModule } from "@posthog/core/integrations/githubConnect.module";
6+
import { localMcpCoreModule } from "@posthog/core/local-mcp/local-mcp.module";
67
import { onboardingModule } from "@posthog/core/onboarding/onboarding.module";
78
import { setupCoreModule } from "@posthog/core/setup/setup.module";
89
import { skillsCoreModule } from "@posthog/core/skills/skills.module";
@@ -44,6 +45,7 @@ export function registerDesktopContributions(): void {
4445
focusUiModule,
4546
githubConnectModule,
4647
inboxCoreModule,
48+
localMcpCoreModule,
4749
notificationsUiModule,
4850
onboardingModule,
4951
provisioningUiModule,

apps/code/src/renderer/di/bindings.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,14 @@ import {
6161
import type { RepositoriesService } from "@posthog/core/integrations/repositoriesService";
6262
import { LLM_GATEWAY_SERVICE } from "@posthog/core/llm-gateway/identifiers";
6363
import type { LlmGatewayService } from "@posthog/core/llm-gateway/llm-gateway";
64+
import {
65+
LOCAL_MCP_IMPORT_SERVICE,
66+
LOCAL_MCP_WORKSPACE_CLIENT,
67+
} from "@posthog/core/local-mcp/identifiers";
68+
import type {
69+
LocalMcpImportService,
70+
LocalMcpWorkspaceClient,
71+
} from "@posthog/core/local-mcp/localMcpImport";
6472
import {
6573
GITHUB_CONNECT_CLIENT,
6674
type GithubConnectClient,
@@ -305,6 +313,8 @@ export interface RendererBindings {
305313
[CODE_REVIEW_WORKSPACE_CLIENT]: CodeReviewWorkspaceClient;
306314
[REVERT_HUNK_SERVICE]: RevertHunkService;
307315
[SKILLS_WORKSPACE_CLIENT]: SkillsWorkspaceClient;
316+
[LOCAL_MCP_WORKSPACE_CLIENT]: LocalMcpWorkspaceClient;
317+
[LOCAL_MCP_IMPORT_SERVICE]: LocalMcpImportService;
308318
[CLOUD_ARTIFACT_BUNDLE_LOCAL_SKILL]: BundleLocalSkill;
309319
[CLOUD_ARTIFACT_RESOLVE_SKILL_DEPENDENCIES]: ResolveSkillBundleDependencies;
310320
[CLOUD_ARTIFACT_READ_FILE_AS_BASE64]: ReadFileAsBase64;

apps/code/src/renderer/di/container.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@ import {
3030
import { LLM_GATEWAY_SERVICE } from "@posthog/core/llm-gateway/identifiers";
3131
import type { LlmGatewayService } from "@posthog/core/llm-gateway/llm-gateway";
3232
import type { LlmMessage } from "@posthog/core/llm-gateway/schemas";
33+
import { LOCAL_MCP_WORKSPACE_CLIENT } from "@posthog/core/local-mcp/identifiers";
34+
import type { LocalMcpWorkspaceClient } from "@posthog/core/local-mcp/localMcpImport";
3335
import {
3436
CLOUD_ARTIFACT_BUNDLE_LOCAL_SKILL,
3537
CLOUD_ARTIFACT_READ_FILE_AS_BASE64,
@@ -388,6 +390,12 @@ container.bind(CODE_REVIEW_WORKSPACE_CLIENT).toConstantValue({
388390
} satisfies CodeReviewWorkspaceClient);
389391
container.bind(REVERT_HUNK_SERVICE).to(RevertHunkService).inSingletonScope();
390392

393+
// local MCP servers (~/.claude.json), read for cloud-import classification
394+
container.bind(LOCAL_MCP_WORKSPACE_CLIENT).toConstantValue({
395+
listLocalMcpServers: (cwd?: string) =>
396+
trpcClient.localMcp.list.query({ cwd }),
397+
} satisfies LocalMcpWorkspaceClient);
398+
391399
// skills (team publish/install reach workspace-server through this slice)
392400
container.bind(SKILLS_WORKSPACE_CLIENT).toConstantValue({
393401
exportSkill: (skillPath: string) =>

docs/cloud-mcp-import.md

Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
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

Comments
 (0)