Skip to content

Commit 488e100

Browse files
feat(local-mcp): relay importable servers for codex runs
Codex hard-fails a session on any unreachable MCP server, so importable (public-URL) servers can't go into the sandbox config for GPT runs the way they do for claude. partitionLocalMcpServersForRun routes them through the relay instead when the run's adapter is codex: the loopback relay endpoint always answers codex's reachability probe and the desktop executes the server from local config, public URLs included. Desktop-only servers still relay for every adapter. Relayed list is capped at 20 (matching the backend), keeping desktop-only servers ahead of importables when the cap bites.
1 parent 555b643 commit 488e100

4 files changed

Lines changed: 124 additions & 13 deletions

File tree

docs/cloud-mcp-import.md

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -114,13 +114,21 @@ echoed back from the run detail API), and excluded from logs/analytics.
114114
PostHog MCP and installation-derived servers. No other transformation — the
115115
payload shape is already `remoteMcpServerSchema`.
116116

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.
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.
124132

125133
## Auth: header staleness and rotation
126134

packages/core/src/local-mcp/localMcpImport.test.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,10 @@ import { describe, expect, it } from "vitest";
33
import {
44
classifyLocalMcpServer,
55
isPrivateHostname,
6+
type LocalMcpCloudClassification,
67
LocalMcpImportService,
78
type LocalMcpWorkspaceClient,
9+
partitionLocalMcpServersForRun,
810
} from "./localMcpImport";
911

1012
describe("isPrivateHostname", () => {
@@ -195,3 +197,54 @@ describe("LocalMcpImportService", () => {
195197
]);
196198
});
197199
});
200+
201+
describe("partitionLocalMcpServersForRun", () => {
202+
const importable = (name: string): LocalMcpCloudClassification => ({
203+
name,
204+
availability: "importable",
205+
reason: "public_url",
206+
remote: {
207+
type: "http",
208+
name,
209+
url: `https://${name}.example.com/mcp`,
210+
headers: [],
211+
},
212+
});
213+
const desktopOnly = (name: string): LocalMcpCloudClassification => ({
214+
name,
215+
availability: "requires_desktop",
216+
reason: "stdio_transport",
217+
});
218+
const servers = [
219+
importable("grafana"),
220+
desktopOnly("slack"),
221+
{ name: "posthog", availability: "built_in", reason: "reserved_name" },
222+
{ name: "broken", availability: "unsupported", reason: "invalid_url" },
223+
] as LocalMcpCloudClassification[];
224+
225+
it.each([
226+
["claude", "claude"],
227+
["unset", undefined],
228+
] as const)("imports public servers for the %s adapter", (_name, adapter) => {
229+
const result = partitionLocalMcpServersForRun(servers, adapter);
230+
expect(result.imported.map((s) => s.name)).toEqual(["grafana"]);
231+
expect(result.relayed).toEqual([{ name: "slack" }]);
232+
});
233+
234+
it("relays importable servers instead of importing them for codex", () => {
235+
const result = partitionLocalMcpServersForRun(servers, "codex");
236+
expect(result.imported).toEqual([]);
237+
expect(result.relayed).toEqual([{ name: "slack" }, { name: "grafana" }]);
238+
});
239+
240+
it("keeps desktop-only servers when the codex relay list hits the cap", () => {
241+
const many = [
242+
...Array.from({ length: 15 }, (_, i) => importable(`pub-${i}`)),
243+
...Array.from({ length: 10 }, (_, i) => desktopOnly(`desk-${i}`)),
244+
];
245+
const result = partitionLocalMcpServersForRun(many, "codex");
246+
expect(result.relayed).toHaveLength(20);
247+
const names = result.relayed.map((s) => s.name);
248+
for (let i = 0; i < 10; i++) expect(names).toContain(`desk-${i}`);
249+
});
250+
});

