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 cbc5271fd7515..745c79246d55d 100644 --- a/packages/mcp-gateway/src/tools.test.ts +++ b/packages/mcp-gateway/src/tools.test.ts @@ -528,6 +528,21 @@ 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" }, { 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); @@ -577,6 +592,28 @@ 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"], + [{ parentThreadId: "archived-parent" }, "archived"], + ] as const) { + await expect(create(input, key)).rejects.toMatchObject({ + code: "invalid_input", + message: `parentThreadId ${input.parentThreadId} 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..e32636d19ae8e 100644 --- a/packages/mcp-gateway/src/tools.ts +++ b/packages/mcp-gateway/src/tools.ts @@ -1994,6 +1994,25 @@ 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; + 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.`, + retryable: false, + environmentId: parentEnvironment, + }); + } + } const profileName = typeof input.profile === "string" ? input.profile.trim() : ""; const profileIdInput = typeof input.profileId === "string" ? input.profileId.trim() : ""; const legacyIdentity = {