authorgravatar for maria@kuuro.netmaria <maria@kuuro.net> 2026-10-06 14:21:23-03:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-06 14:21:23-03:00
logb38aa18365fe314e079d99670932bedfc7407e5a
tree59310c5f44bff97363c6f55fe0a3165d226e9792
parentfd1c3386c4d60f3477ab3f13c87537848de099f5
signature Signed by PGP key B5690EEEBB952194

fix(mcp): preserve thread command rejection reasons (#15627)


4 files changed, 92 insertions(+), 7 deletions(-)

apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts+28
......@@ -767,6 +767,20 @@ describe("orchestrator MCP toolkit", () => {
767767 const invoke = (name: string, args: Record<string, unknown>) =>
768768 invokeAs(invocation, name, args);
769769
770 const refusedSettle = yield* invoke("t3_thread_organize", { action: "settle" });
771 expect(refusedSettle.isError).toBe(true);
772 expect(refusedSettle.structuredContent).toBeUndefined();
773 expect(declaredFailure(refusedSettle)).toEqual({
774 _tag: "OrchestratorMcpFailure",
775 code: "orchestration_error",
776 message: `Thread ${parentThreadId} has active or blocked work and cannot be settled.`,
777 });
778 const afterRefusedSettle = yield* orchestrator.getThreadProjection(parentThreadId);
779 expect(afterRefusedSettle.thread.settledOverride).not.toBe("settled");
780 expect(afterRefusedSettle.runs.find((run) => run.id === parentRun?.id)?.status).toBe(
781 "running",
782 );
783
770784 const pinned = yield* invoke("t3_thread_organize", { action: "pin" });
771785 expect(pinned.isError).toBe(false);
772786 expect(pinned.structuredContent).toHaveProperty("sequence");
......@@ -777,6 +791,20 @@ describe("orchestrator MCP toolkit", () => {
777791 if (parentRun === undefined || parentRun.rootNodeId === null) {
778792 return yield* Effect.die(new Error("Parent run missing."));
779793 }
794 for (const name of ["t3_queue_edit", "t3_queue_cancel"]) {
795 const refusedQueueMutation = yield* invoke(name, {
796 queuedRunId: parentRun.id,
797 ...(name === "t3_queue_edit" ? { text: "Keep the active turn." } : {}),
798 });
799 expect(refusedQueueMutation.isError).toBe(true);
800 expect(refusedQueueMutation.structuredContent).toBeUndefined();
801 expect(declaredFailure(refusedQueueMutation)).toEqual({
802 _tag: "OrchestratorMcpFailure",
803 code: "orchestration_error",
804 message: `Run ${parentRun.id} is not queued.`,
805 });
806 }
807
780808 let parentRootNodeId = parentRun.rootNodeId;
781809 const queueAutomaticCompletion = (suffix: string, taskText: string) =>
782810 Effect.gen(function* () {
apps/server/src/mcp/threadAccess.ts+13
......@@ -11,6 +11,7 @@ import {
1111import * as Crypto from "effect/Crypto";
1212import * as Effect from "effect/Effect";
1313
14import type { OrchestratorV2Error } from "../orchestration-v2/Orchestrator.ts";
1415import * as ThreadManagement from "../orchestration-v2/ThreadManagementService.ts";
1516import * as OrchestrationMcp from "./OrchestratorMcpService.ts";
1617import * as McpInvocationContext from "./McpInvocationContext.ts";
......@@ -21,6 +22,18 @@ export const unavailable = () =>
2122 message: "The operation could not be completed.",
2223 });
2324
25/** Decider string rejections are public; wrapped storage and hydration causes are not. */
26export const dispatchFailure = (error: OrchestratorV2Error) =>
27 (error._tag === "OrchestratorDispatchError" ||
28 error._tag === "OrchestratorCommandRejectedError") &&
29 typeof error.cause === "string" &&
30 error.cause.length > 0
31 ? new OrchestratorMcpFailure({
32 code: "orchestration_error",
33 message: Array.from(error.cause).slice(0, 1000).join(""),
34 })
35 : unavailable();
36
2437/**
2538 * The most a caller may hand to the threads it targets. A thread caller is
2639 * capped by its own thread's modes; an OAuth client by the ceiling chosen
apps/server/src/mcp/toolkits/core.test.ts+44-1
......@@ -6,6 +6,7 @@ import type { JsonSchemaType } from "@modelcontextprotocol/sdk/validation";
66import {
77 DEFAULT_SERVER_SETTINGS,
88 ChatImageAttachment,
9 CommandId,
910 EnvironmentId,
1011 ProviderInstanceId,
1112 RunId,
......@@ -19,8 +20,13 @@ import { McpAttachmentInput } from "./attachment/input.ts";
1920import { McpSchema, McpServer, Tool } from "effect/ai";
2021import { FetchHttpClient } from "effect/http";
2122
23import {
24 OrchestratorCommandRejectedError,
25 OrchestratorDispatchError,
26 OrchestratorProjectionError,
27} from "../../orchestration-v2/Orchestrator.ts";
28
2229import * as ServerConfig from "../../config.ts";
23import { OrchestratorProjectionError } from "../../orchestration-v2/Orchestrator.ts";
2430import * as ProviderAdapterRegistry from "../../orchestration-v2/ProviderAdapterRegistry.ts";
2531import * as ThreadManagement from "../../orchestration-v2/ThreadManagementService.ts";
2632import * as PreviewBrowser from "../../preview/PreviewBrowser.ts";
......@@ -30,6 +36,7 @@ import * as SecretRequests from "../../secrets/SecretRequests.ts";
3036import * as ScheduledTaskService from "../../scheduledTasks/ScheduledTaskService.ts";
3137import * as McpHttpServer from "../McpHttpServer.ts";
3238import * as McpInvocationContext from "../McpInvocationContext.ts";
39import { dispatchFailure } from "../threadAccess.ts";
3340import { OrchestratorToolkit } from "./orchestrator/tools.ts";
3441import { PreviewToolkit } from "./preview/tools.ts";
3542import { PreviewControlsToolkit } from "./previewControls/tools.ts";
......@@ -202,6 +209,42 @@ it.effect("returns a bounded public failure without serializing storage causes",
202209 ),
203210);
204211
212it("bounds public command rejections and redacts internal dispatch causes", () => {
213 const command = { commandId: CommandId.make("mcp-core-command"), commandType: "thread.settle" };
214 expect(
215 dispatchFailure(new OrchestratorDispatchError({ ...command, cause: "🙂".repeat(1001) }))
216 .message,
217 ).toBe("🙂".repeat(1000));
218 expect(
219 dispatchFailure(
220 new OrchestratorCommandRejectedError({ ...command, cause: "Run is not queued." }),
221 ).message,
222 ).toBe("Run is not queued.");
223 for (const cause of [
224 undefined,
225 "",
226 new Error("private-storage-path"),
227 { message: "private-storage-path" },
228 ]) {
229 expect(dispatchFailure(new OrchestratorDispatchError({ ...command, cause }))).toMatchObject({
230 code: "orchestration_error",
231 message: "The operation could not be completed.",
232 });
233 expect(
234 dispatchFailure(new OrchestratorCommandRejectedError({ ...command, cause })),
235 ).toMatchObject({
236 code: "orchestration_error",
237 message: "The operation could not be completed.",
238 });
239 }
240 expect(
241 dispatchFailure(new OrchestratorProjectionError({ threadId, cause: "private-storage-path" })),
242 ).toMatchObject({
243 code: "orchestration_error",
244 message: "The operation could not be completed.",
245 });
246});
247
205248it.effect("returns an HTML render reference that Codex and Claude tool rows both carry", () =>
206249 Effect.gen(function* () {
207250 const server = yield* McpServer.McpServer;
apps/server/src/mcp/toolkits/thread/handlers.ts+7-6
......@@ -11,6 +11,7 @@ import * as Effect from "effect/Effect";
1111import { modelSelectionCommandType } from "@t3tools/shared/model";
1212
1313import {
14 dispatchFailure,
1415 newCommandId,
1516 readCaller,
1617 readFullAccessCaller,
......@@ -45,7 +46,7 @@ const dispatch = Effect.fn("mcp.dispatchThreadCommand")(function* (
4546 const { threads, projection } = yield* readWritableThread(threadId);
4647 const result = yield* threads
4748 .dispatch(command({ commandId: yield* newCommandId(), threadId: projection.thread.id }))
48 .pipe(Effect.mapError(unavailable));
49 .pipe(Effect.mapError(dispatchFailure));
4950 return { sequence: result.sequence };
5051});
5152
......@@ -131,7 +132,7 @@ export const layer = ThreadToolkit.toLayer({
131132 createdBy: "agent",
132133 creationSource: "mcp",
133134 })
134 .pipe(Effect.mapError(unavailable));
135 .pipe(Effect.mapError(dispatchFailure));
135136 return { sequence: result.sequence, targetThreadId };
136137 }),
137138 t3_thread_merge_back: (input) =>
......@@ -148,7 +149,7 @@ export const layer = ThreadToolkit.toLayer({
148149 createdBy: "agent",
149150 creationSource: "mcp",
150151 })
151 .pipe(Effect.mapError(unavailable));
152 .pipe(Effect.mapError(dispatchFailure));
152153 return { sequence: result.sequence, targetThreadId: input.targetThreadId };
153154 }),
154155 t3_thread_transfers: (input) =>
......@@ -191,7 +192,7 @@ export const layer = ThreadToolkit.toLayer({
191192 commandId: yield* newCommandId(),
192193 modelSelection: input.modelSelection,
193194 })
194 .pipe(Effect.mapError(unavailable));
195 .pipe(Effect.mapError(dispatchFailure));
195196 return { sequence: result.sequence };
196197 }),
197198 t3_pending_request_list: (input) =>
......@@ -219,7 +220,7 @@ export const layer = ThreadToolkit.toLayer({
219220 requestId: input.requestId,
220221 answers: input.answers,
221222 })
222 .pipe(Effect.mapError(unavailable));
223 .pipe(Effect.mapError(dispatchFailure));
223224 return { sequence: result.sequence };
224225 }),
225226 t3_queue_list: (input) =>
......@@ -300,7 +301,7 @@ export const layer = ThreadToolkit.toLayer({
300301 default:
301302 command = { ...common, type: `thread.${input.action}` };
302303 }
303 const result = yield* threads.dispatch(command).pipe(Effect.mapError(unavailable));
304 const result = yield* threads.dispatch(command).pipe(Effect.mapError(dispatchFailure));
304305 return { sequence: result.sequence };
305306 }),
306307});