packages/core/src/local-mcp/localMcpImport.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import type {
2+
Adapter,
23
CloudMcpServerImport,
4+
CloudMcpServerRelayDesignation,
35
LocalMcpServerDescriptor,
46
} from "@posthog/shared";
57
import { isPrivateIpv4Octets, isPrivateIpv6Literal } from "@posthog/shared";
@@ -141,6 +143,53 @@ export function classifyLocalMcpServer(
141143
};
142144
}
143145

146+
/** Mirrors the run-creation API's per-field caps (MAX_RELAYED_MCP_SERVERS). */
147+
const MAX_RELAYED_MCP_SERVERS = 20;
148+
149+
export interface LocalMcpServersForRun {
150+
imported: CloudMcpServerImport[];
151+
relayed: CloudMcpServerRelayDesignation[];
152+
}
153+
154+
/**
155+
* Split classified local servers into the run-creation payload's imported and
156+
* relayed lists for the run's adapter.
157+
*
158+
* Claude tolerates an unreachable configured server, so importable (public
159+
* URL) servers go straight into the sandbox config. Codex hard-fails the whole
160+
* session on any unreachable MCP server, so for codex runs importable servers
161+
* ride the relay instead: the loopback relay endpoint always answers codex's
162+
* reachability probe, and the desktop executes the server from local config —
163+
* public URLs included. Desktop-only servers relay for every adapter.
164+
*/
165+
export function partitionLocalMcpServersForRun(
166+
servers: LocalMcpCloudClassification[],
167+
adapter: Adapter | undefined,
168+
): LocalMcpServersForRun {
169+
const relayImportable = adapter === "codex";
170+
const imported = relayImportable
171+
? []
172+
: servers.flatMap((server) => (server.remote ? [server.remote] : []));
173+
const relayed = servers
174+
.filter(
175+
(server) =>
176+
server.availability === "requires_desktop" ||
177+
(relayImportable && server.availability === "importable"),
178+
)
179+
// Desktop-only servers first: unlike importables they have no other
180+
// transport, so they must survive the count cap.
181+
.sort((a, b) =>
182+
a.availability === b.availability
183+
? 0
184+
: a.availability === "requires_desktop"
185+
? -1
186+
: 1,
187+
)
188+
.slice(0, MAX_RELAYED_MCP_SERVERS)
189+
.map((server) => ({ name: server.name }));
190+
return { imported, relayed };
191+
}
192+
144193
@injectable()
145194
export class LocalMcpImportService {
146195
constructor(

packages/ui/src/features/task-detail/hooks/useTaskCreation.ts

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { partitionLocalMcpServersForRun } from "@posthog/core/local-mcp/localMcpImport";
12
import {
23
getErrorTitle,
34
prepareTaskInput,
@@ -341,6 +342,10 @@ export function useTaskCreation({
341342
? personalChannel?.id
342343
: undefined;
343344

345+
const localMcpServersForRun = partitionLocalMcpServersForRun(
346+
localMcpServers,
347+
adapter,
348+
);
344349
const input = prepareTaskInput(serializedContent, filePaths, {
345350
// In channels chat-box mode no repo is attached up front, even if a
346351
// directory/repo is lingering in the persisted picker state.
@@ -368,12 +373,8 @@ export function useTaskCreation({
368373
autoPublishCloudRuns: settings.autoPublishCloudRuns,
369374
rtkEnabledCloud: settings.rtkEnabledCloud,
370375
allowNoRepo,
371-
importedMcpServers: localMcpServers.flatMap((server) =>
372-
server.remote ? [server.remote] : [],
373-
),
374-
relayedMcpServers: localMcpServers
375-
.filter((server) => server.availability === "requires_desktop")
376-
.map((server) => ({ name: server.name })),
376+
importedMcpServers: localMcpServersForRun.imported,
377+
relayedMcpServers: localMcpServersForRun.relayed,
377378
});
378379

379380
if (executionMode) {

0 commit comments

Comments
 (0)