From e054a17d54be63d91501fcef71a5afd3f80dfd92 Mon Sep 17 00:00:00 2001 From: JJ Eaton <58495321+oldbettie@users.noreply.github.com> Date: Sat, 31 Oct 2026 09:17:50 +0700 Subject: [PATCH 1/2] fix(mcp-gateway): chat creation rejects a parentThreadId that isn't a chat An explicit parentThreadId on another environment was stored unchecked, because the target server can only verify parents it owns. A Captain passed an MCP request namespace (mcp:) as the parent, and its children never appeared under it. The gateway now looks the parent up on its environment and fails with invalid_input when it is not a chat. Co-Authored-By: Claude Opus 5.5 --- packages/mcp-gateway/src/tools.test.ts | 25 +++++++++++++++++++++++++ packages/mcp-gateway/src/tools.ts | 12 ++++++++++++ 2 files changed, 37 insertions(+) diff --git a/packages/mcp-gateway/src/tools.test.ts b/packages/mcp-gateway/src/tools.test.ts index cbc5271fd7515..3b4d4649fe03c 100644 --- a/packages/mcp-gateway/src/tools.test.ts +++ b/packages/mcp-gateway/src/tools.test.ts @@ -528,6 +528,10 @@ describe("gateway chat tools", () => { const context = { port: { ...base, + listThreads: async (environmentId: string) => ({ + snapshotAt: "2026-09-02T00:00:00.000Z", + items: environmentId === "local" ? [{ id: "coordinator" }, { id: "other-parent" }] : [], + }), createThread: async (request: Parameters[0]) => { parents.push([request.parentThreadId, request.parentEnvironmentId]); return base.createThread(request); @@ -577,6 +581,27 @@ describe("gateway chat tools", () => { ["coordinator", "local"], ["coordinator", undefined], ]); + + // An MCP request namespace copied out of a task ID is not a chat on either environment. + const fake = "mcp:b33476a4-892a-4ec5-a923-2a98b0bd14e5"; + for (const [input, key] of [ + [ + { + environmentId: "remote", + projectId: "remote-project", + parentThreadId: fake, + parentEnvironmentId: "local", + }, + "fake-remote", + ], + [{ parentThreadId: fake }, "fake-local"], + ] as const) { + await expect(create(input, key)).rejects.toMatchObject({ + code: "invalid_input", + message: `parentThreadId ${fake} is not a chat on environment local; omit parentThreadId to nest under the calling chat.`, + }); + } + expect(parents).toHaveLength(5); }); it.each(["constructor", "toString", "__proto__"])( diff --git a/packages/mcp-gateway/src/tools.ts b/packages/mcp-gateway/src/tools.ts index b96290380005b..70c9eda19db80 100644 --- a/packages/mcp-gateway/src/tools.ts +++ b/packages/mcp-gateway/src/tools.ts @@ -1994,6 +1994,18 @@ export async function callGatewayTool( ? input.parentEnvironmentId.trim() : undefined : invocation.caller?.environmentId; + // Checked here: the target environment cannot verify a parent on another environment. + if (typeof input.parentThreadId === "string" && parentThreadId !== undefined) { + const parentEnvironment = parentEnvironmentId ?? environmentId; + if ((await findGatewayThread(context, parentEnvironment, parentThreadId)) === undefined) { + throw new GatewayError({ + code: "invalid_input", + message: `parentThreadId ${parentThreadId} is not a chat on environment ${parentEnvironment}; omit parentThreadId to nest under the calling chat.`, + retryable: false, + environmentId: parentEnvironment, + }); + } + } const profileName = typeof input.profile === "string" ? input.profile.trim() : ""; const profileIdInput = typeof input.profileId === "string" ? input.profileId.trim() : ""; const legacyIdentity = { From cbd2530e193b361146bd95067af1f7b40ea031d8 Mon Sep 17 00:00:00 2001 From: JJ Eaton <58495321+oldbettie@users.noreply.github.com> Date: Sat, 31 Oct 2026 09:47:30 +0700 Subject: [PATCH 2/2] perf(mcp-gateway): check an explicit parent with one bounded thread read The parent check listed every thread shell on the parent's environment (about 5,000 on a busy one). It now reads the parent with the bounded getThread snapshot and lists only when that read fails, to tell a missing thread from any other error (relays pass errors on as text). The gateway projection and relay schema now carry archivedAt and deletedAt, so archived and deleted parents stay rejected. Co-Authored-By: Claude Opus 5.5 --- .../client-runtime/src/gateway/runtimePort.ts | 2 ++ packages/contracts/src/mcpGateway.ts | 2 ++ packages/mcp-gateway/src/tools.test.ts | 16 ++++++++++++++-- packages/mcp-gateway/src/tools.ts | 9 ++++++++- 4 files changed, 26 insertions(+), 3 deletions(-) diff --git a/packages/client-runtime/src/gateway/runtimePort.ts b/packages/client-runtime/src/gateway/runtimePort.ts index 80c6caa81f921..391a1c4b8ab86 100644 --- a/packages/client-runtime/src/gateway/runtimePort.ts +++ b/packages/client-runtime/src/gateway/runtimePort.ts @@ -406,6 +406,8 @@ export function gatewayThreadProjection(projection: OrchestrationV2ThreadProject })), createdAt: iso(thread.createdAt), updatedAt: iso(thread.updatedAt), + archivedAt: iso(thread.archivedAt), + deletedAt: iso(thread.deletedAt), }; } diff --git a/packages/contracts/src/mcpGateway.ts b/packages/contracts/src/mcpGateway.ts index b63756fb21bf3..7df63a4f38530 100644 --- a/packages/contracts/src/mcpGateway.ts +++ b/packages/contracts/src/mcpGateway.ts @@ -66,6 +66,8 @@ export const McpGatewayRelayThread = Schema.Struct({ ), settledAt: RelaySettledAt, settlementSource: Schema.optional(Schema.NullOr(Schema.Literals(["manual", "inactivity"]))), + archivedAt: RelayIso, + deletedAt: RelayIso, runs: Schema.Array( Schema.Struct({ id: RunId, diff --git a/packages/mcp-gateway/src/tools.test.ts b/packages/mcp-gateway/src/tools.test.ts index 3b4d4649fe03c..745c79246d55d 100644 --- a/packages/mcp-gateway/src/tools.test.ts +++ b/packages/mcp-gateway/src/tools.test.ts @@ -530,8 +530,19 @@ describe("gateway chat tools", () => { ...base, listThreads: async (environmentId: string) => ({ snapshotAt: "2026-09-02T00:00:00.000Z", - items: environmentId === "local" ? [{ id: "coordinator" }, { id: "other-parent" }] : [], + items: + environmentId === "local" + ? [{ id: "coordinator" }, { id: "other-parent" }, { id: "archived-parent" }] + : [], }), + getThread: async (environmentId: string, threadId: string) => { + if (environmentId !== "local" || !["other-parent", "archived-parent"].includes(threadId)) + throw new Error("Failed to load thread"); + const thread = await base.getThread(environmentId, threadId); + return threadId === "archived-parent" + ? { ...thread, archivedAt: "2026-09-01T00:00:00.000Z" } + : thread; + }, createThread: async (request: Parameters[0]) => { parents.push([request.parentThreadId, request.parentEnvironmentId]); return base.createThread(request); @@ -595,10 +606,11 @@ describe("gateway chat tools", () => { "fake-remote", ], [{ parentThreadId: fake }, "fake-local"], + [{ parentThreadId: "archived-parent" }, "archived"], ] as const) { await expect(create(input, key)).rejects.toMatchObject({ code: "invalid_input", - message: `parentThreadId ${fake} is not a chat on environment local; omit parentThreadId to nest under the calling chat.`, + message: `parentThreadId ${input.parentThreadId} is not a chat on environment local; omit parentThreadId to nest under the calling chat.`, }); } expect(parents).toHaveLength(5); diff --git a/packages/mcp-gateway/src/tools.ts b/packages/mcp-gateway/src/tools.ts index 70c9eda19db80..e32636d19ae8e 100644 --- a/packages/mcp-gateway/src/tools.ts +++ b/packages/mcp-gateway/src/tools.ts @@ -1997,7 +1997,14 @@ export async function callGatewayTool( // Checked here: the target environment cannot verify a parent on another environment. if (typeof input.parentThreadId === "string" && parentThreadId !== undefined) { const parentEnvironment = parentEnvironmentId ?? environmentId; - if ((await findGatewayThread(context, parentEnvironment, parentThreadId)) === undefined) { + const parent = await getThreadOrNotFound(context, parentEnvironment, parentThreadId).catch( + (error: unknown) => { + if (error instanceof GatewayError && error.code === "thread_not_found") + return undefined; + throw error; + }, + ); + if (parent === undefined || parent.archivedAt != null || parent.deletedAt != null) { throw new GatewayError({ code: "invalid_input", message: `parentThreadId ${parentThreadId} is not a chat on environment ${parentEnvironment}; omit parentThreadId to nest under the calling chat.`,