Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/client-runtime/src/gateway/runtimePort.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
};
}

Expand Down
2 changes: 2 additions & 0 deletions packages/contracts/src/mcpGateway.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
37 changes: 37 additions & 0 deletions packages/mcp-gateway/src/tools.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof base.createThread>[0]) => {
parents.push([request.parentThreadId, request.parentEnvironmentId]);
return base.createThread(request);
Expand Down Expand Up @@ -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__"])(
Expand Down
19 changes: 19 additions & 0 deletions packages/mcp-gateway/src/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
Loading