1import * as NodeCrypto from "@effect/platform-node/NodeCrypto";
2import * as NodeServices from "@effect/platform-node/NodeServices";
3import { expect, it } from "@effect/vitest";
4import { AjvJsonSchemaValidator } from "@modelcontextprotocol/sdk/validation/ajv";
5import type { JsonSchemaType } from "@modelcontextprotocol/sdk/validation";
6import {
7 DEFAULT_SERVER_SETTINGS,
8 ChatImageAttachment,
9 CommandId,
10 EnvironmentId,
11 NodeId,
12 OrchestratorMcpThreadReadResult,
13 ProviderDriverKind,
14 ProviderInstanceId,
15 RunId,
16 ThreadId,
17 TurnItemId,
18 type OrchestrationV2ProjectedTurnItem,
19 type OrchestrationV2ThreadShell,
20} from "@t3tools/contracts";
21import * as Effect from "effect/Effect";
22import * as TestClock from "effect/testing/TestClock";
23import * as Layer from "effect/Layer";
24import * as Schema from "effect/Schema";
25import { McpAttachmentInput } from "./attachment/input.ts";
26import { McpSchema, McpServer, Tool } from "effect/ai";
27import { FetchHttpClient } from "effect/http";
28
29import {
30 OrchestratorCommandRejectedError,
31 OrchestratorDispatchError,
32 OrchestratorProjectionError,
33} from "../../orchestration-v2/Orchestrator.ts";
34
35import * as ServerSecretStore from "../../auth/ServerSecretStore.ts";
36import * as ServerConfig from "../../config.ts";
37import * as ProviderAdapterRegistry from "../../orchestration-v2/ProviderAdapterRegistry.ts";
38import * as ThreadLaunchService from "../../orchestration-v2/ThreadLaunchService.ts";
39import * as GitVcsDriver from "../../vcs/GitVcsDriver.ts";
40import * as ThreadManagement from "../../orchestration-v2/ThreadManagementService.ts";
41import * as ThreadSearch from "../../orchestration-v2/ThreadSearch.ts";
42import * as PreviewBrowser from "../../preview/PreviewBrowser.ts";
43import * as ProjectService from "../../project/ProjectService.ts";
44import * as ProviderRegistry from "../../provider/ProviderRegistry.ts";
45import * as SecretRequests from "../../secrets/SecretRequests.ts";
46import * as ScheduledTaskService from "../../scheduledTasks/ScheduledTaskService.ts";
47import * as ServerSettings from "../../serverSettings.ts";
48import * as McpHttpServer from "../McpHttpServer.ts";
49import * as McpInvocationContext from "../McpInvocationContext.ts";
50import * as McpToolAccessTestkit from "../McpToolAccess.testkit.ts";
51import { dispatchFailure } from "../threadAccess.ts";
52import { OrchestratorToolkit } from "./orchestrator/tools.ts";
53import { PreviewToolkit } from "./preview/tools.ts";
54import { PreviewControlsToolkit } from "./previewControls/tools.ts";
55import { EnvironmentToolkit } from "./environment/tools.ts";
56import * as EnvironmentHandlers from "./environment/handlers.ts";
57import { ProjectToolkit } from "./project/tools.ts";
58import { AttachmentToolkit } from "./attachment/tools.ts";
59import * as AttachmentHandlers from "./attachment/handlers.ts";
60import { ThreadToolkit } from "./thread/tools.ts";
61import { WorktreeToolkit } from "./worktree/tools.ts";
62import { DeviceToolkit } from "./device/tools.ts";
63
64const layerOrchestratorToolkit = McpHttpServer.layerOrchestratorToolkit.pipe(
65 Layer.provide(Layer.mock(ThreadLaunchService.ThreadLaunchService)({})),
66 Layer.provide(Layer.mock(GitVcsDriver.GitVcsDriver)({})),
67);
68
69// Effect returns a declared tool failure as `isError` with its encoded payload
70// as JSON text, never as `structuredContent`.
71const declaredFailure = (result: McpSchema.CallToolResult) => {
72 const text = result.content[0];
73 return result.isError === true && text?.type === "text" ? JSON.parse(text.text) : undefined;
74};
75import { PullRequestsToolkit } from "./pullRequests/tools.ts";
76import { HtmlToolkit } from "./html/tools.ts";
77import {
78 resolveT3McpToolDefinition,
79 resolveT3McpToolPresentation,
80 resolveT3McpToolSummaryAction,
81} from "@t3tools/shared/t3McpToolPresentation";
82import { htmlRenderFromToolItem } from "@t3tools/shared/toolOutput";
83
84const decodeMcpAttachmentInput = Schema.decodeUnknownEffect(McpAttachmentInput);
85const decodeThreadReadResult = Schema.decodeUnknownEffect(OrchestratorMcpThreadReadResult);
86
87// Registration asks for every service the thread tools declare; these cases call none that use them.
88const layerThreadToolkit = McpHttpServer.layerThreadToolkit.pipe(
89 Layer.provide(Layer.mock(ThreadSearch.ThreadSearch)({})),
90 Layer.provide(Layer.mock(ScheduledTaskService.ScheduledTaskService)({})),
91);
92
93it("publishes unique tool names with reference-free object-root inputs", () => {
94 const names = new Set<string>();
95 for (const toolkit of [
96 OrchestratorToolkit,
97 PreviewToolkit,
98 WorktreeToolkit,
99 ThreadToolkit,
100 AttachmentToolkit,
101 ProjectToolkit,
102 EnvironmentToolkit,
103 PreviewControlsToolkit,
104 DeviceToolkit,
105 PullRequestsToolkit,
106 HtmlToolkit,
107 ]) {
108 for (const tool of Object.values(toolkit.tools)) {
109 expect(names.has(tool.name)).toBe(false);
110 names.add(tool.name);
111 const schema = Tool.getJsonSchema(tool);
112 expect(schema).toMatchObject({ type: "object" });
113 // The published tool catalog must also work with providers without $ref support.
114 expect(JSON.stringify(schema), tool.name).not.toContain('"$ref"');
115 // Every published tool must have labels for its lifecycle, branding, and a summary.
116 const definition = resolveT3McpToolDefinition(tool.name);
117 expect(definition, tool.name).not.toBeNull();
118 expect(
119 definition?.labels.every((label) => label.trim().length > 0),
120 tool.name,
121 ).toBe(true);
122 for (const name of [tool.name, `mcp__t3-code__${tool.name}`, `T3-code.${tool.name}`]) {
123 expect(resolveT3McpToolPresentation(name)?.logo, name).toBe("t3-code");
124 expect(resolveT3McpToolSummaryAction(name), name).not.toBeNull();
125 }
126 }
127 }
128 expect(names.has("t3_thread_launch")).toBe(true);
129 expect(names.has("t3_thread_start")).toBe(false);
130});
131
132const threadId = ThreadId.make("mcp-core-thread");
133const scope: McpInvocationContext.McpInvocationScope = {
134 environmentId: EnvironmentId.make("mcp-core-environment"),
135 requestNamespace: "mcp-core-session",
136 thread: {
137 threadId,
138 providerSessionId: "mcp-core-session",
139 providerInstanceId: ProviderInstanceId.make("codex"),
140 },
141 client: undefined,
142 issuedAt: 0,
143 capabilities: new Set(["orchestration"]),
144};
145const client = McpSchema.McpServerClient.of({
146 clientId: 1,
147 protocolVersion: "2025-06-18",
148 clientCapabilities: {},
149 clientInfo: { name: "mcp-core", version: "1" },
150 initializePayload: {
151 protocolVersion: "2025-06-18",
152 capabilities: {},
153 clientInfo: { name: "mcp-core", version: "1" },
154 },
155 getClient: Effect.die("unused"),
156});
157
158it.effect("checks capability through the production registration", () =>
159 Effect.gen(function* () {
160 const server = yield* McpServer.McpServer;
161 expect(server.tools.some(({ tool }) => tool.name === "t3_thread_organize")).toBe(true);
162 const result = yield* server
163 .callTool({ name: "t3_thread_organize", arguments: { action: "pin" } })
164 .pipe(
165 Effect.provideService(McpInvocationContext.McpInvocationContext, {
166 ...scope,
167 capabilities: new Set<never>(),
168 }),
169 Effect.provideService(McpSchema.McpServerClient, client),
170 );
171 expect(declaredFailure(result)).toMatchObject({ code: "capability_denied" });
172 expect(result.isError).toBe(true);
173 expect(result.structuredContent).toBeUndefined();
174 }).pipe(
175 Effect.provide(
176 layerThreadToolkit.pipe(
177 Layer.provideMerge(McpServer.McpServer.layer),
178 Layer.provide(NodeCrypto.layer),
179 Layer.provide(McpToolAccessTestkit.liveThreadsLayer),
180 ),
181 ),
182 ),
183);
184
185it.effect("returns a bounded public failure without serializing storage causes", () =>
186 Effect.gen(function* () {
187 const server = yield* McpServer.McpServer;
188 const result = yield* server
189 .callTool({ name: "t3_thread_organize", arguments: { action: "pin" } })
190 .pipe(
191 Effect.provideService(McpInvocationContext.McpInvocationContext, scope),
192 Effect.provideService(McpSchema.McpServerClient, client),
193 );
194 expect(declaredFailure(result)).toEqual({
195 _tag: "OrchestratorMcpFailure",
196 code: "orchestration_error",
197 message: "The operation could not be completed.",
198 });
199 expect(result.isError).toBe(true);
200 expect(result.content).toEqual([
201 {
202 type: "text",
203 text: '{"_tag":"OrchestratorMcpFailure","code":"orchestration_error","message":"The operation could not be completed."}',
204 },
205 ]);
206 const definition = server.tools.find(({ tool }) => tool.name === "t3_thread_organize");
207 expect(definition?.tool.outputSchema).toBeDefined();
208 const validate = new AjvJsonSchemaValidator().getValidator(
209 definition!.tool.outputSchema! as JsonSchemaType,
210 );
211 expect(result.structuredContent).toBeUndefined();
212 expect(validate({ sequence: 1 }).valid).toBe(true);
213 expect(validate({ code: "orchestration_error" }).valid).toBe(false);
214 expect(validate({ sequence: "invalid" }).valid).toBe(false);
215 }).pipe(
216 Effect.provide(
217 layerThreadToolkit.pipe(
218 Layer.provideMerge(McpServer.McpServer.layer),
219 Layer.provide(NodeCrypto.layer),
220 Layer.provide(
221 Layer.mock(ThreadManagement.ThreadManagementService)({
222 getThreadShell: () =>
223 Effect.fail(
224 new OrchestratorProjectionError({
225 threadId,
226 cause: new Error("private-storage-path"),
227 }),
228 ),
229 }),
230 ),
231 ),
232 ),
233 ),
234);
235
236it("bounds public command rejections and redacts internal dispatch causes", () => {
237 const command = { commandId: CommandId.make("mcp-core-command"), commandType: "thread.settle" };
238 expect(
239 dispatchFailure(new OrchestratorDispatchError({ ...command, cause: "🙂".repeat(1001) }))
240 .message,
241 ).toBe("🙂".repeat(1000));
242 expect(
243 dispatchFailure(
244 new OrchestratorCommandRejectedError({ ...command, cause: "Run is not queued." }),
245 ).message,
246 ).toBe("Run is not queued.");
247 for (const cause of [
248 undefined,
249 "",
250 new Error("private-storage-path"),
251 { message: "private-storage-path" },
252 ]) {
253 expect(dispatchFailure(new OrchestratorDispatchError({ ...command, cause }))).toMatchObject({
254 code: "orchestration_error",
255 message: "The operation could not be completed.",
256 });
257 expect(
258 dispatchFailure(new OrchestratorCommandRejectedError({ ...command, cause })),
259 ).toMatchObject({
260 code: "orchestration_error",
261 message: "The operation could not be completed.",
262 });
263 }
264 expect(
265 dispatchFailure(new OrchestratorProjectionError({ threadId, cause: "private-storage-path" })),
266 ).toMatchObject({
267 code: "orchestration_error",
268 message: "The operation could not be completed.",
269 });
270});
271
272it.effect("returns an HTML render reference that Codex and Claude tool rows both carry", () =>
273 Effect.gen(function* () {
274 const server = yield* McpServer.McpServer;
275 const result = yield* server
276 .callTool({
277 name: "html_render",
278 arguments: { html: "<p>Revenue</p>", title: "Revenue", height: 240 },
279 })
280 .pipe(
281 Effect.provideService(McpInvocationContext.McpInvocationContext, scope),
282 Effect.provideService(McpSchema.McpServerClient, client),
283 );
284 const reference = htmlRenderFromToolItem({
285 toolName: "t3-code.html_render",
286 output: result.structuredContent,
287 });
288 expect(reference).toMatchObject({ title: "Revenue", height: 240 });
289 expect(
290 htmlRenderFromToolItem({ toolName: "mcp__t3-code__html_render", output: result.content }),
291 ).toEqual(reference);
292 }).pipe(
293 Effect.provide(
294 McpHttpServer.layerHtmlToolkit.pipe(
295 Layer.provideMerge(McpServer.McpServer.layer),
296 Layer.provide(PreviewBrowser.layer),
297 Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-mcp-html-render-" })),
298 Layer.provide(NodeServices.layer),
299 // The preview browser is not installed in a fresh home, so nothing downloads.
300 Layer.provide(FetchHttpClient.layer),
301 Layer.provide(
302 Layer.mock(ThreadManagement.ThreadManagementService)({
303 // A live run: publishing stores a page, so it needs the caller's active turn.
304 getThreadShell: () =>
305 Effect.succeed({
306 id: threadId,
307 deletedAt: null,
308 archivedAt: null,
309 activeRunId: RunId.make("mcp-core-run"),
310 providerInstanceId: ProviderInstanceId.make("codex"),
311 } as OrchestrationV2ThreadShell),
312 }),
313 ),
314 ),
315 ),
316 ),
317);
318
319it.effect("returns invalid parameter errors through the production registration", () =>
320 Effect.gen(function* () {
321 const server = yield* McpServer.McpServer;
322 const error = yield* server
323 .callTool({ name: "t3_thread_organize", arguments: { action: "invalid" } })
324 .pipe(
325 Effect.provideService(McpInvocationContext.McpInvocationContext, scope),
326 Effect.provideService(McpSchema.McpServerClient, client),
327 Effect.flip,
328 );
329 expect(error._tag).toBe("InvalidParams");
330 }).pipe(
331 Effect.provide(
332 layerThreadToolkit.pipe(
333 Layer.provideMerge(McpServer.McpServer.layer),
334 Layer.provide(NodeCrypto.layer),
335 Layer.provide(Layer.mock(ThreadManagement.ThreadManagementService)({})),
336 ),
337 ),
338 ),
339);
340
341it.effect("keeps unexpected handler defects private through the production registration", () =>
342 Effect.gen(function* () {
343 const server = yield* McpServer.McpServer;
344 const result = yield* server
345 .callTool({ name: "t3_thread_organize", arguments: { action: "pin" } })
346 .pipe(
347 Effect.provideService(McpInvocationContext.McpInvocationContext, scope),
348 Effect.provideService(McpSchema.McpServerClient, client),
349 );
350 expect(result.isError).toBe(true);
351 expect(result.structuredContent).toBeUndefined();
352 expect(result.content).toEqual([
353 { type: "text", text: "Tool execution failed due to an internal server error." },
354 ]);
355 }).pipe(
356 Effect.provide(
357 layerThreadToolkit.pipe(
358 Layer.provideMerge(McpServer.McpServer.layer),
359 Layer.provide(NodeCrypto.layer),
360 Layer.provide(
361 Layer.mock(ThreadManagement.ThreadManagementService)({
362 getThreadShell: () => Effect.die(new Error("private-storage-path")),
363 }),
364 ),
365 ),
366 ),
367 ),
368);
369
370it("keeps MCP preference output allowlisted and Unicode-bounded", () => {
371 const settings = {
372 ...DEFAULT_SERVER_SETTINGS,
373 privateCredential: "must-not-escape",
374 sourceControlWritingStyle: {
375 ...DEFAULT_SERVER_SETTINGS.sourceControlWritingStyle,
376 customInstructions: "🙂".repeat(4001),
377 },
378 };
379 const result = EnvironmentHandlers.preferences(settings);
380 expect(result).not.toHaveProperty("privateCredential");
381 expect(result).not.toHaveProperty("providers");
382 expect(result.sourceControlWritingStyle).toMatchObject({
383 customInstructions: "🙂".repeat(4000),
384 truncated: true,
385 });
386});
387
388it.effect("resolves reused attachment references from stored metadata", () =>
389 Effect.gen(function* () {
390 const stored = ChatImageAttachment.make({
391 type: "image",
392 id: "owned-image",
393 name: "original.png",
394 mimeType: "image/png",
395 sizeBytes: 12,
396 source: {
397 kind: "snap-shot",
398 capturedAt: "2026-09-10T00:00:00.000Z",
399 appName: "Terminal",
400 windowTitle: "Test",
401 accessibility: { format: "flat-text", text: "Stored context", truncated: false },
402 },
403 });
404 const forged = yield* decodeMcpAttachmentInput({
405 type: "image",
406 id: stored.id,
407 name: "changed.jpg",
408 mimeType: "image/jpeg",
409 sizeBytes: 99,
410 });
411 const result = yield* AttachmentHandlers.resolveAttachmentReferences([forged], [stored]);
412 expect(result).toEqual([stored]);
413 const failure = yield* AttachmentHandlers.resolveAttachmentReferences(
414 [{ ...forged, id: "other-image" }],
415 [stored],
416 ).pipe(Effect.flip);
417 expect(failure.code).toBe("invalid_request");
418 }),
419);
420
421const clientScope = (
422 access: McpInvocationContext.McpClientCaller["access"],
423): McpInvocationContext.McpInvocationScope => ({
424 environmentId: EnvironmentId.make("mcp-core-environment"),
425 requestNamespace: "client:session-1",
426 thread: undefined,
427 client: { sessionId: "session-1", label: "Claude Code", access },
428 issuedAt: 0,
429 capabilities: new Set(["orchestration", "worktree", "pull-requests"]),
430});
431
432it.effect("a client caller targets any thread within its ceiling and cannot act as a thread", () =>
433 Effect.gen(function* () {
434 const server = yield* McpServer.McpServer;
435 const call = (
436 name: string,
437 args: Record<string, unknown>,
438 invocation: McpInvocationContext.McpInvocationScope,
439 ) =>
440 server
441 .callTool({ name, arguments: args })
442 .pipe(
443 Effect.provideService(McpInvocationContext.McpInvocationContext, invocation),
444 Effect.provideService(McpSchema.McpServerClient, client),
445 );
446
447 const untargeted = yield* call("t3_thread_organize", { action: "pin" }, clientScope("auto"));
448 expect(declaredFailure(untargeted)).toMatchObject({ code: "target_required" });
449
450 const pinned = yield* call(
451 "t3_thread_organize",
452 { action: "pin", threadId: "other-project-thread" },
453 clientScope("auto"),
454 );
455 expect(pinned.isError).toBe(false);
456 expect(pinned.structuredContent).toMatchObject({ sequence: 7 });
457
458 const aboveCeiling = yield* call(
459 "t3_thread_organize",
460 { action: "pin", threadId: "other-project-thread" },
461 clientScope("approval-required"),
462 );
463 expect(declaredFailure(aboveCeiling)).toMatchObject({
464 code: "runtime_mode_escalation_denied",
465 });
466
467 const forked = yield* call(
468 "t3_thread_fork",
469 { sourcePoint: { type: "latest_stable" } },
470 clientScope("auto"),
471 );
472 expect(declaredFailure(forked)).toMatchObject({ code: "target_required" });
473 }).pipe(
474 Effect.provide(
475 layerThreadToolkit.pipe(
476 Layer.provideMerge(McpServer.McpServer.layer),
477 Layer.provide(NodeCrypto.layer),
478 Layer.provide(
479 Layer.mock(ThreadManagement.ThreadManagementService)({
480 getThreadShell: (id) =>
481 Effect.succeed(McpToolAccessTestkit.liveThreadShell(id, { runtimeMode: "auto" })),
482 getProjectThreadRecords: () =>
483 Effect.succeed({
484 thread: {
485 id: ThreadId.make("other-project-thread"),
486 projectId: "other-project",
487 runtimeMode: "auto",
488 interactionMode: "default",
489 deletedAt: null,
490 },
491 } as never),
492 dispatch: () => Effect.succeed({ sequence: 7, storedEvents: [] }),
493 }),
494 ),
495 ),
496 ),
497 ),
498);
499
500it.effect("a read-only client reads threads and is refused every write before it runs", () =>
501 Effect.gen(function* () {
502 const server = yield* McpServer.McpServer;
503 const call = (name: string, args: Record<string, unknown>) =>
504 server
505 .callTool({ name, arguments: args })
506 .pipe(
507 Effect.provideService(
508 McpInvocationContext.McpInvocationContext,
509 clientScope("read-only"),
510 ),
511 Effect.provideService(McpSchema.McpServerClient, client),
512 );
513
514 const configuration = yield* call("t3_thread_configuration", {
515 threadId: "other-project-thread",
516 });
517 expect(configuration.isError).toBe(false);
518 expect(configuration.structuredContent).toMatchObject({ runtimeMode: "auto" });
519
520 const pinned = yield* call("t3_thread_organize", {
521 action: "pin",
522 threadId: "other-project-thread",
523 });
524 expect(declaredFailure(pinned)).toMatchObject({ code: "capability_denied" });
525 expect(dispatched).toEqual([]);
526
527 const configure = yield* call("t3_thread_configure", {
528 threadId: "other-project-thread",
529 modelSelection: { instanceId: "codex", model: "gpt-5" },
530 });
531 expect(declaredFailure(configure)).toMatchObject({ code: "capability_denied" });
532 expect(dispatched).toEqual([]);
533 }).pipe(
534 Effect.provide(
535 layerThreadToolkit.pipe(
536 Layer.provideMerge(McpServer.McpServer.layer),
537 Layer.provide(NodeCrypto.layer),
538 Layer.provide(
539 Layer.mock(ThreadManagement.ThreadManagementService)({
540 getThreadShell: () =>
541 Effect.succeed({
542 id: ThreadId.make("other-project-thread"),
543 projectId: "other-project",
544 deletedAt: null,
545 } as never),
546 getProjectThreadRecords: () =>
547 Effect.succeed({
548 thread: {
549 id: ThreadId.make("other-project-thread"),
550 projectId: "other-project",
551 modelSelection: { instanceId: "codex", model: "gpt-5" },
552 runtimeMode: "auto",
553 interactionMode: "default",
554 deletedAt: null,
555 },
556 } as never),
557 dispatch: () =>
558 Effect.sync(() => {
559 dispatched.push("dispatch");
560 return { sequence: 7 } as never;
561 }),
562 }),
563 ),
564 ),
565 ),
566 ),
567);
568const dispatched: Array<string> = [];
569
570it.effect("refuses act-as-caller tools to a client caller", () =>
571 Effect.gen(function* () {
572 const server = yield* McpServer.McpServer;
573 const result = yield* server
574 .callTool({
575 name: "delegate_task",
576 arguments: { task: "Review", mode: "async" },
577 })
578 .pipe(
579 Effect.provideService(
580 McpInvocationContext.McpInvocationContext,
581 clientScope("full-access"),
582 ),
583 Effect.provideService(McpSchema.McpServerClient, client),
584 );
585 expect(declaredFailure(result)).toMatchObject({ code: "thread_credential_required" });
586 }).pipe(
587 Effect.provide(
588 layerOrchestratorToolkit.pipe(
589 Layer.provideMerge(McpServer.McpServer.layer),
590 Layer.provide(NodeCrypto.layer),
591 Layer.provide(Layer.mock(ThreadManagement.ThreadManagementService)({})),
592 Layer.provide(ServerSettings.layerTest()),
593 Layer.provide(Layer.mock(ProviderRegistry.ProviderRegistry)({})),
594 Layer.provide(Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({})),
595 Layer.provide(Layer.mock(ScheduledTaskService.ScheduledTaskService)({})),
596 Layer.provide(Layer.mock(ProjectService.ProjectService)({})),
597 Layer.provide(Layer.mock(SecretRequests.SecretRequests)({})),
598 ),
599 ),
600 ),
601);
602
603it.effect("a caller cannot rewrite a scheduled task that runs above its own modes", () =>
604 Effect.gen(function* () {
605 const server = yield* McpServer.McpServer;
606 const call = (name: string, args: Record<string, unknown>) =>
607 server
608 .callTool({ name, arguments: args })
609 .pipe(
610 Effect.provideService(McpInvocationContext.McpInvocationContext, clientScope("auto")),
611 Effect.provideService(McpSchema.McpServerClient, client),
612 );
613 const update = yield* call("update_scheduled_task", {
614 scheduledTaskId: "task-full-access",
615 prompt: "Run something else",
616 });
617 expect(declaredFailure(update)).toMatchObject({ code: "runtime_mode_escalation_denied" });
618 const remove = yield* call("delete_scheduled_task", { scheduledTaskId: "task-full-access" });
619 expect(declaredFailure(remove)).toMatchObject({ code: "runtime_mode_escalation_denied" });
620 const allowed = yield* call("update_scheduled_task", {
621 scheduledTaskId: "task-auto",
622 enabled: false,
623 });
624 expect(allowed.isError).toBe(false);
625 }).pipe(
626 Effect.provide(
627 layerOrchestratorToolkit.pipe(
628 Layer.provideMerge(McpServer.McpServer.layer),
629 Layer.provide(NodeCrypto.layer),
630 Layer.provide(Layer.mock(ThreadManagement.ThreadManagementService)({})),
631 Layer.provide(ServerSettings.layerTest()),
632 Layer.provide(Layer.mock(ProviderRegistry.ProviderRegistry)({})),
633 Layer.provide(Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({})),
634 Layer.provide(
635 Layer.mock(ScheduledTaskService.ScheduledTaskService)({
636 list: () =>
637 Effect.succeed({
638 tasks: [
639 scheduledTask("task-full-access", "full-access"),
640 scheduledTask("task-auto", "auto"),
641 ],
642 }),
643 upsert: (input) =>
644 Effect.succeed({
645 task: { ...(scheduledTask(input.id ?? "task-auto", "auto") as object), ...input },
646 } as never),
647 }),
648 ),
649 Layer.provide(Layer.mock(ProjectService.ProjectService)({})),
650 Layer.provide(Layer.mock(SecretRequests.SecretRequests)({})),
651 ),
652 ),
653 ),
654);
655
656function scheduledTask(id: string, runtimeMode: "auto" | "full-access"): never {
657 return {
658 id,
659 title: id,
660 prompt: "Check the build",
661 enabled: true,
662 projectId: "project-a",
663 threadId: null,
664 schedule: { type: "interval", everyMs: 3_600_000 },
665 workspaceStrategy: { type: "worktree", baseRef: "main", startFromOrigin: true },
666 modelSelection: { instanceId: "codex", model: "gpt-5" },
667 runtimeMode,
668 interactionMode: "default",
669 createdBy: "user",
670 creationSource: "web",
671 nextRunAt: null,
672 lastRunStatus: "never",
673 lastRunAt: null,
674 lastRunThreadId: null,
675 lastRunError: null,
676 runCount: 0,
677 createdAt: "2026-10-01T00:00:00.000Z",
678 updatedAt: "2026-10-01T00:00:00.000Z",
679 } as never;
680}
681
682it.effect.each(["t3_thread_interrupt", "t3_workflow_stop"])(
683 "a caller cannot use %s on a thread that runs above its own modes",
684 (toolName) =>
685 Effect.gen(function* () {
686 const server = yield* McpServer.McpServer;
687 const result = yield* server
688 .callTool({
689 name: toolName,
690 arguments: {
691 threadId: "full-access-thread",
692 ...(toolName === "t3_workflow_stop" ? { subagentId: "workflow-coordinator" } : {}),
693 },
694 })
695 .pipe(
696 Effect.provideService(McpInvocationContext.McpInvocationContext, clientScope("auto")),
697 Effect.provideService(McpSchema.McpServerClient, client),
698 );
699 expect(declaredFailure(result)).toMatchObject({ code: "runtime_mode_escalation_denied" });
700 }).pipe(
701 Effect.provide(
702 layerOrchestratorToolkit.pipe(
703 Layer.provideMerge(McpServer.McpServer.layer),
704 Layer.provide(NodeCrypto.layer),
705 Layer.provide(
706 Layer.mock(ThreadManagement.ThreadManagementService)({
707 getThreadShell: () =>
708 Effect.succeed({
709 projectId: "project-a",
710 deletedAt: null,
711 runtimeMode: "full-access",
712 interactionMode: "default",
713 } as never),
714 getProjectThreadRecords: () =>
715 Effect.succeed({
716 thread: {
717 id: ThreadId.make("full-access-thread"),
718 projectId: "project-a",
719 runtimeMode: "full-access",
720 interactionMode: "default",
721 deletedAt: null,
722 },
723 runs: [],
724 } as never),
725 stopWorkflow: () => Effect.die("workflow stop must not dispatch above the ceiling"),
726 interruptThread: () => Effect.die("interrupt must not dispatch above the ceiling"),
727 }),
728 ),
729 Layer.provide(ServerSettings.layerTest()),
730 Layer.provide(Layer.mock(ProviderRegistry.ProviderRegistry)({})),
731 Layer.provide(Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({})),
732 Layer.provide(Layer.mock(ScheduledTaskService.ScheduledTaskService)({})),
733 Layer.provide(Layer.mock(ProjectService.ProjectService)({})),
734 Layer.provide(Layer.mock(SecretRequests.SecretRequests)({})),
735 ),
736 ),
737 ),
738);
739
740it.effect("discovers workflow stop targets and requires orchestration capability", () => {
741 const threadId = ThreadId.make("workflow-parent");
742 const shell = McpToolAccessTestkit.liveThreadShell(threadId);
743 const subagentId = NodeId.make("workflow-coordinator");
744 const itemId = TurnItemId.make("workflow-coordinator-activity");
745 const row: OrchestrationV2ProjectedTurnItem = {
746 position: 0,
747 visibility: "local",
748 sourceThreadId: threadId,
749 sourceItemId: itemId,
750 item: {
751 id: itemId,
752 threadId,
753 runId: shell.activeRunId,
754 nodeId: subagentId,
755 providerThreadId: null,
756 providerTurnId: null,
757 nativeItemRef: null,
758 parentItemId: null,
759 ordinal: 0,
760 status: "running",
761 title: "Workflow: checkout review",
762 startedAt: shell.createdAt,
763 completedAt: null,
764 updatedAt: shell.updatedAt,
765 type: "subagent",
766 subagentId,
767 origin: "provider_native",
768 driver: ProviderDriverKind.make("claudeAgent"),
769 providerInstanceId: ProviderInstanceId.make("claudeAgent"),
770 childThreadId: ThreadId.make("workflow-child"),
771 prompt: "Review checkout",
772 result: null,
773 },
774 };
775 const stopped: Array<{ readonly threadId: ThreadId; readonly subagentId: string }> = [];
776 return Effect.gen(function* () {
777 const server = yield* McpServer.McpServer;
778 const scope = clientScope("full-access");
779 const read = yield* server
780 .callTool({ name: "t3_thread_read", arguments: { threadId, view: "activity" } })
781 .pipe(
782 Effect.provideService(McpInvocationContext.McpInvocationContext, scope),
783 Effect.provideService(McpSchema.McpServerClient, client),
784 );
785 expect(read.isError).toBe(false);
786 const result = yield* decodeThreadReadResult(read.structuredContent);
787 expect(result.items[0]?.subagentId).toBe(subagentId);
788 const call = (invocation: McpInvocationContext.McpInvocationScope) =>
789 server
790 .callTool({
791 name: "t3_workflow_stop",
792 arguments: { threadId, subagentId: result.items[0]?.subagentId },
793 })
794 .pipe(
795 Effect.provideService(McpInvocationContext.McpInvocationContext, invocation),
796 Effect.provideService(McpSchema.McpServerClient, client),
797 );
798 const denied = yield* call({ ...scope, capabilities: new Set(["worktree"]) });
799 expect(declaredFailure(denied)).toMatchObject({ code: "capability_denied" });
800 expect(stopped).toEqual([]);
801 const allowed = yield* call(scope);
802 expect(allowed.isError).toBe(false);
803 expect(stopped).toEqual([{ threadId: "workflow-parent", subagentId: "workflow-coordinator" }]);
804 }).pipe(
805 Effect.provide(
806 layerOrchestratorToolkit.pipe(
807 Layer.provideMerge(McpServer.McpServer.layer),
808 Layer.provide(NodeCrypto.layer),
809 Layer.provide(
810 Layer.mock(ThreadManagement.ThreadManagementService)({
811 getThreadShell: (id) => Effect.succeed(McpToolAccessTestkit.liveThreadShell(id)),
812 getProjectThreadRecords: () =>
813 Effect.succeed(McpToolAccessTestkit.idleThreadProjection(shell)),
814 getTimelinePage: () => Effect.succeed({ items: [row], totalItems: 1, hasMore: false }),
815 stopWorkflow: (input) =>
816 Effect.sync(() => {
817 stopped.push(input);
818 }),
819 }),
820 ),
821 Layer.provide(ServerSettings.layerTest()),
822 Layer.provide(Layer.mock(ProviderRegistry.ProviderRegistry)({})),
823 Layer.provide(Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({})),
824 Layer.provide(Layer.mock(ScheduledTaskService.ScheduledTaskService)({})),
825 Layer.provide(Layer.mock(ProjectService.ProjectService)({})),
826 Layer.provide(Layer.mock(SecretRequests.SecretRequests)({})),
827 ),
828 ),
829 );
830});
831
832it.effect("only the caller that prepared a pending upload can discard it", () =>
833 Effect.gen(function* () {
834 const server = yield* McpServer.McpServer;
835 const call = (
836 name: string,
837 args: Record<string, unknown>,
838 invocation: McpInvocationContext.McpInvocationScope,
839 ) =>
840 server
841 .callTool({ name, arguments: args })
842 .pipe(
843 Effect.provideService(McpInvocationContext.McpInvocationContext, invocation),
844 Effect.provideService(McpSchema.McpServerClient, client),
845 );
846 const prepared = yield* call(
847 "t3_attachment_prepare_upload",
848 { upload: { name: "shot.png", mimeType: "image/png", sizeBytes: 4 } },
849 scope,
850 );
851 const { attachmentId } = prepared.structuredContent as { readonly attachmentId: string };
852 const otherThread = {
853 ...scope,
854 requestNamespace: "other-session",
855 thread: { ...scope.thread!, threadId: ThreadId.make("other-thread") },
856 };
857
858 const refused = yield* call("t3_attachment_discard", { attachmentId }, otherThread);
859 expect(declaredFailure(refused)).toMatchObject({ code: "invalid_request" });
860
861 // Another prepare a day later keeps it: the file outlives its URL's 24 hours
862 // by as long as the upload took, up to the URL's own lifetime.
863 yield* TestClock.adjust(24 * 60 * 60 * 1000 + 5 * 60_000);
864 yield* call(
865 "t3_attachment_prepare_upload",
866 { upload: { name: "later.png", mimeType: "image/png", sizeBytes: 4 } },
867 scope,
868 );
869
870 // A new provider session of the same thread still owns the upload.
871 const discarded = yield* call(
872 "t3_attachment_discard",
873 { attachmentId },
874 { ...scope, requestNamespace: "mcp-core-session-2" },
875 );
876 expect(discarded.isError).toBe(false);
877 }).pipe(
878 Effect.provide(
879 McpHttpServer.layerAttachmentToolkit.pipe(
880 Layer.provideMerge(McpServer.McpServer.layer),
881 Layer.provide(McpToolAccessTestkit.liveThreadsLayer),
882 Layer.provide(ServerSecretStore.layer),
883 Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-mcp-attachment-" })),
884 Layer.provide(NodeServices.layer),
885 ),
886 ),
887 ),
888);