feat(tasks): accept client-imported MCP servers on cloud run creation#68954
feat(tasks): accept client-imported MCP servers on cloud run creation#68954richardsolomou wants to merge 9 commits into
Conversation
|
Hey @richardsolomou! 👋 It looks like your git author email on this PR isn't your
You can fix it for this repo with: git config user.email "you@posthog.com"Or set it globally with |
Migration SQL ChangesHey 👋, we've detected some migrations on this PR. Here's the SQL output for each migration, make sure they make sense:
|
🔍 Migration Risk AnalysisWe've analyzed your migrations for potential risks. Summary: 2 Safe | 0 Needs Review | 0 Blocked ✅ SafeBrief or no lock, backwards compatible 📚 How to Deploy These Changes SafelyAddField: This operation acquires a brief lock but doesn't rewrite the table. Deployment uses lock timeouts with automatic retries, so lock contention will cause retries rather than connection pile-up. Last updated: 2026-07-09 00:40 UTC (555e3d7) |
|
Reviews (1): Last reviewed commit: "chore: update OpenAPI generated types" | Re-trigger Greptile |
🤖 CI report
|
| Root | Eager (shipped) | Δ vs base | Budget |
|---|---|---|---|
entry (logged-out pages, app bootstrap)src/index.tsx |
1.20 MiB · 22 files | no change | ███░░░░░░░ 27.9% of 4.29 MiB |
authenticated shell (every logged-in page)src/scenes/AuthenticatedShell.tsx |
8.04 MiB · 2,947 files | no change | █████████░ 86.9% of 9.25 MiB |
🟢 node_modules/monaco-editor/ stays out of src/index.tsx
🟢 src/lib/components/ActivityLog/describers stays out of src/index.tsx
🟢 [object Object] stays out of src/index.tsx
🟢 [object Object] stays out of src/index.tsx
🟢 node_modules/monaco-editor/ stays out of src/scenes/AuthenticatedShell.tsx
🟢 src/lib/components/ActivityLog/describers stays out of src/scenes/AuthenticatedShell.tsx
🟢 [object Object] stays out of src/scenes/AuthenticatedShell.tsx
🟢 [object Object] stays out of src/scenes/AuthenticatedShell.tsx
Largest files eagerly shipped from src/index.tsx
| Size | File |
|---|---|
| 126.8 KiB | ../node_modules/.pnpm/react-dom@18.3.1_react@18.3.1/node_modules/react-dom/cjs/react-dom.production.min.js |
| 24.6 KiB | ../node_modules/.pnpm/buffer@6.0.3/node_modules/buffer/index.js |
| 6.3 KiB | ../node_modules/.pnpm/react@18.3.1/node_modules/react/cjs/react.production.min.js |
| 4.5 KiB | ../node_modules/.pnpm/@jspm+core@2.1.0/node_modules/@jspm/core/nodelibs/browser/process.js |
| 3.9 KiB | ../node_modules/.pnpm/scheduler@0.23.2/node_modules/scheduler/cjs/scheduler.production.min.js |
| 1.4 KiB | ../node_modules/.pnpm/base64-js@1.5.1/node_modules/base64-js/index.js |
| 1.3 KiB | src/RootErrorBoundary.tsx |
| 912 B | ../node_modules/.pnpm/ieee754@1.2.1/node_modules/ieee754/index.js |
| 789 B | src/scenes/ChunkLoadErrorBoundary.tsx |
| 668 B | src/index.tsx |
Largest files eagerly shipped from src/scenes/AuthenticatedShell.tsx
| Size | File |
|---|---|
| 277.1 KiB | ../node_modules/.pnpm/posthog-js@1.398.2/node_modules/posthog-js/dist/rrweb.js |
| 266.9 KiB | ../node_modules/.pnpm/@posthog+icons@0.37.4_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/@posthog/icons/dist/posthog-icons.es.js |
| 224.6 KiB | src/taxonomy/core-filter-definitions-by-group.json |
| 213.6 KiB | ../node_modules/.pnpm/posthog-js@1.398.2/node_modules/posthog-js/dist/module.js |
| 160.9 KiB | src/queries/validators.js |
| 154.0 KiB | ../node_modules/.pnpm/re2js@0.4.1/node_modules/re2js/build/index.esm.js |
| 126.8 KiB | ../node_modules/.pnpm/react-dom@18.3.1_react@18.3.1/node_modules/react-dom/cjs/react-dom.production.min.js |
| 106.1 KiB | src/lib/api.ts |
| 93.3 KiB | ../node_modules/.pnpm/prosemirror-view@1.40.1/node_modules/prosemirror-view/dist/index.js |
| 90.5 KiB | ../node_modules/.pnpm/@tiptap+core@3.20.1_@tiptap+pm@3.20.1/node_modules/@tiptap/core/dist/index.js |
Posted automatically by check-eager-graph · sizes are eager output bytes (shipped, post-tree-shake) from the esbuild metafile · part of #32479
⚠️ Dist folder size — 🔺 +12.1 KiB (+0.0%)
Total size of the built frontend/dist folder (all assets), compared against the base branch.
Total: 1453.96 MiB · 🔺 +12.1 KiB (+0.0%)
PR overviewAll previously flagged issues have been addressed. No open security concerns remain on this pull request. Security reviewNo open security issues remain on this pull request. Fixed/addressed: 1 · PR risk: 0/10 |
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
- Fetch the TaskRun once in _prepare_launch and reuse it for both the event-ingest token and the imported MCP servers, dropping the second query on the claude path. - Assert the run/ endpoint response never echoes imported_mcp_servers, mirroring the runs/ test's credential-leak guard. Generated-By: PostHog Code Task-Id: 7c87c3a4-0be6-475e-a8ec-269140ded301
The periodic MCP token refresh rebuilds the session's server list as PostHog MCP + installations only, and the agent treats a refresh_session list as authoritative — so client-imported servers silently vanished at the first mid-run token refresh. - New shared get_imported_mcp_server_configs(task_run, existing_names) in utils.py owns the claude-only gate and collision rule; both the launch path and _refresh_sandbox_mcp use it now. - build_imported_mcp_server_configs guards against non-list values in the encrypted column so stored drift can't break a launch. - Tests: refresh keeps imported servers (and posthog-name collisions still lose); non-list guard case. Generated-By: PostHog Code Task-Id: 7c87c3a4-0be6-475e-a8ec-269140ded301
Address review feedback: the missing-run check was only applied on the event-ingest path, so with ingest disabled a missing TaskRun row would silently launch without imported MCP servers instead of failing. Check unconditionally right after the fetch, raising SandboxExecutionError per this activity's convention. Generated-By: PostHog Code Task-Id: 7c87c3a4-0be6-475e-a8ec-269140ded301
- mypy: type build_imported_mcp_server_configs' input as Any — the encrypted column is schemaless at read time and the guards that mypy flagged as unreachable exist precisely for non-conforming values; give SandboxExecutionError its required cause. - test_start_agent_server: the launch test now mocks TaskRun.objects.filter().first() to match the hoisted single fetch (its fake run-id isn't a valid UUID, so the real query would raise). - test_api: patch is_url_allowed in the two happy-path tests — CI runs the real SSRF guard, which does live DNS, and the example.com hosts don't resolve. The rejection tests keep exercising the real guard. Generated-By: PostHog Code Task-Id: 7c87c3a4-0be6-475e-a8ec-269140ded301
Address review feedback: reserved names were checked case-insensitively but duplicates case-sensitively, so ["MyServer", "myserver"] slipped through. Compare both checks on the same lowercased key — two imported names differing only by case are far more likely a client bug than intent. Error messages keep the original casing. Generated-By: PostHog Code Task-Id: 7c87c3a4-0be6-475e-a8ec-269140ded301
fda9db4 to
dce6f8e
Compare
🦔 Hogbox preview · ✅ ready▶ Open the preview
commit |
Django half of the PostHog Code MCP relay (docs/cloud-mcp-relay.md in
posthog/code): lets the creating desktop relay stdio/private-URL MCP
servers into a cloud sandbox over the existing event-stream + command
channel.
- TaskRun.relayed_mcp_servers: plain JSONField of {"name": ...} dicts —
names only, no secrets to encrypt; migration 0055.
- Run creation accepts relayed_mcp_servers on both endpoints (≤20,
case-insensitive dedupe, reserved names rejected, disjoint from
imported_mcp_servers), persisted with the same single save as
imported servers.
- Launch passes --relayMcpServers (names) to the agent server on both
sandbox backends; name collisions with resolved MCP configs drop.
- Command endpoint allows mcp_response: requestId + server plus exactly
one of payload/error, params ≤300KB, forwarded verbatim to the
sandbox and excluded from relay telemetry — response payloads carry
data from the user's private systems and must not be persisted.
Generated-By: PostHog Code
Task-Id: 7c87c3a4-0be6-475e-a8ec-269140ded301
| "set_config_option, and mcp_response commands.", | ||
| strict_request_validation=True, | ||
| ) | ||
| @action( |
There was a problem hiding this comment.
Originally flagged line 1459; mapped to the nearest line in the diff (1371).
Unbounded relayed MCP responses can be proxied into any active sandbox
The new mcp_response command is accepted and forwarded by the generic run /command endpoint, but there is no check that the target run actually declared the named relayed_mcp_servers during creation. Any user who can control a run can now inject arbitrary mcp_response payloads into any active sandbox session, impersonating a relayed desktop MCP server and potentially steering tool results or exfiltrating agent data through a fake relay channel.
Prompt To Fix With AI
Before proxying an `mcp_response` command, verify that the target TaskRun explicitly opted into relay for the requested `params.server` name. Use the persisted `TaskRun.relayed_mcp_servers` allowlist (case-insensitive) and reject any `mcp_response` whose server name is absent. Keep this guard server-side in the Django endpoint so a client cannot inject responses for undeclared relay servers even if the sandbox agent would accept them.Severity: medium | Confidence: 89% | React with 👍 if useful or 👎 if not
Problem
A local PostHog Code task run gets the user's own
~/.claude.jsonMCP servers; a cloud sandbox only gets the PostHog MCP plus MCP Store installations baked into--mcpServersat spawn. PostHog/code#3231 (client half, behind theposthog-code-local-mcp-importflag) sends the user's importable url-based servers in the run-creation payload; this PR is the backend half that validates, stores, and forwards them. Full design: docs/cloud-mcp-import.md.Changes
POST .../run/andPOST .../runs/) accept an optional write-onlyimported_mcp_serverslist, already in the agent server's--mcpServersentry shape ({type: http|sse, name, url, headers: [{name, value}]}).posthogreserved, header values ≤4KB, whole field ≤32KB, and a server-sideis_url_allowedSSRF re-check — the client classifies public vs private hosts for UX, but the sandbox egresses from PostHog infra, so a private URL here is a user-controlled SSRF vector.TaskRun.imported_mcp_serverscolumn (EncryptedJSONStringField, migration 0054) rather than the plainstateJSON, because header values carry credentials; never returned by any API response.start_agent_servermerges the stored entries into--mcpServersafter the PostHog MCP and MCP Store installations; on name collision the existing server wins. Claude-only for now: codex-acp hard-fails the session on any unreachable MCP server and the sandbox does no reachability pruning.send_refresh_session.relayed_mcp_servers— names only (≤20, deduped case-insensitively, reserved names rejected, disjoint from imported names), stored in a plainTaskRun.relayed_mcp_serversJSONField (migration 0055; no secrets, nothing to encrypt). Launch passes--relayMcpServersto the agent server on both sandbox backends so it can expose loopback relay endpoints. The command endpoint gains anmcp_responsemethod (requestId + server plus exactly one of payload/error, params ≤300KB) forwarded verbatim to the sandbox and deliberately excluded from relay telemetry — response payloads carry data returned by the user's private systems. Client half in PostHog/code#3231; design in that repo'sdocs/cloud-mcp-relay.md.How did you test this code?
Automated only (I could not run the suite in this sandbox — no database — so CI is the arbiter):
test_create_run_endpoint_persists_imported_mcp_servers_outside_state/test_run_endpoint_persists_imported_mcp_servers: the field round-trips through the encrypted column and never lands instateor the response — no existing test touched the new column.test_create_run_endpoint_rejects_private_imported_mcp_server_urls(FORCE_URL_VALIDATION=True): private/loopback URLs are rejected — catches regressions that would open the SSRF vector.test_create_run_endpoint_rejects_invalid_imported_mcp_servers(parameterized): reserved name, duplicates, >20 servers, oversized header.TestBuildImportedMcpServerConfigs: spawn-side merge drops name collisions and malformed stored entries instead of failing the launch.test_refresh_keeps_imported_mcp_servers: a mid-runrefresh_sessionstill includes imported servers — the regression here is them disappearing minutes into a run.relayed_mcp_serverson both endpoints, parameterized invalid cases (case-differing duplicates, >20, reserved, imported-name collision),mcp_responseforwarding for payload and error variants plus seven invalid-shape rejections, andTestGetRelayedMcpServerNamesfor launch-side drift/collision handling.ruff checkandruff formatpass on the changed files.Automatic notifications
Docs update
N/A — client-facing docs live in the PostHog/code repo.