authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-10-06 14:09:35-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-06 14:09:35-07:00
log2f85686d9dca5ec98c892a2a26c321e09d746d71
tree7621c9cfdc76cbe6a10f53d0c3856d81783741cd
parentf21d6da51c9aa78ea12e7f13a378f55f1e63cb38
signature Signed by PGP key B5690EEEBB952194

feat(server): every T3 MCP tool declares who may call it (#16335)

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

48 files changed, 3043 insertions(+), 680 deletions(-)

apps/server/src/mcp/McpDeviceToolkit.test.ts+3
...@@ -14,6 +14,7 @@ import * as ServerConfig from "../config.ts";...@@ -14,6 +14,7 @@ import * as ServerConfig from "../config.ts";
14import * as DeviceService from "../device/DeviceService.ts";14import * as DeviceService from "../device/DeviceService.ts";
15import * as McpHttpServer from "./McpHttpServer.ts";15import * as McpHttpServer from "./McpHttpServer.ts";
16import * as McpInvocationContext from "./McpInvocationContext.ts";16import * as McpInvocationContext from "./McpInvocationContext.ts";
17import * as McpToolAccessTestkit from "./McpToolAccess.testkit.ts";
1718
18const environmentId = EnvironmentId.make("environment-device-test");19const environmentId = EnvironmentId.make("environment-device-test");
19const threadId = ThreadId.make("thread-device-test");20const threadId = ThreadId.make("thread-device-test");
...@@ -102,6 +103,7 @@ const layerDeviceServiceMock = Layer.mock(DeviceService.DeviceService)({...@@ -102,6 +103,7 @@ const layerDeviceServiceMock = Layer.mock(DeviceService.DeviceService)({
102103
103const layerTest = McpHttpServer.layerDeviceToolkit.pipe(104const layerTest = McpHttpServer.layerDeviceToolkit.pipe(
104 Layer.provideMerge(McpServer.McpServer.layer),105 Layer.provideMerge(McpServer.McpServer.layer),
106 Layer.provideMerge(McpToolAccessTestkit.liveThreadsLayer),
105 Layer.provideMerge(layerDeviceServiceMock),107 Layer.provideMerge(layerDeviceServiceMock),
106 Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-mcp-device-toolkit-test-" })),108 Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-mcp-device-toolkit-test-" })),
107 Layer.provide(NodeServices.layer),109 Layer.provide(NodeServices.layer),
...@@ -172,6 +174,7 @@ it.effect("rejects unavailable agent access before booting or opening a device",...@@ -172,6 +174,7 @@ it.effect("rejects unavailable agent access before booting or opening a device",
172 Effect.provide(174 Effect.provide(
173 McpHttpServer.layerDeviceToolkit.pipe(175 McpHttpServer.layerDeviceToolkit.pipe(
174 Layer.provideMerge(McpServer.McpServer.layer),176 Layer.provideMerge(McpServer.McpServer.layer),
177 Layer.provideMerge(McpToolAccessTestkit.liveThreadsLayer),
175 Layer.provide(layerUnavailable),178 Layer.provide(layerUnavailable),
176 Layer.provide(NodeServices.layer),179 Layer.provide(NodeServices.layer),
177 ),180 ),
apps/server/src/mcp/McpHttpServer.test.ts+3
...@@ -17,6 +17,7 @@ import { HttpBody, HttpClient, HttpRouter, HttpServerResponse } from "effect/htt...@@ -17,6 +17,7 @@ import { HttpBody, HttpClient, HttpRouter, HttpServerResponse } from "effect/htt
17import * as ProjectService from "../project/ProjectService.ts";17import * as ProjectService from "../project/ProjectService.ts";
18import * as ServerConfig from "../config.ts";18import * as ServerConfig from "../config.ts";
19import * as McpHttpServer from "./McpHttpServer.ts";19import * as McpHttpServer from "./McpHttpServer.ts";
20import * as McpToolAccessTestkit from "./McpToolAccess.testkit.ts";
20import * as McpInvocationContext from "./McpInvocationContext.ts";21import * as McpInvocationContext from "./McpInvocationContext.ts";
21import * as PreviewAutomationBroker from "./PreviewAutomationBroker.ts";22import * as PreviewAutomationBroker from "./PreviewAutomationBroker.ts";
2223
...@@ -51,12 +52,14 @@ const client = McpSchema.McpServerClient.of({...@@ -51,12 +52,14 @@ const client = McpSchema.McpServerClient.of({
51});52});
52const layerTest = McpHttpServer.layerPreviewToolkit.pipe(53const layerTest = McpHttpServer.layerPreviewToolkit.pipe(
53 Layer.provideMerge(McpServer.McpServer.layer),54 Layer.provideMerge(McpServer.McpServer.layer),
55 Layer.provideMerge(McpToolAccessTestkit.liveThreadsLayer),
54 Layer.provideMerge(PreviewAutomationBroker.layer),56 Layer.provideMerge(PreviewAutomationBroker.layer),
55 Layer.provideMerge(ServerConfig.layerTest(process.cwd(), { prefix: "t3-mcp-http-server-test-" })),57 Layer.provideMerge(ServerConfig.layerTest(process.cwd(), { prefix: "t3-mcp-http-server-test-" })),
56 Layer.provideMerge(NodeServices.layer),58 Layer.provideMerge(NodeServices.layer),
57);59);
58const layerPullRequestsTest = McpHttpServer.layerPullRequestsToolkit.pipe(60const layerPullRequestsTest = McpHttpServer.layerPullRequestsToolkit.pipe(
59 Layer.provideMerge(McpServer.McpServer.layer),61 Layer.provideMerge(McpServer.McpServer.layer),
62 Layer.provideMerge(McpToolAccessTestkit.liveThreadsLayer),
60 Layer.provide(63 Layer.provide(
61 Layer.mergeAll(64 Layer.mergeAll(
62 Layer.mock(ProjectService.ProjectService)({}),65 Layer.mock(ProjectService.ProjectService)({}),
apps/server/src/mcp/McpHttpServer.ts+68-36
...@@ -11,7 +11,7 @@ import * as Schema from "effect/Schema";...@@ -11,7 +11,7 @@ import * as Schema from "effect/Schema";
11import * as Sink from "effect/Sink";11import * as Sink from "effect/Sink";
12import * as Stream from "effect/Stream";12import * as Stream from "effect/Stream";
13import type * as Types from "effect/Types";13import type * as Types from "effect/Types";
14import { AiError, McpProtocol, McpSchema, McpServer, Tool } from "effect/ai";14import { AiError, McpProtocol, McpSchema, McpServer, Tool, type Toolkit } from "effect/ai";
15import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/http";15import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/http";
16import { OrchestratorMcpFailure, PreviewAutomationError } from "@t3tools/contracts";16import { OrchestratorMcpFailure, PreviewAutomationError } from "@t3tools/contracts";
1717
...@@ -19,7 +19,9 @@ import packageJson from "../../package.json" with { type: "json" };...@@ -19,7 +19,9 @@ import packageJson from "../../package.json" with { type: "json" };
19import * as ServerConfig from "../config.ts";19import * as ServerConfig from "../config.ts";
20import * as DeviceService from "../device/DeviceService.ts";20import * as DeviceService from "../device/DeviceService.ts";
21import * as HtmlRender from "../htmlRender/HtmlRender.ts";21import * as HtmlRender from "../htmlRender/HtmlRender.ts";
22import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts";
22import * as McpInvocationContext from "./McpInvocationContext.ts";23import * as McpInvocationContext from "./McpInvocationContext.ts";
24import * as McpToolAccess from "./McpToolAccess.ts";
23import * as OrchestratorMcpService from "./OrchestratorMcpService.ts";25import * as OrchestratorMcpService from "./OrchestratorMcpService.ts";
24import { PreviewControlsToolkit } from "./toolkits/previewControls/tools.ts";26import { PreviewControlsToolkit } from "./toolkits/previewControls/tools.ts";
25import * as PreviewControlsHandlers from "./toolkits/previewControls/handlers.ts";27import * as PreviewControlsHandlers from "./toolkits/previewControls/handlers.ts";
...@@ -396,6 +398,7 @@ const previewSnapshotFailure = <E>(cause: Cause.Cause<E>) => {...@@ -396,6 +398,7 @@ const previewSnapshotFailure = <E>(cause: Cause.Cause<E>) => {
396const registerPreviewSnapshot = Effect.fn("McpHttpServer.registerPreviewSnapshot")(function* () {398const registerPreviewSnapshot = Effect.fn("McpHttpServer.registerPreviewSnapshot")(function* () {
397 const server = yield* McpServer.McpServer;399 const server = yield* McpServer.McpServer;
398 const broker = yield* PreviewAutomationBroker.PreviewAutomationBroker;400 const broker = yield* PreviewAutomationBroker.PreviewAutomationBroker;
401 const threads = yield* ThreadManagementService.ThreadManagementService;
399 // The MCP tool runner only supplies the client, so hand the save path its services here.402 // The MCP tool runner only supplies the client, so hand the save path its services here.
400 const saveServices = yield* Effect.context<403 const saveServices = yield* Effect.context<
401 ServerConfig.ServerConfig | FileSystem.FileSystem | Path.Path | Crypto.Crypto404 ServerConfig.ServerConfig | FileSystem.FileSystem | Path.Path | Crypto.Crypto
...@@ -430,6 +433,7 @@ const registerPreviewSnapshot = Effect.fn("McpHttpServer.registerPreviewSnapshot...@@ -430,6 +433,7 @@ const registerPreviewSnapshot = Effect.fn("McpHttpServer.registerPreviewSnapshot
430 Stream.run(Sink.last()),433 Stream.run(Sink.last()),
431 Effect.flatMap(Effect.fromOption),434 Effect.flatMap(Effect.fromOption),
432 Effect.provideService(PreviewAutomationBroker.PreviewAutomationBroker, broker),435 Effect.provideService(PreviewAutomationBroker.PreviewAutomationBroker, broker),
436 Effect.provideService(ThreadManagementService.ThreadManagementService, threads),
433 Effect.provideService(McpInvocationContext.McpInvocationContext, invocation),437 Effect.provideService(McpInvocationContext.McpInvocationContext, invocation),
434 Effect.flatMap(({ encodedResult }) =>438 Effect.flatMap(({ encodedResult }) =>
435 Effect.gen(function* () {439 Effect.gen(function* () {
...@@ -655,6 +659,7 @@ const registerImageTool = <T extends Tool.Any, E, R>(...@@ -655,6 +659,7 @@ const registerImageTool = <T extends Tool.Any, E, R>(
655659
656const registerDeviceScreenshot = Effect.fn("McpHttpServer.registerDeviceScreenshot")(function* () {660const registerDeviceScreenshot = Effect.fn("McpHttpServer.registerDeviceScreenshot")(function* () {
657 const devices = yield* DeviceService.DeviceService;661 const devices = yield* DeviceService.DeviceService;
662 const threads = yield* ThreadManagementService.ThreadManagementService;
658 const built = yield* DeviceScreenshotToolkit;663 const built = yield* DeviceScreenshotToolkit;
659 yield* registerImageTool(664 yield* registerImageTool(
660 DeviceScreenshotTool,665 DeviceScreenshotTool,
...@@ -662,7 +667,11 @@ const registerDeviceScreenshot = Effect.fn("McpHttpServer.registerDeviceScreensh...@@ -662,7 +667,11 @@ const registerDeviceScreenshot = Effect.fn("McpHttpServer.registerDeviceScreensh
662 built667 built
663 .handle("device_screenshot", payload)668 .handle("device_screenshot", payload)
664 .pipe(Stream.unwrap, Stream.run(Sink.last()), Effect.flatMap(Effect.fromOption)),669 .pipe(Stream.unwrap, Stream.run(Sink.last()), Effect.flatMap(Effect.fromOption)),
665 (effect) => effect.pipe(Effect.provideService(DeviceService.DeviceService, devices)),670 (effect) =>
671 effect.pipe(
672 Effect.provideService(DeviceService.DeviceService, devices),
673 Effect.provideService(ThreadManagementService.ThreadManagementService, threads),
674 ),
666 "screenshot",675 "screenshot",
667 "Device screenshot failed.",676 "Device screenshot failed.",
668 );677 );
...@@ -672,6 +681,7 @@ const isOrchestratorMcpFailure = Schema.is(OrchestratorMcpFailure);...@@ -672,6 +681,7 @@ const isOrchestratorMcpFailure = Schema.is(OrchestratorMcpFailure);
672681
673const registerHtmlPreview = Effect.fn("McpHttpServer.registerHtmlPreview")(function* () {682const registerHtmlPreview = Effect.fn("McpHttpServer.registerHtmlPreview")(function* () {
674 const htmlRender = yield* HtmlRender.HtmlRender;683 const htmlRender = yield* HtmlRender.HtmlRender;
684 const threads = yield* ThreadManagementService.ThreadManagementService;
675 const built = yield* HtmlPreviewToolkit;685 const built = yield* HtmlPreviewToolkit;
676 yield* registerImageTool(686 yield* registerImageTool(
677 HtmlPreviewTool,687 HtmlPreviewTool,
...@@ -679,7 +689,11 @@ const registerHtmlPreview = Effect.fn("McpHttpServer.registerHtmlPreview")(funct...@@ -679,7 +689,11 @@ const registerHtmlPreview = Effect.fn("McpHttpServer.registerHtmlPreview")(funct
679 built689 built
680 .handle("html_preview", payload)690 .handle("html_preview", payload)
681 .pipe(Stream.unwrap, Stream.run(Sink.last()), Effect.flatMap(Effect.fromOption)),691 .pipe(Stream.unwrap, Stream.run(Sink.last()), Effect.flatMap(Effect.fromOption)),
682 (effect) => effect.pipe(Effect.provideService(HtmlRender.HtmlRender, htmlRender)),692 (effect) =>
693 effect.pipe(
694 Effect.provideService(HtmlRender.HtmlRender, htmlRender),
695 Effect.provideService(ThreadManagementService.ThreadManagementService, threads),
696 ),
683 "preview",697 "preview",
684 // Parameter errors and HTML render errors are both written by the server for the agent.698 // Parameter errors and HTML render errors are both written by the server for the agent.
685 (error) =>699 (error) =>
...@@ -689,17 +703,34 @@ const registerHtmlPreview = Effect.fn("McpHttpServer.registerHtmlPreview")(funct...@@ -689,17 +703,34 @@ const registerHtmlPreview = Effect.fn("McpHttpServer.registerHtmlPreview")(funct
689 );703 );
690});704});
691705
706/**
707 * `McpServer.toolkit` for handlers that declared their access (see
708 * `McpToolAccess`). Every toolkit on `/mcp` registers through this.
709 */
710export const toolkitRegistration = <Tools extends Record<string, Tool.Any>, EX, RX>(
711 toolkit: Toolkit.Toolkit<Tools>,
712 handlers: McpToolAccess.HandlersLayer<Tools, EX, RX>,
713) => McpServer.toolkit(toolkit).pipe(Layer.provide(McpToolAccess.HandlersLayer.layer(handlers)));
714
715/** A hand-registered tool, also only with handlers that declared their access. */
716const imageToolRegistration = <Tools extends Record<string, Tool.Any>, A, E, R, EX, RX>(
717 register: Effect.Effect<A, E, R>,
718 handlers: McpToolAccess.HandlersLayer<Tools, EX, RX>,
719) => Layer.effectDiscard(register).pipe(Layer.provide(McpToolAccess.HandlersLayer.layer(handlers)));
720
692export const layerHtmlToolkit = Layer.mergeAll(721export const layerHtmlToolkit = Layer.mergeAll(
693 McpServer.toolkit(HtmlRenderToolkit).pipe(Layer.provide(HtmlHandlers.layerRender)),722 toolkitRegistration(HtmlRenderToolkit, HtmlHandlers.layerRender),
694 Layer.effectDiscard(registerHtmlPreview()).pipe(Layer.provide(HtmlHandlers.layerPreview)),723 imageToolRegistration(registerHtmlPreview(), HtmlHandlers.layerPreview),
695).pipe(Layer.provide(HtmlRender.layer));724).pipe(Layer.provide(HtmlRender.layer));
696725
697const layerPreviewStandardToolkitRegistration = McpServer.toolkit(PreviewStandardToolkit).pipe(726const layerPreviewStandardToolkitRegistration = toolkitRegistration(
698 Layer.provide(PreviewHandlers.layerStandard),727 PreviewStandardToolkit,
728 PreviewHandlers.layerStandard,
699);729);
700730
701const layerPreviewSnapshotRegistration = Layer.effectDiscard(registerPreviewSnapshot()).pipe(731const layerPreviewSnapshotRegistration = imageToolRegistration(
702 Layer.provide(PreviewHandlers.layerSnapshot),732 registerPreviewSnapshot(),
733 PreviewHandlers.layerSnapshot,
703);734);
704735
705export const layerPreviewToolkit = Layer.mergeAll(736export const layerPreviewToolkit = Layer.mergeAll(
...@@ -707,47 +738,48 @@ export const layerPreviewToolkit = Layer.mergeAll(...@@ -707,47 +738,48 @@ export const layerPreviewToolkit = Layer.mergeAll(
707 layerPreviewSnapshotRegistration,738 layerPreviewSnapshotRegistration,
708);739);
709740
710export const layerOrchestratorToolkit = McpServer.toolkit(OrchestratorToolkit).pipe(741export const layerOrchestratorToolkit = toolkitRegistration(
711 Layer.provide(OrchestratorHandlers.layer),742 OrchestratorToolkit,
712 Layer.provide(OrchestratorMcpService.layer),743 OrchestratorHandlers.layer,
713 Layer.provide(ThreadMetadataMcpService.layer),744).pipe(Layer.provide(OrchestratorMcpService.layer), Layer.provide(ThreadMetadataMcpService.layer));
714);
715745
716export const layerThreadToolkit = McpServer.toolkit(ThreadToolkit).pipe(746export const layerThreadToolkit = toolkitRegistration(ThreadToolkit, ThreadHandlers.layer);
717 Layer.provide(ThreadHandlers.layer),
718);
719747
720const layerWorktreeToolkitRegistration = McpServer.toolkit(WorktreeToolkit).pipe(748const layerWorktreeToolkitRegistration = toolkitRegistration(
721 Layer.provide(WorktreeHandlers.layer),749 WorktreeToolkit,
722 Layer.provide(WorktreeMcpService.layer),750 WorktreeHandlers.layer,
723);751).pipe(Layer.provide(WorktreeMcpService.layer));
724752
725const layerPreviewControlsRegistration = McpServer.toolkit(PreviewControlsToolkit).pipe(753const layerPreviewControlsRegistration = toolkitRegistration(
726 Layer.provide(PreviewControlsHandlers.layer),754 PreviewControlsToolkit,
755 PreviewControlsHandlers.layer,
727);756);
728757
729const layerEnvironmentRegistration = McpServer.toolkit(EnvironmentToolkit).pipe(758const layerEnvironmentRegistration = toolkitRegistration(
730 Layer.provide(EnvironmentHandlers.layer),759 EnvironmentToolkit,
760 EnvironmentHandlers.layer,
731);761);
732762
733const layerProjectRegistration = McpServer.toolkit(ProjectToolkit).pipe(763const layerProjectRegistration = toolkitRegistration(ProjectToolkit, ProjectHandlers.layer);
734 Layer.provide(ProjectHandlers.layer),
735);
736764
737const layerAttachmentRegistration = McpServer.toolkit(AttachmentToolkit).pipe(765const layerAttachmentRegistration = toolkitRegistration(
738 Layer.provide(AttachmentHandlers.layer),766 AttachmentToolkit,
767 AttachmentHandlers.layer,
739);768);
740769
741export const layerPullRequestsToolkit = McpServer.toolkit(PullRequestsToolkit).pipe(770export const layerPullRequestsToolkit = toolkitRegistration(
742 Layer.provide(PullRequestsHandlers.layer),771 PullRequestsToolkit,
772 PullRequestsHandlers.layer,
743);773);
744774
745const layerDeviceStandardToolkitRegistration = McpServer.toolkit(DeviceStandardToolkit).pipe(775const layerDeviceStandardToolkitRegistration = toolkitRegistration(
746 Layer.provide(DeviceHandlers.layerStandard),776 DeviceStandardToolkit,
777 DeviceHandlers.layerStandard,
747);778);
748779
749const layerDeviceScreenshotRegistration = Layer.effectDiscard(registerDeviceScreenshot()).pipe(780const layerDeviceScreenshotRegistration = imageToolRegistration(
750 Layer.provide(DeviceHandlers.layerScreenshot),781 registerDeviceScreenshot(),
782 DeviceHandlers.layerScreenshot,
751);783);
752784
753export const layerDeviceToolkit = Layer.mergeAll(785export const layerDeviceToolkit = Layer.mergeAll(
apps/server/src/mcp/McpToolAccess.race.test.ts created+200
...@@ -0,0 +1,200 @@
1import { assert, it } from "@effect/vitest";
2import {
3 CommandId,
4 EnvironmentId,
5 OrchestratorMcpFailure,
6 ProjectId,
7 ProviderDriverKind,
8 ProviderInstanceId,
9 ThreadId,
10} from "@t3tools/contracts";
11import * as Effect from "effect/Effect";
12import * as Layer from "effect/Layer";
13import * as Ref from "effect/Ref";
14import * as Schema from "effect/Schema";
15import { McpSchema, McpServer, Tool, Toolkit } from "effect/ai";
16
17import * as SqlitePersistence from "../persistence/Sqlite.ts";
18import { DispatchModeLimit } from "../orchestration-v2/DispatchModeLimit.ts";
19import { CodexProviderCapabilitiesV2 } from "../orchestration-v2/Adapters/CodexAdapterV2.ts";
20import * as Orchestrator from "../orchestration-v2/Orchestrator.ts";
21import * as ProjectionStore from "../orchestration-v2/ProjectionStore.ts";
22import type { ProviderAdapterV2Shape } from "../orchestration-v2/ProviderAdapter.ts";
23import * as ProviderAdapterRegistry from "../orchestration-v2/ProviderAdapterRegistry.ts";
24import * as ThreadManagement from "../orchestration-v2/ThreadManagementService.ts";
25import * as ProviderReplayHarness from "../orchestration-v2/testkit/ProviderReplayHarness.ts";
26import * as McpHttpServer from "./McpHttpServer.ts";
27import * as McpInvocationContext from "./McpInvocationContext.ts";
28import * as McpToolAccess from "./McpToolAccess.ts";
29
30// A Supervised outside agent renames a thread through a `writesThreads` tool.
31// The thread's user raises it to full access after the tool's check but
32// before its write: the race the orchestrator closes under the thread's lock.
33
34const instanceId = ProviderInstanceId.make("codex");
35const adapter = {
36 instanceId,
37 driver: ProviderDriverKind.make("codex"),
38 getCapabilities: () => Effect.succeed(CodexProviderCapabilitiesV2),
39 planSelectionTransition: () => Effect.succeed({ type: "apply_on_next_turn" as const }),
40 openSession: () => Effect.die("No provider process needed for metadata commands"),
41} as ProviderAdapterV2Shape;
42const layerDatabase = SqlitePersistence.layerMemory;
43const layerOrchestrator = Layer.mergeAll(
44 layerDatabase,
45 ProjectionStore.layer.pipe(Layer.provide(layerDatabase)),
46 ProviderReplayHarness.layerWithRegistry(
47 { name: "mcp-mode-race" },
48 ProviderAdapterRegistry.layerFromAdapters([adapter]),
49 { databaseLayer: layerDatabase, runEffectWorker: false },
50 ),
51);
52
53const RenameTool = Tool.make("rename", {
54 parameters: Schema.Struct({ threadId: ThreadId }),
55 success: Schema.Struct({ renamed: Schema.Boolean }),
56 failure: OrchestratorMcpFailure,
57 failureMode: "return",
58 dependencies: [
59 McpInvocationContext.McpInvocationContext,
60 ThreadManagement.ThreadManagementService,
61 ],
62});
63const RenameToolkit = Toolkit.make(RenameTool);
64
65const supervisedClient: McpInvocationContext.McpInvocationScope = {
66 environmentId: EnvironmentId.make("environment"),
67 requestNamespace: "client:race",
68 thread: undefined,
69 client: { sessionId: "race", label: "Claude Code", runtimeModeCeiling: "approval-required" },
70 capabilities: new Set(["orchestration"]),
71 issuedAt: 0,
72};
73
74const mcpClient = McpSchema.McpServerClient.of({
75 clientId: 1,
76 protocolVersion: "2025-06-18",
77 clientCapabilities: {},
78 clientInfo: { name: "mcp-race", version: "1" },
79 initializePayload: {
80 protocolVersion: "2025-06-18",
81 capabilities: {},
82 clientInfo: { name: "mcp-race", version: "1" },
83 },
84 getClient: Effect.die("unused"),
85});
86
87const decodeOutcome = Schema.decodeUnknownEffect(
88 Schema.fromJsonString(
89 Schema.Union([
90 Schema.Struct({ renamed: Schema.Boolean }),
91 Schema.Struct({ code: Schema.String }),
92 ]),
93 ),
94);
95
96/** Renames `threadId` as the Supervised client; the user may raise it in between. */
97const renameRacingTheUser = (threadId: ThreadId, userRaises: boolean) =>
98 Effect.gen(function* () {
99 const orchestrator = yield* Orchestrator.OrchestratorV2;
100 const threads = yield* ThreadManagement.ThreadManagementService;
101 const raised = yield* Ref.make(false);
102 const handlers = McpToolAccess.toLayer(RenameToolkit, {
103 rename: McpToolAccess.writesThreads(
104 (input) => [input.threadId],
105 (input) =>
106 Effect.gen(function* () {
107 // The tool's own check has passed; the user acts before the write.
108 if (userRaises) {
109 // The user's own command carries no limit, so it lands.
110 yield* orchestrator
111 .dispatch({
112 type: "thread.runtime-mode.set",
113 commandId: CommandId.make("user-raise"),
114 threadId: input.threadId,
115 runtimeMode: "full-access",
116 })
117 .pipe(Effect.provideService(DispatchModeLimit, undefined), Effect.orDie);
118 yield* Ref.set(raised, true);
119 }
120 yield* threads
121 .dispatch({
122 type: "thread.metadata.update",
123 commandId: CommandId.make(`agent-rename:${userRaises}`),
124 threadId: input.threadId,
125 title: "Renamed by the agent",
126 })
127 .pipe(
128 Effect.mapError(
129 () =>
130 new OrchestratorMcpFailure({
131 code: "orchestration_error",
132 message: "The rename failed.",
133 }),
134 ),
135 );
136 return { renamed: true };
137 }),
138 ),
139 });
140 const outcome = yield* McpServer.McpServer.pipe(
141 Effect.flatMap((server) => server.callTool({ name: "rename", arguments: { threadId } })),
142 Effect.flatMap(({ content }) =>
143 decodeOutcome(content.map((part) => (part.type === "text" ? part.text : "")).join("")),
144 ),
145 Effect.provideService(McpInvocationContext.McpInvocationContext, supervisedClient),
146 Effect.provideService(McpSchema.McpServerClient, mcpClient),
147 Effect.provide(
148 McpHttpServer.toolkitRegistration(RenameToolkit, handlers).pipe(
149 Layer.provideMerge(McpServer.McpServer.layer),
150 ),
151 ),
152 );
153 assert.equal(yield* Ref.get(raised), userRaises);
154 return outcome;
155 });
156
157const createSupervisedThread = (threadId: ThreadId) =>
158 Effect.gen(function* () {
159 const orchestrator = yield* Orchestrator.OrchestratorV2;
160 yield* orchestrator.dispatch({
161 type: "thread.create",
162 commandId: CommandId.make(`create:${threadId}`),
163 threadId,
164 projectId: ProjectId.make("project:mcp-race"),
165 title: "Before",
166 modelSelection: { instanceId, model: "gpt-5" },
167 runtimeMode: "approval-required",
168 interactionMode: "default",
169 branch: null,
170 worktreePath: null,
171 createdBy: "user",
172 creationSource: "web",
173 });
174 });
175
176it.layer(layerOrchestrator)("writesThreads against a mode raise", (it) => {
177 it.effect("refuses the write when the user raises the thread after the check", () =>
178 Effect.gen(function* () {
179 const projections = yield* ProjectionStore.ProjectionStoreV2;
180 const threadId = ThreadId.make("thread:race-raised");
181 yield* createSupervisedThread(threadId);
182 const outcome = yield* renameRacingTheUser(threadId, true);
183 assert.deepEqual(outcome, { code: "runtime_mode_escalation_denied" });
184 const shell = yield* projections.getThreadShell(threadId);
185 assert.equal(shell?.runtimeMode, "full-access");
186 assert.equal(shell?.title, "Before");
187 }).pipe(Effect.provide(ThreadManagement.layer)),
188 );
189
190 it.effect("lets the write through when the thread stays within the caller's modes", () =>
191 Effect.gen(function* () {
192 const projections = yield* ProjectionStore.ProjectionStoreV2;
193 const threadId = ThreadId.make("thread:race-steady");
194 yield* createSupervisedThread(threadId);
195 const outcome = yield* renameRacingTheUser(threadId, false);
196 assert.deepEqual(outcome, { renamed: true });
197 assert.equal((yield* projections.getThreadShell(threadId))?.title, "Renamed by the agent");
198 }).pipe(Effect.provide(ThreadManagement.layer)),
199 );
200});
apps/server/src/mcp/McpToolAccess.test.ts created+306
...@@ -0,0 +1,306 @@
1import { expect, it } from "@effect/vitest";
2import {
3 EnvironmentId,
4 OrchestratorMcpFailure,
5 ProviderInstanceId,
6 ProviderInteractionMode,
7 RuntimeMode,
8 ThreadId,
9} from "@t3tools/contracts";
10import * as Effect from "effect/Effect";
11import * as Layer from "effect/Layer";
12import * as Schema from "effect/Schema";
13import { McpSchema, McpServer, Tool, Toolkit } from "effect/ai";
14
15import { OrchestratorProjectionError } from "../orchestration-v2/Orchestrator.ts";
16import * as ThreadManagement from "../orchestration-v2/ThreadManagementService.ts";
17import * as McpHttpServer from "./McpHttpServer.ts";
18import * as McpInvocationContext from "./McpInvocationContext.ts";
19import * as McpToolAccess from "./McpToolAccess.ts";
20import { liveThreadShell } from "./McpToolAccess.testkit.ts";
21
22const supervisedThreadId = ThreadId.make("thread:supervised");
23const planThreadId = ThreadId.make("thread:plan");
24const fullAccessThreadId = ThreadId.make("thread:full-access");
25const endedThreadId = ThreadId.make("thread:ended");
26
27const shells = new Map([
28 [supervisedThreadId, liveThreadShell(supervisedThreadId, { runtimeMode: "approval-required" })],
29 [
30 planThreadId,
31 liveThreadShell(planThreadId, { runtimeMode: "approval-required", interactionMode: "plan" }),
32 ],
33 [fullAccessThreadId, liveThreadShell(fullAccessThreadId)],
34 [endedThreadId, liveThreadShell(endedThreadId, { activeRunId: null })],
35]);
36
37const threadCaller = (threadId: ThreadId): McpInvocationContext.McpInvocationScope => ({
38 environmentId: EnvironmentId.make("environment"),
39 requestNamespace: `provider:${threadId}`,
40 thread: {
41 threadId,
42 providerSessionId: `provider:${threadId}`,
43 providerInstanceId: ProviderInstanceId.make("codex"),
44 },
45 client: undefined,
46 capabilities: new Set(["orchestration"]),
47 issuedAt: 0,
48});
49
50const clientCaller = (
51 runtimeModeCeiling: RuntimeMode,
52): McpInvocationContext.McpInvocationScope => ({
53 environmentId: EnvironmentId.make("environment"),
54 requestNamespace: "client:session",
55 thread: undefined,
56 client: { sessionId: "session", label: "Claude Code", runtimeModeCeiling },
57 capabilities: new Set(["orchestration"]),
58 issuedAt: 0,
59});
60
61const mcpClient = McpSchema.McpServerClient.of({
62 clientId: 1,
63 protocolVersion: "2025-06-18",
64 clientCapabilities: {},
65 clientInfo: { name: "mcp-test", version: "1" },
66 initializePayload: {
67 protocolVersion: "2025-06-18",
68 capabilities: {},
69 clientInfo: { name: "mcp-test", version: "1" },
70 },
71 getClient: Effect.die("unused"),
72});
73
74// One tool per declaration, each answering with the modes it was handed, so
75// the table below reads what the gate let through.
76const probe = {
77 success: Schema.Struct({ ran: Schema.String }),
78 failure: OrchestratorMcpFailure,
79 failureMode: "return" as const,
80 dependencies: [
81 McpInvocationContext.McpInvocationContext,
82 ThreadManagement.ThreadManagementService,
83 ],
84};
85const ProbeToolkit = Toolkit.make(
86 Tool.make("reads", probe),
87 Tool.make("reads_as_caller", probe),
88 Tool.make("acts_as_caller", probe),
89 Tool.make("writes", probe),
90 Tool.make("writes_threads", {
91 ...probe,
92 parameters: Schema.Struct({ threadId: Schema.optional(ThreadId) }),
93 }),
94 Tool.make("starts_threads", {
95 ...probe,
96 parameters: Schema.Struct({
97 runtimeMode: Schema.optional(RuntimeMode),
98 interactionMode: Schema.optional(ProviderInteractionMode),
99 }),
100 }),
101 Tool.make("writes_environment", probe),
102);
103const ran = Effect.succeed({ ran: "ran" });
104const probeHandlers: McpToolAccess.Handlers<typeof ProbeToolkit.tools> = {
105 reads: McpToolAccess.reads(() => ran),
106 reads_as_caller: McpToolAccess.readsAsCaller(() => ran),
107 acts_as_caller: McpToolAccess.actsAsCaller(() => ran),
108 writes: McpToolAccess.writes(() => ran),
109 writes_threads: McpToolAccess.writesThreads(
110 (input) => [input.threadId],
111 () => ran,
112 ),
113 starts_threads: McpToolAccess.startsThreads(
114 (input) => input,
115 (_, modes) => Effect.succeed({ ran: `${modes.runtimeMode}/${modes.interactionMode}` }),
116 ),
117 writes_environment: McpToolAccess.writesEnvironment(() => ran),
118};
119const layerProbeHandlers = McpToolAccess.toLayer(ProbeToolkit, probeHandlers);
120
121const unchecked = () => ran;
122
123// What the compiler refuses: each line below must fail to typecheck, and its
124// `@ts-expect-error` fails the build if one ever compiles. Never called.
125export const refusedAtCompileTime = () => {
126 McpToolAccess.toLayer(ProbeToolkit, {
127 ...probeHandlers,
128 // @ts-expect-error a handler that skips its declaration
129 reads: unchecked,
130 });
131 McpToolAccess.toLayer(ProbeToolkit, {
132 ...probeHandlers,
133 // @ts-expect-error a declaration's fields copied onto an unchecked handler
134 reads: Object.assign(
135 unchecked,
136 McpToolAccess.reads(() => ran),
137 ),
138 });
139 // @ts-expect-error a declaration built outside McpToolAccess
140 McpToolAccess.Declaration.make(unchecked);
141 // @ts-expect-error a declaration constructed directly
142 new McpToolAccess.Declaration(unchecked);
143 // @ts-expect-error a handlers layer built outside McpToolAccess
144 McpToolAccess.HandlersLayer.make(Layer.empty);
145 // The refused layer below types its error and services as unknown, which is fine here.
146 // @effect-diagnostics-next-line anyUnknownInErrorContext:off
147 McpHttpServer.toolkitRegistration(
148 ProbeToolkit,
149 // @ts-expect-error a handlers layer that skipped `McpToolAccess.toLayer`
150 ProbeToolkit.toLayer({
151 reads: unchecked,
152 reads_as_caller: unchecked,
153 acts_as_caller: unchecked,
154 writes: unchecked,
155 writes_threads: unchecked,
156 starts_threads: unchecked,
157 writes_environment: unchecked,
158 }),
159 );
160};
161
162const probeServer = (threads: Layer.Layer<ThreadManagement.ThreadManagementService>) =>
163 McpHttpServer.toolkitRegistration(ProbeToolkit, layerProbeHandlers).pipe(
164 Layer.provideMerge(McpServer.McpServer.layer),
165 Layer.provideMerge(threads),
166 );
167
168/** A probe's answer, or the gate's refusal, as the result's JSON text. */
169const decodeOutcome = Schema.decodeUnknownEffect(
170 Schema.fromJsonString(
171 Schema.Union([Schema.Struct({ ran: Schema.String }), Schema.Struct({ code: Schema.String })]),
172 ),
173);
174
175const call = (
176 tool: string,
177 scope: McpInvocationContext.McpInvocationScope,
178 args: Record<string, unknown> = {},
179) =>
180 McpServer.McpServer.pipe(
181 Effect.flatMap((server) => server.callTool({ name: tool, arguments: args })),
182 Effect.flatMap(({ content }) =>
183 decodeOutcome(content.map((part) => (part.type === "text" ? part.text : "")).join("")),
184 ),
185 Effect.map((outcome) => ("code" in outcome ? outcome.code : outcome.ran)),
186 Effect.provideService(McpInvocationContext.McpInvocationContext, scope),
187 Effect.provideService(McpSchema.McpServerClient, mcpClient),
188 );
189
190const supervised = threadCaller(supervisedThreadId);
191const plan = threadCaller(planThreadId);
192const fullAccess = threadCaller(fullAccessThreadId);
193const ended = threadCaller(endedThreadId);
194const supervisedClient = clientCaller("approval-required");
195const fullAccessClient = clientCaller("full-access");
196// A live full-access thread whose credential may use its browser but not control threads.
197const previewOnly = { ...fullAccess, capabilities: new Set(["preview"] as const) };
198
199it.effect.each([
200 // Reads are open to every caller, even one whose turn ended.
201 ["reads", ended, {}, "ran"],
202 ["reads", supervisedClient, {}, "ran"],
203
204 // What belongs to the calling thread needs one; changing it needs its live turn.
205 ["reads_as_caller", ended, {}, "ran"],
206 ["reads_as_caller", fullAccessClient, {}, "thread_credential_required"],
207 ["acts_as_caller", supervised, {}, "ran"],
208 ["acts_as_caller", ended, {}, "parent_not_active"],
209 ["acts_as_caller", fullAccessClient, {}, "thread_credential_required"],
210
211 // Any change needs a thread caller's live turn; a client has none to lose.
212 ["writes", supervised, {}, "ran"],
213 ["writes", ended, {}, "parent_not_active"],
214 ["writes", supervisedClient, {}, "ran"],
215 // Uploads, scheduled tasks, projects and settings need a credential that controls threads.
216 ["writes", previewOnly, {}, "capability_denied"],
217 ["writes_environment", previewOnly, {}, "capability_denied"],
218
219 // Changing a thread: only one that runs within the caller's modes.
220 ["writes_threads", supervised, {}, "ran"],
221 ["writes_threads", supervised, { threadId: planThreadId }, "ran"],
222 [
223 "writes_threads",
224 supervised,
225 { threadId: fullAccessThreadId },
226 "runtime_mode_escalation_denied",
227 ],
228 ["writes_threads", plan, { threadId: supervisedThreadId }, "interaction_mode_escalation_denied"],
229 ["writes_threads", fullAccess, { threadId: supervisedThreadId }, "ran"],
230 ["writes_threads", ended, { threadId: supervisedThreadId }, "parent_not_active"],
231 ["writes_threads", supervisedClient, { threadId: supervisedThreadId }, "ran"],
232 [
233 "writes_threads",
234 supervisedClient,
235 { threadId: fullAccessThreadId },
236 "runtime_mode_escalation_denied",
237 ],
238 // A thread that does not exist is the tool's to report.
239 ["writes_threads", supervised, { threadId: "thread:missing" }, "ran"],
240
241 // Starting a thread: the caller's own modes or narrower.
242 ["starts_threads", supervised, {}, "approval-required/default"],
243 ["starts_threads", supervised, { runtimeMode: "full-access" }, "runtime_mode_escalation_denied"],
244 ["starts_threads", plan, {}, "approval-required/plan"],
245 ["starts_threads", plan, { interactionMode: "default" }, "interaction_mode_escalation_denied"],
246 ["starts_threads", fullAccess, { runtimeMode: "auto", interactionMode: "plan" }, "auto/plan"],
247 ["starts_threads", ended, {}, "parent_not_active"],
248 ["starts_threads", supervisedClient, {}, "approval-required/default"],
249 ["starts_threads", supervisedClient, { runtimeMode: "auto" }, "runtime_mode_escalation_denied"],
250 ["starts_threads", fullAccessClient, { runtimeMode: "full-access" }, "full-access/default"],
251
252 // Projects and environment settings need full access.
253 ["writes_environment", fullAccess, {}, "ran"],
254 ["writes_environment", supervised, {}, "capability_denied"],
255 ["writes_environment", plan, {}, "capability_denied"],
256 ["writes_environment", fullAccessClient, {}, "ran"],
257 ["writes_environment", supervisedClient, {}, "capability_denied"],
258] as const)("%s from %o with %o: %s", ([tool, scope, args, expected]) =>
259 call(tool, scope, args).pipe(
260 Effect.map((outcome) => expect(outcome).toBe(expected)),
261 Effect.provide(
262 probeServer(
263 Layer.mock(ThreadManagement.ThreadManagementService)({
264 getThreadShell: (threadId) => Effect.succeed(shells.get(threadId) ?? null),
265 }),
266 ),
267 ),
268 ),
269);
270
271it("refuses a declaration or handlers layer built outside McpToolAccess", () => {
272 // Private constructors only bind the type checker; Reflect.construct reaches them anyway.
273 expect(() => Reflect.construct(McpToolAccess.Declaration, [unchecked])).toThrow(TypeError);
274 expect(() => Reflect.construct(McpToolAccess.HandlersLayer, [Layer.empty])).toThrow(TypeError);
275});
276
277it("refuses a declaration or handlers layer that only wears another's fields", () => {
278 // The types already refuse these; at runtime the private fields are missing too.
279 const copiedDeclaration = Object.assign(
280 unchecked,
281 McpToolAccess.reads(() => ran),
282 );
283 expect(() =>
284 McpToolAccess.toLayer(ProbeToolkit, {
285 ...probeHandlers,
286 // @ts-expect-error a declaration's fields copied onto an unchecked handler
287 reads: copiedDeclaration,
288 }),
289 ).toThrow(TypeError);
290 const copiedLayer = Object.assign(Layer.empty, layerProbeHandlers);
291 expect(() => McpToolAccess.HandlersLayer.layer(copiedLayer)).toThrow(TypeError);
292});
293
294it.effect("refuses a change when the calling thread cannot be read", () =>
295 call("writes", supervised).pipe(
296 Effect.map((outcome) => expect(outcome).toBe("orchestration_error")),
297 Effect.provide(
298 probeServer(
299 Layer.mock(ThreadManagement.ThreadManagementService)({
300 getThreadShell: (threadId) =>
301 Effect.fail(new OrchestratorProjectionError({ threadId, cause: "unreadable" })),
302 }),
303 ),
304 ),
305 ),
306);
apps/server/src/mcp/McpToolAccess.testkit.ts created+115
...@@ -0,0 +1,115 @@
1import {
2 type OrchestrationV2ThreadProjection,
3 type OrchestrationV2ThreadShell,
4 ProjectId,
5 type ProviderInteractionMode,
6 ProviderInstanceId,
7 RunId,
8 type RuntimeMode,
9 type ThreadId,
10} from "@t3tools/contracts";
11import * as DateTime from "effect/DateTime";
12import * as Effect from "effect/Effect";
13import * as Layer from "effect/Layer";
14
15import * as ThreadManagement from "../orchestration-v2/ThreadManagementService.ts";
16
17const EPOCH = DateTime.makeUnsafe("2026-01-01T00:00:00.000Z");
18
19/**
20 * A thread in the middle of a turn on the `codex` instance: the shape the
21 * access gate reads for a thread caller, and for the threads a caller targets.
22 */
23export const liveThreadShell = (
24 id: ThreadId,
25 modes: {
26 readonly runtimeMode?: RuntimeMode;
27 readonly interactionMode?: ProviderInteractionMode;
28 readonly activeRunId?: RunId | null;
29 } = {},
30): OrchestrationV2ThreadShell => {
31 const providerInstanceId = ProviderInstanceId.make("codex");
32 return {
33 id,
34 projectId: ProjectId.make("project:mcp-test"),
35 title: id,
36 providerInstanceId,
37 modelSelection: { instanceId: providerInstanceId, model: "gpt-5" },
38 runtimeMode: modes.runtimeMode ?? "full-access",
39 interactionMode: modes.interactionMode ?? "default",
40 branch: null,
41 worktreePath: null,
42 createdBy: "user",
43 creationSource: "web",
44 activeProviderThreadId: null,
45 lineage: { rootThreadId: id, parentThreadId: null, relationshipToParent: null },
46 forkedFrom: null,
47 latestRunId: null,
48 activeRunId: modes.activeRunId === undefined ? RunId.make("run:mcp-test") : modes.activeRunId,
49 status: "running",
50 pendingRuntimeRequest: null,
51 latestVisibleMessage: null,
52 latestUserMessageAt: null,
53 hasActionableProposedPlan: false,
54 itemCount: 0,
55 visibleItemCount: 0,
56 createdAt: EPOCH,
57 updatedAt: EPOCH,
58 archivedAt: null,
59 settledOverride: null,
60 settledAt: null,
61 lastVisitedAt: null,
62 deletedAt: null,
63 };
64};
65
66/**
67 * `ThreadManagementService` that answers only the access gate's lookups,
68 * every thread a live full-access one. For tests of a tool rather than of who
69 * may call it.
70 */
71export const liveThreadsLayer = Layer.mock(ThreadManagement.ThreadManagementService)({
72 getThreadShell: (threadId) => Effect.succeed(liveThreadShell(threadId)),
73});
74
75/**
76 * The thread of `shell` with no runs, messages or other records: what a
77 * caller looks like once its turn has ended.
78 */
79export const idleThreadProjection = (
80 shell: OrchestrationV2ThreadShell,
81): OrchestrationV2ThreadProjection => {
82 const {
83 status: _status,
84 activeRunId: _activeRunId,
85 latestRunId: _latestRunId,
86 pendingRuntimeRequest: _pendingRuntimeRequest,
87 latestVisibleMessage: _latestVisibleMessage,
88 latestUserMessageAt: _latestUserMessageAt,
89 hasActionableProposedPlan: _hasActionableProposedPlan,
90 itemCount: _itemCount,
91 visibleItemCount: _visibleItemCount,
92 lastVisitedAt,
93 ...thread
94 } = shell;
95 return {
96 thread: { ...thread, lastVisitedAt: lastVisitedAt ?? null },
97 runs: [],
98 attempts: [],
99 nodes: [],
100 subagents: [],
101 providerSessions: [],
102 providerThreads: [],
103 providerTurns: [],
104 runtimeRequests: [],
105 messages: [],
106 plans: [],
107 turnItems: [],
108 checkpointScopes: [],
109 checkpoints: [],
110 contextHandoffs: [],
111 contextTransfers: [],
112 visibleTurnItems: [],
113 updatedAt: shell.updatedAt,
114 };
115};
apps/server/src/mcp/McpToolAccess.ts created+295
...@@ -0,0 +1,295 @@
1import {
2 OrchestratorMcpFailure,
3 type ProviderInteractionMode,
4 type RuntimeMode,
5 type ThreadId,
6} from "@t3tools/contracts";
7import * as Effect from "effect/Effect";
8import * as Ref from "effect/Ref";
9import * as Struct from "effect/Struct";
10import type * as Layer from "effect/Layer";
11import type * as Scope from "effect/Scope";
12import type { Tool, Toolkit } from "effect/ai";
13
14import {
15 DispatchModeLimit,
16 type DispatchModeRefusal,
17} from "../orchestration-v2/DispatchModeLimit.ts";
18import * as McpInvocationContext from "./McpInvocationContext.ts";
19import { resolveInteractionMode, resolveRuntimeMode } from "./OrchestratorMcpService.ts";
20import {
21 assertFullAccess,
22 assertLiveCaller,
23 assertTargetWithinLimits,
24 type Caller,
25 loadCaller,
26 readCaller,
27 unavailable,
28} from "./threadAccess.ts";
29
30// Only the classes below assign these, from their static blocks, so nothing
31// outside this module can build a declaration or a handlers layer.
32
33/**
34 * Both constructors demand this. Their private constructors only stop the type
35 * checker; this also stops `Reflect.construct` and friends at runtime.
36 */
37const builtHere: unique symbol = Symbol("t3/mcp/McpToolAccess/builtHere");
38
39const refuseOutsideConstruction = (token: symbol) => {
40 if (token !== builtHere) {
41 throw new TypeError("Only McpToolAccess builds MCP tool declarations and handler layers.");
42 }
43};
44
45/** Builds a declaration; only the declaration functions below call it. */
46let declare: <P, A, E, R>(
47 handle: (params: P) => Effect.Effect<A, E, R>,
48) => Declaration<(params: P) => Effect.Effect<A, E, R>>;
49
50type CheckedHandlerOf<D> = D extends Declaration<infer Handler> ? Handler : never;
51
52/** Turns each declaration back into the handler it checks, keeping its type. */
53interface CheckedHandler extends Struct.Lambda {
54 <Handler>(declaration: Declaration<Handler>): Handler;
55 readonly "~lambda.out": CheckedHandlerOf<this["~lambda.in"]>;
56}
57let checkedHandler: CheckedHandler;
58
59/** Only `toLayer` below builds one. */
60let handlersLayer: <Tools extends Record<string, Tool.Any>, EX, RX>(
61 layer: Layer.Layer<Tool.HandlersFor<Tools>, EX, RX>,
62) => HandlersLayer<Tools, EX, RX>;
63
64/**
65 * Who may call a T3 MCP tool. Every handler is built by one of the
66 * declarations below, which say what the tool does. `toLayer` accepts only
67 * declarations, and `/mcp` registers only layers `toLayer` built, so a tool
68 * without a decision here does not compile. Both are nominal classes, so a
69 * handler or layer cannot pass for one by copying its fields. Effect's own
70 * registration functions accept any handler, so the
71 * `t3code/no-raw-mcp-registration` lint rule keeps Effect's `McpServer` inside
72 * McpHttpServer.
73 *
74 * Parameters choose the target; the caller sets the limits: a thread caller
75 * its own runtime and interaction modes, an outside client the ceiling it was
76 * approved with. Nothing a caller starts or changes may run with broader
77 * modes, and a thread caller changes things only while its own run is live.
78 * A refusal is an `OrchestratorMcpFailure`, so the compiler requires it in the
79 * tool's failure schema, and `ThreadManagementService` in its dependencies.
80 *
81 * The declaration checks the caller before the handler runs. Handlers still
82 * check what only they can see, such as a queued run belonging to its thread.
83 */
84export class Declaration<out Handler> {
85 // A private field makes the class nominal, and only this module can build
86 // one: its constructor is private and the static block hands the only way
87 // in to module-scoped functions. Copying a declaration's fields onto
88 // anything else fails to typecheck and, at runtime, to run.
89 readonly #handle: Handler;
90 private constructor(token: typeof builtHere, handle: Handler) {
91 refuseOutsideConstruction(token);
92 this.#handle = handle;
93 }
94 static {
95 declare = (handle) => new Declaration(builtHere, handle);
96 checkedHandler = Struct.lambda<CheckedHandler>((declaration) => declaration.#handle);
97 }
98}
99
100/**
101 * The caller of a tool that changes something. Tools that act for a
102 * capability of their own (preview, device, worktree, pull requests) check it
103 * themselves.
104 */
105const writingCaller = loadCaller().pipe(Effect.tap(assertLiveCaller));
106
107/** The same, for tools whose only capability is controlling threads. */
108const orchestratingCaller = readCaller().pipe(Effect.tap(assertLiveCaller));
109
110const requireThreadCaller = McpInvocationContext.McpInvocationContext.pipe(
111 Effect.flatMap((scope) => McpInvocationContext.requireThreadScope(scope, "This tool")),
112);
113
114/** Changes nothing, so every caller may call it. */
115export const reads = <P, A, E, R>(handle: (params: P) => Effect.Effect<A, E, R>) =>
116 declare((params: P) => handle(params));
117
118/** Reads what belongs to the calling T3 thread, such as its preview tabs or devices. */
119export const readsAsCaller = <P, A, E, R>(handle: (params: P) => Effect.Effect<A, E, R>) =>
120 declare((params: P) => requireThreadCaller.pipe(Effect.flatMap(() => handle(params))));
121
122/**
123 * Acts as the calling T3 thread (its subagents, preview tabs, devices,
124 * worktree) while that thread's run is live. Only an agent running inside a
125 * T3 thread has one.
126 */
127export const actsAsCaller = <P, A, E, R>(handle: (params: P) => Effect.Effect<A, E, R>) =>
128 declare((params: P) =>
129 requireThreadCaller.pipe(
130 Effect.flatMap(() => writingCaller),
131 Effect.flatMap(() => handle(params)),
132 ),
133 );
134
135/** Changes something that belongs to no thread, such as a pending upload or a scheduled task. */
136export const writes = <P, A, E, R>(handle: (params: P) => Effect.Effect<A, E, R>) =>
137 declare((params: P) => orchestratingCaller.pipe(Effect.flatMap(() => handle(params))));
138
139/**
140 * Changes the threads `threads` names. An omitted id is the caller's own
141 * thread; any other thread must run within the caller's modes, checked here
142 * and again by the orchestrator when it applies each command. A thread that
143 * does not exist is the handler's to report.
144 */
145export const writesThreads = <P, A, E, R>(
146 threads: (params: P) => ReadonlyArray<ThreadId | undefined>,
147 handle: (params: P) => Effect.Effect<A, E, R>,
148) =>
149 declare((params: P) =>
150 Effect.gen(function* () {
151 const caller = yield* writingCaller;
152 for (const threadId of threads(params)) {
153 if (threadId === undefined || threadId === caller.scope.thread?.threadId) continue;
154 const target = yield* caller.threads
155 .getThreadShell(threadId)
156 .pipe(Effect.mapError(unavailable));
157 if (target !== null && target.deletedAt === null) {
158 yield* assertTargetWithinLimits(caller.limits, target);
159 }
160 }
161 // The target's user can raise its modes after the check above; the
162 // orchestrator checks again under the thread's lock and records its
163 // refusal here, since handlers wrap dispatch errors their own way.
164 const refused = yield* Ref.make<DispatchModeRefusal | undefined>(undefined);
165 return yield* handle(params).pipe(
166 Effect.provideService(DispatchModeLimit, { ...caller.limits, refused }),
167 Effect.catch((error) =>
168 Effect.flatMap(Ref.get(refused), (refusal) =>
169 Effect.fail<E | OrchestratorMcpFailure>(
170 refusal === undefined ? error : escalationDenied(refusal),
171 ),
172 ),
173 ),
174 );
175 }),
176 );
177
178/** How an agent hears that a thread it targets was raised above its modes mid-call. */
179const escalationDenied = (refusal: DispatchModeRefusal) =>
180 new OrchestratorMcpFailure({
181 code:
182 refusal.mode === "runtime"
183 ? "runtime_mode_escalation_denied"
184 : "interaction_mode_escalation_denied",
185 message: `Thread ${refusal.threadId} now runs in ${refusal.runtimeMode}/${refusal.interactionMode} mode, above this caller's. Its user changed it while this call ran.`,
186 });
187
188/** The modes a started thread runs with: those requested, else the caller's own. */
189export interface StartedModes {
190 readonly runtimeMode: RuntimeMode;
191 readonly interactionMode: ProviderInteractionMode;
192}
193
194/** Starts threads with the modes `modes` requests, which may not be broader than the caller's. */
195export const startsThreads = <P, A, E, R>(
196 modes: (params: P) => {
197 readonly runtimeMode?: RuntimeMode | undefined;
198 readonly interactionMode?: ProviderInteractionMode | undefined;
199 },
200 handle: (params: P, modes: StartedModes) => Effect.Effect<A, E, R>,
201) =>
202 declare((params: P) =>
203 Effect.gen(function* () {
204 const { limits } = yield* writingCaller;
205 const requested = modes(params);
206 const started: StartedModes = {
207 runtimeMode: yield* resolveRuntimeMode(limits.runtimeMode, requested.runtimeMode),
208 interactionMode: yield* resolveInteractionMode(
209 limits.interactionMode,
210 requested.interactionMode,
211 ),
212 };
213 return yield* handle(params, started);
214 }),
215 );
216
217const fullAccessRequired =
218 "Changing projects or environment settings needs a live full-access/default calling thread or a full-access client.";
219
220/**
221 * Changes projects or environment settings, which needs a full-access/default
222 * caller. `check` re-checks the caller wherever the handler waits before
223 * writing, such as for a lock, since the caller's modes can change meanwhile.
224 */
225export const writesEnvironment = <P, A, E, R>(
226 handle: (
227 params: P,
228 check: Effect.Effect<Caller, OrchestratorMcpFailure, CheckServices>,
229 ) => Effect.Effect<A, E, R>,
230) => {
231 const check = orchestratingCaller.pipe(
232 Effect.tap((caller) => assertFullAccess(caller, fullAccessRequired)),
233 );
234 return declare((params: P) => check.pipe(Effect.flatMap(() => handle(params, check))));
235};
236
237/** What a declaration's own check needs. */
238type CheckServices = Effect.Services<typeof orchestratingCaller>;
239
240/**
241 * Each handler of `Handlers`, built by one of the declarations above. A
242 * declaration is never callable, which rules out a handler function wearing a
243 * declaration's fields.
244 */
245type Declarations<Handlers> = {
246 readonly [Name in keyof Handlers]: Declaration<Handlers[Name]> & NotCallable;
247};
248
249/** Anything but a function. */
250type NotCallable = { readonly call?: never } & { readonly apply?: never };
251
252/** A toolkit's handlers, each built by one of the declarations above. */
253export type Handlers<Tools extends Record<string, Tool.Any>> = Declarations<
254 Toolkit.HandlersFrom<Tools>
255>;
256
257/**
258 * A toolkit's handler layer built by `toLayer`, the only kind `/mcp`
259 * registers. Like a declaration it is nominal, so nothing else passes for one.
260 */
261export class HandlersLayer<Tools extends Record<string, Tool.Any>, EX = never, RX = never> {
262 readonly #layer: Layer.Layer<Tool.HandlersFor<Tools>, EX, RX>;
263 private constructor(
264 token: typeof builtHere,
265 layer: Layer.Layer<Tool.HandlersFor<Tools>, EX, RX>,
266 ) {
267 refuseOutsideConstruction(token);
268 this.#layer = layer;
269 }
270 static {
271 handlersLayer = (layer) => new HandlersLayer(builtHere, layer);
272 }
273 /** The handlers, for registering this toolkit on the MCP server. */
274 static layer<Tools extends Record<string, Tool.Any>, EX, RX>(
275 handlers: HandlersLayer<Tools, EX, RX>,
276 ) {
277 return handlers.#layer;
278 }
279}
280
281const checkedHandlers = <Handlers>(declarations: Declarations<Handlers>): Handlers =>
282 Struct.map(declarations, checkedHandler);
283
284/** `Toolkit.toLayer` for handlers that all declare their access. */
285export const toLayer = <Tools extends Record<string, Tool.Any>, EX = never, RX = never>(
286 toolkit: Toolkit.Toolkit<Tools>,
287 build: Handlers<Tools> | Effect.Effect<Handlers<Tools>, EX, RX>,
288): HandlersLayer<Tools, EX, Exclude<RX, Scope.Scope>> =>
289 handlersLayer(
290 toolkit.toLayer(
291 Effect.isEffect(build)
292 ? Effect.map(build, checkedHandlers<Toolkit.HandlersFrom<Tools>>)
293 : checkedHandlers(build),
294 ),
295 );
apps/server/src/mcp/OrchestratorMcpService.test.ts+494-1
...@@ -1,8 +1,12 @@...@@ -1,8 +1,12 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { assert, describe, it } from "@effect/vitest";2import { assert, describe, it } from "@effect/vitest";
3import {3import {
4 CommandId,
4 EnvironmentId,5 EnvironmentId,
5 NodeId,6 NodeId,
7 type OrchestrationV2ThreadShell,
8 type ScheduledTask,
9 ScheduledTaskId,
6 ProjectId,10 ProjectId,
7 ProviderDriverKind,11 ProviderDriverKind,
8 ProviderInstanceId,12 ProviderInstanceId,
...@@ -16,7 +20,14 @@ import * as Effect from "effect/Effect";...@@ -16,7 +20,14 @@ import * as Effect from "effect/Effect";
16import * as Layer from "effect/Layer";20import * as Layer from "effect/Layer";
17import * as Ref from "effect/Ref";21import * as Ref from "effect/Ref";
1822
19import { OrchestratorProjectionError } from "../orchestration-v2/Orchestrator.ts";23import {
24 DispatchModeLimit,
25 type DispatchModeRefusal,
26} from "../orchestration-v2/DispatchModeLimit.ts";
27import {
28 OrchestratorProjectionError,
29 OrchestratorThreadAboveModeLimitError,
30} from "../orchestration-v2/Orchestrator.ts";
20import type { ProviderAdapterV2Shape } from "../orchestration-v2/ProviderAdapter.ts";31import type { ProviderAdapterV2Shape } from "../orchestration-v2/ProviderAdapter.ts";
21import * as ProviderAdapterRegistry from "../orchestration-v2/ProviderAdapterRegistry.ts";32import * as ProviderAdapterRegistry from "../orchestration-v2/ProviderAdapterRegistry.ts";
22import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts";33import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts";
...@@ -26,6 +37,7 @@ import * as ProjectService from "../project/ProjectService.ts";...@@ -26,6 +37,7 @@ import * as ProjectService from "../project/ProjectService.ts";
26import * as ScheduledTaskService from "../scheduledTasks/ScheduledTaskService.ts";37import * as ScheduledTaskService from "../scheduledTasks/ScheduledTaskService.ts";
27import * as SecretRequests from "../secrets/SecretRequests.ts";38import * as SecretRequests from "../secrets/SecretRequests.ts";
28import type { McpInvocationScope } from "./McpInvocationContext.ts";39import type { McpInvocationScope } from "./McpInvocationContext.ts";
40import { idleThreadProjection, liveThreadShell } from "./McpToolAccess.testkit.ts";
29import * as OrchestratorMcpService from "./OrchestratorMcpService.ts";41import * as OrchestratorMcpService from "./OrchestratorMcpService.ts";
3042
31describe("OrchestratorMcpService", () => {43describe("OrchestratorMcpService", () => {
...@@ -283,6 +295,8 @@ describe("OrchestratorMcpService", () => {...@@ -283,6 +295,8 @@ describe("OrchestratorMcpService", () => {
283 Layer.mock(ThreadManagementService.ThreadManagementService)({295 Layer.mock(ThreadManagementService.ThreadManagementService)({
284 getThreadRecords: (threadId) =>296 getThreadRecords: (threadId) =>
285 Effect.succeed(threadId === parentThreadId ? parentProjection : childProjection),297 Effect.succeed(threadId === parentThreadId ? parentProjection : childProjection),
298 // The child still runs within the parent's modes.
299 getThreadShell: (threadId) => Effect.succeed(liveThreadShell(threadId)),
286 dispatch: (command) =>300 dispatch: (command) =>
287 Ref.update(dispatched, (commands) => [...commands, command]).pipe(301 Ref.update(dispatched, (commands) => [...commands, command]).pipe(
288 Effect.as({} as never),302 Effect.as({} as never),
...@@ -357,6 +371,8 @@ describe("OrchestratorMcpService", () => {...@@ -357,6 +371,8 @@ describe("OrchestratorMcpService", () => {
357 Layer.mock(ThreadManagementService.ThreadManagementService)({371 Layer.mock(ThreadManagementService.ThreadManagementService)({
358 getThreadRecords: (threadId) =>372 getThreadRecords: (threadId) =>
359 Effect.succeed(threadId === parentThreadId ? parentProjection : childProjection),373 Effect.succeed(threadId === parentThreadId ? parentProjection : childProjection),
374 // The child still runs within the parent's modes.
375 getThreadShell: (threadId) => Effect.succeed(liveThreadShell(threadId)),
360 dispatch: (command) =>376 dispatch: (command) =>
361 Ref.update(dispatched, (commands) => [...commands, command]).pipe(377 Ref.update(dispatched, (commands) => [...commands, command]).pipe(
362 Effect.andThen(Effect.fail(new Error("simulated stop failure") as never)),378 Effect.andThen(Effect.fail(new Error("simulated stop failure") as never)),
...@@ -434,6 +450,8 @@ describe("OrchestratorMcpService", () => {...@@ -434,6 +450,8 @@ describe("OrchestratorMcpService", () => {
434 Layer.mock(ThreadManagementService.ThreadManagementService)({450 Layer.mock(ThreadManagementService.ThreadManagementService)({
435 getThreadRecords: (threadId) =>451 getThreadRecords: (threadId) =>
436 Effect.succeed(threadId === parentThreadId ? parentProjection : childProjection),452 Effect.succeed(threadId === parentThreadId ? parentProjection : childProjection),
453 // The child still runs within the parent's modes.
454 getThreadShell: (threadId) => Effect.succeed(liveThreadShell(threadId)),
437 dispatch: (command) =>455 dispatch: (command) =>
438 Ref.update(dispatched, (commands) => [...commands, command]).pipe(456 Ref.update(dispatched, (commands) => [...commands, command]).pipe(
439 Effect.andThen(457 Effect.andThen(
...@@ -479,6 +497,258 @@ describe("OrchestratorMcpService", () => {...@@ -479,6 +497,258 @@ describe("OrchestratorMcpService", () => {
479 }).pipe(Effect.provide(OrchestratorMcpService.layer.pipe(Layer.provide(layerDependencies))));497 }).pipe(Effect.provide(OrchestratorMcpService.layer.pipe(Layer.provide(layerDependencies))));
480 }),498 }),
481 );499 );
500
501 it.effect("refuses to cancel a task whose child now runs above the parent's modes", () =>
502 Effect.gen(function* () {
503 const parentThreadId = ThreadId.make("thread:mcp-cancel-dispose-failed-parent");
504 const childThreadId = ThreadId.make("thread:mcp-cancel-dispose-failed-child");
505 const childRunId = RunId.make("run:mcp-cancel-dispose-failed-child");
506 const taskId = NodeId.make("node:mcp-cancel-dispose-failed-task");
507 const dispatched = yield* Ref.make<ReadonlyArray<unknown>>([]);
508 // The parent runs Supervised in plan mode.
509 const parentProjection = {
510 thread: { id: parentThreadId, runtimeMode: "approval-required", interactionMode: "plan" },
511 runs: [],
512 contextTransfers: [],
513 subagents: [
514 {
515 id: taskId,
516 threadId: parentThreadId,
517 origin: "app_owned",
518 childThreadId,
519 driver: "codex",
520 model: "gpt-5.6-terra",
521 result: null,
522 completionDelivery: { state: "pending" },
523 },
524 ],
525 } as unknown as OrchestrationV2ThreadProjection;
526 const childProjection = {
527 thread: { id: childThreadId },
528 runs: [{ id: childRunId, status: "running" }],
529 contextTransfers: [],
530 messages: [],
531 subagents: [],
532 providerThreads: [],
533 } as unknown as OrchestrationV2ThreadProjection;
534 const layerDependencies = Layer.mergeAll(
535 NodeServices.layer,
536 Layer.mock(ThreadManagementService.ThreadManagementService)({
537 getThreadRecords: (threadId) =>
538 Effect.succeed(threadId === parentThreadId ? parentProjection : childProjection),
539 // Its user has since raised the child to full access.
540 getThreadShell: (threadId) => Effect.succeed(liveThreadShell(threadId)),
541 dispatch: (command) =>
542 Ref.update(dispatched, (commands) => [...commands, command]).pipe(
543 Effect.andThen(
544 command.type === "delegated_task.completion-delivery.dispose"
545 ? Effect.fail(new Error("simulated disposal failure") as never)
546 : Effect.succeed({} as never),
547 ),
548 ),
549 stopDelegatedTasks: () => Effect.void,
550 }),
551 Layer.mock(ProviderRegistry.ProviderRegistry)({ getProviders: Effect.succeed([]) }),
552 Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({
553 list: () => Effect.succeed([]),
554 }),
555 Layer.mock(ProjectService.ProjectService)({}),
556 Layer.mock(SecretRequests.SecretRequests)({}),
557 Layer.mock(ScheduledTaskService.ScheduledTaskService)({}),
558 );
559 const scope: McpInvocationScope = {
560 environmentId: EnvironmentId.make("environment:mcp-cancel-dispose-failed"),
561 requestNamespace: "provider-session:mcp-cancel-dispose-failed",
562 thread: {
563 threadId: parentThreadId,
564 providerSessionId: "provider-session:mcp-cancel-dispose-failed",
565 providerInstanceId: ProviderInstanceId.make("codex"),
566 },
567 client: undefined,
568 capabilities: new Set(["orchestration"]),
569 issuedAt: 1,
570 };
571
572 yield* Effect.gen(function* () {
573 const service = yield* OrchestratorMcpService.OrchestratorMcpService;
574 const error = yield* service
575 .cancelTask(scope, { taskId, clientRequestId: "cancel-above-modes" })
576 .pipe(Effect.flip);
577 assert.equal(error.code, "runtime_mode_escalation_denied");
578 assert.deepEqual(yield* Ref.get(dispatched), []);
579 }).pipe(Effect.provide(OrchestratorMcpService.layer.pipe(Layer.provide(layerDependencies))));
580 }),
581 );
582
583 /**
584 * A Supervised parent cancels its Supervised child, under which a task
585 * now runs at full access. Reports what the cancel did.
586 */
587 const cancelOverRaisedGrandchild = (child: {
588 readonly deleted: boolean;
589 /** The grandchild passes the check, and its user raises it before the stop reaches it. */
590 readonly raisedDuringStop?: boolean;
591 }) =>
592 Effect.gen(function* () {
593 const parentThreadId = ThreadId.make("thread:mcp-cancel-grandchild-parent");
594 const childThreadId = ThreadId.make("thread:mcp-cancel-grandchild-child");
595 const grandchildThreadId = ThreadId.make("thread:mcp-cancel-grandchild-grandchild");
596 const taskId = NodeId.make("node:mcp-cancel-grandchild-task");
597 const dispatched = yield* Ref.make<ReadonlyArray<unknown>>([]);
598 const stoppedBelow = yield* Ref.make(false);
599 const appOwnedTask = (id: string, threadId: ThreadId, childId: ThreadId) => ({
600 id: NodeId.make(id),
601 threadId,
602 origin: "app_owned",
603 childThreadId: childId,
604 driver: "codex",
605 model: "gpt-5.6-terra",
606 result: null,
607 completionDelivery: { state: "pending" },
608 });
609 // A Supervised parent delegated a Supervised child, which delegated a task of its own.
610 const projections = new Map([
611 [
612 parentThreadId,
613 {
614 thread: {
615 id: parentThreadId,
616 runtimeMode: "approval-required",
617 interactionMode: "default",
618 },
619 runs: [],
620 contextTransfers: [],
621 subagents: [appOwnedTask(taskId, parentThreadId, childThreadId)],
622 },
623 ],
624 [
625 childThreadId,
626 {
627 thread: { id: childThreadId },
628 runs: [{ id: RunId.make("run:mcp-cancel-grandchild-child"), status: "running" }],
629 contextTransfers: [],
630 messages: [],
631 subagents: [
632 appOwnedTask("node:mcp-cancel-grandchild-below", childThreadId, grandchildThreadId),
633 ],
634 providerThreads: [],
635 },
636 ],
637 [
638 grandchildThreadId,
639 {
640 thread: { id: grandchildThreadId },
641 runs: [],
642 contextTransfers: [],
643 messages: [],
644 subagents: [],
645 providerThreads: [],
646 },
647 ],
648 ]) as unknown as ReadonlyMap<ThreadId, OrchestrationV2ThreadProjection>;
649 const layerDependencies = Layer.mergeAll(
650 NodeServices.layer,
651 Layer.mock(ThreadManagementService.ThreadManagementService)({
652 getThreadRecords: (threadId) => Effect.succeed(projections.get(threadId)!),
653 // The child still runs Supervised; its user has since raised the task under it
654 // to full access.
655 getThreadShell: (threadId) =>
656 Effect.succeed(
657 threadId === grandchildThreadId && child.raisedDuringStop !== true
658 ? liveThreadShell(threadId)
659 : threadId === childThreadId && child.deleted
660 ? null
661 : liveThreadShell(threadId, { runtimeMode: "approval-required" }),
662 ),
663 dispatch: (command) =>
664 Ref.update(dispatched, (commands) => [...commands, command]).pipe(
665 Effect.as({} as never),
666 ),
667 // Stands in for the orchestrator, which finds the grandchild raised
668 // under its lock, when the stop runs under the parent's limit.
669 stopDelegatedTasks: () =>
670 Effect.gen(function* () {
671 const limit = yield* DispatchModeLimit;
672 if (child.raisedDuringStop === true && limit?.refused !== undefined) {
673 const refusal: DispatchModeRefusal = {
674 threadId: grandchildThreadId,
675 mode: "runtime",
676 runtimeMode: "full-access",
677 interactionMode: "default",
678 };
679 yield* Ref.set(limit.refused, refusal);
680 return yield* new OrchestratorThreadAboveModeLimitError({
681 commandId: CommandId.make("stop-grandchild"),
682 threadId: grandchildThreadId,
683 mode: "runtime",
684 runtimeMode: "full-access",
685 interactionMode: "default",
686 });
687 }
688 yield* Ref.set(stoppedBelow, true);
689 }),
690 }),
691 Layer.mock(ProviderRegistry.ProviderRegistry)({ getProviders: Effect.succeed([]) }),
692 Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({
693 list: () => Effect.succeed([]),
694 }),
695 Layer.mock(ProjectService.ProjectService)({}),
696 Layer.mock(SecretRequests.SecretRequests)({}),
697 Layer.mock(ScheduledTaskService.ScheduledTaskService)({}),
698 );
699 const scope: McpInvocationScope = {
700 environmentId: EnvironmentId.make("environment:mcp-cancel-grandchild"),
701 requestNamespace: "provider-session:mcp-cancel-grandchild",
702 thread: {
703 threadId: parentThreadId,
704 providerSessionId: "provider-session:mcp-cancel-grandchild",
705 providerInstanceId: ProviderInstanceId.make("codex"),
706 },
707 client: undefined,
708 capabilities: new Set(["orchestration"]),
709 issuedAt: 1,
710 };
711
712 return yield* Effect.gen(function* () {
713 const service = yield* OrchestratorMcpService.OrchestratorMcpService;
714 const error = yield* service
715 .cancelTask(scope, { taskId, clientRequestId: "cancel-grandchild-above-modes" })
716 .pipe(Effect.flip);
717 return {
718 code: error.code,
719 dispatched: yield* Ref.get(dispatched),
720 stoppedBelow: yield* Ref.get(stoppedBelow),
721 };
722 }).pipe(Effect.provide(OrchestratorMcpService.layer.pipe(Layer.provide(layerDependencies))));
723 });
724
725 it.effect("refuses to cancel a task when a task under it now runs above the parent's modes", () =>
726 Effect.gen(function* () {
727 assert.deepEqual(yield* cancelOverRaisedGrandchild({ deleted: false }), {
728 code: "runtime_mode_escalation_denied",
729 dispatched: [],
730 stoppedBelow: false,
731 });
732 }),
733 );
734
735 it.effect("reports a task under the child that its user raises while it is being stopped", () =>
736 Effect.gen(function* () {
737 const outcome = yield* cancelOverRaisedGrandchild({ deleted: false, raisedDuringStop: true });
738 assert.equal(outcome.code, "runtime_mode_escalation_denied");
739 assert.isFalse(outcome.stoppedBelow);
740 }),
741 );
742
743 it.effect("checks the tasks under a deleted child before cancelling it", () =>
744 Effect.gen(function* () {
745 assert.deepEqual(yield* cancelOverRaisedGrandchild({ deleted: true }), {
746 code: "runtime_mode_escalation_denied",
747 dispatched: [],
748 stoppedBelow: false,
749 });
750 }),
751 );
482});752});
483753
484describe("OrchestratorMcpService provider resolution", () => {754describe("OrchestratorMcpService provider resolution", () => {
...@@ -1294,4 +1564,227 @@ describe("OrchestratorMcpService provider resolution", () => {...@@ -1294,4 +1564,227 @@ describe("OrchestratorMcpService provider resolution", () => {
1294 }1564 }
1295 }),1565 }),
1296 );1566 );
1567
1568 describe("scheduled tasks at modes above the caller's", () => {
1569 const projectId = ProjectId.make("project:scheduled");
1570 const boundThreadId = ThreadId.make("thread:scheduled-bound");
1571 const task = (overrides: Partial<ScheduledTask>): ScheduledTask => ({
1572 id: ScheduledTaskId.make("scheduled-task:webhook"),
1573 title: "On push",
1574 prompt: "Do {{body.instruction}}",
1575 enabled: true,
1576 schedule: { type: "webhook", signature: null },
1577 projectId,
1578 threadId: null,
1579 workspaceStrategy: { type: "root" },
1580 modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5" },
1581 runtimeMode: "approval-required",
1582 interactionMode: "default",
1583 createdBy: "agent",
1584 creationSource: "mcp",
1585 createdAt: "2026-10-05T00:00:00.000Z",
1586 updatedAt: "2026-10-05T00:00:00.000Z",
1587 nextRunAt: null,
1588 lastRunAt: null,
1589 lastRunStatus: "never",
1590 lastRunError: null,
1591 runCount: 0,
1592 webhook: {
1593 path: "/hooks/scheduled-task/secret",
1594 url: "https://t3.example/hooks/secret",
1595 hasSecret: false,
1596 },
1597 ...overrides,
1598 });
1599 const supervisedClient: McpInvocationScope = {
1600 environmentId: EnvironmentId.make("environment:scheduled"),
1601 requestNamespace: "client:scheduled",
1602 thread: undefined,
1603 client: {
1604 sessionId: "scheduled",
1605 label: "Claude Code",
1606 runtimeModeCeiling: "approval-required",
1607 },
1608 capabilities: new Set(["orchestration"]),
1609 issuedAt: 1,
1610 };
1611 const service = (
1612 tasks: ReadonlyArray<ScheduledTask>,
1613 boundThread: OrchestrationV2ThreadShell | null,
1614 upserted: Ref.Ref<number>,
1615 ) =>
1616 OrchestratorMcpService.layer.pipe(
1617 Layer.provide(
1618 Layer.mergeAll(
1619 NodeServices.layer,
1620 Layer.mock(ThreadManagementService.ThreadManagementService)({
1621 getThreadShell: (threadId) =>
1622 Effect.succeed(threadId === boundThreadId ? boundThread : null),
1623 }),
1624 Layer.mock(ProviderRegistry.ProviderRegistry)({ getProviders: Effect.succeed([]) }),
1625 Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({
1626 list: () => Effect.succeed([]),
1627 }),
1628 Layer.mock(ProjectService.ProjectService)({}),
1629 Layer.mock(SecretRequests.SecretRequests)({}),
1630 Layer.mock(ScheduledTaskService.ScheduledTaskService)({
1631 list: () => Effect.succeed({ tasks }),
1632 upsert: () =>
1633 Ref.update(upserted, (count) => count + 1).pipe(Effect.as({ task: tasks[0]! })),
1634 }),
1635 ),
1636 ),
1637 );
1638
1639 it.effect("hides a webhook URL from a caller below the task's modes", () =>
1640 Effect.gen(function* () {
1641 const upserted = yield* Ref.make(0);
1642 const listed = yield* OrchestratorMcpService.OrchestratorMcpService.pipe(
1643 Effect.flatMap((mcp) => mcp.listScheduledTasks(supervisedClient, { projectId })),
1644 Effect.provide(
1645 service(
1646 [
1647 task({ runtimeMode: "full-access" }),
1648 task({ id: ScheduledTaskId.make("scheduled-task:supervised") }),
1649 ],
1650 null,
1651 upserted,
1652 ),
1653 ),
1654 );
1655 assert.deepEqual(
1656 listed.tasks.map((summary) => summary.webhookUrl),
1657 [undefined, "https://t3.example/hooks/secret"],
1658 );
1659 }),
1660 );
1661
1662 it.effect("hides a webhook URL from a thread whose turn has ended", () =>
1663 Effect.gen(function* () {
1664 const callerId = ThreadId.make("thread:scheduled-ended");
1665 const shell = liveThreadShell(callerId, { activeRunId: null });
1666 const listed = yield* OrchestratorMcpService.OrchestratorMcpService.pipe(
1667 Effect.flatMap((mcp) =>
1668 mcp.listScheduledTasks(
1669 {
1670 ...supervisedClient,
1671 requestNamespace: "provider:scheduled-ended",
1672 thread: {
1673 threadId: callerId,
1674 providerSessionId: "provider:scheduled-ended",
1675 providerInstanceId: shell.providerInstanceId,
1676 },
1677 client: undefined,
1678 },
1679 { projectId },
1680 ),
1681 ),
1682 Effect.provide(
1683 OrchestratorMcpService.layer.pipe(
1684 Layer.provide(
1685 Layer.mergeAll(
1686 NodeServices.layer,
1687 Layer.mock(ThreadManagementService.ThreadManagementService)({
1688 getThreadShell: () => Effect.succeed(null),
1689 // Its turn ended: no run is active.
1690 getThreadRecords: () => Effect.succeed(idleThreadProjection(shell)),
1691 }),
1692 Layer.mock(ProviderRegistry.ProviderRegistry)({
1693 getProviders: Effect.succeed([]),
1694 }),
1695 Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({
1696 list: () => Effect.succeed([]),
1697 }),
1698 Layer.mock(ProjectService.ProjectService)({}),
1699 Layer.mock(SecretRequests.SecretRequests)({}),
1700 Layer.mock(ScheduledTaskService.ScheduledTaskService)({
1701 list: () => Effect.succeed({ tasks: [task({})] }),
1702 }),
1703 ),
1704 ),
1705 ),
1706 ),
1707 );
1708 assert.equal(listed.tasks[0]?.webhookUrl, undefined);
1709 }),
1710 );
1711
1712 it.effect("checks a bound task against its thread's current modes", () =>
1713 Effect.gen(function* () {
1714 const upserted = yield* Ref.make(0);
1715 // Scheduled while the thread was Supervised; the thread now runs in full access.
1716 const bound = task({ threadId: boundThreadId });
1717 const layer = service(
1718 [bound],
1719 liveThreadShell(boundThreadId, { runtimeMode: "full-access" }),
1720 upserted,
1721 );
1722 const mcp = yield* OrchestratorMcpService.OrchestratorMcpService.pipe(
1723 Effect.provide(layer),
1724 );
1725 const listed = yield* mcp.listScheduledTasks(supervisedClient, { projectId });
1726 assert.equal(listed.tasks[0]?.webhookUrl, undefined);
1727 const error = yield* mcp
1728 .updateScheduledTask(supervisedClient, {
1729 scheduledTaskId: bound.id,
1730 prompt: "Something else",
1731 })
1732 .pipe(Effect.flip);
1733 assert.equal(error.code, "runtime_mode_escalation_denied");
1734 assert.equal(yield* Ref.get(upserted), 0);
1735 }),
1736 );
1737
1738 it.effect("reports a saved task even when its bound thread cannot be read", () =>
1739 Effect.gen(function* () {
1740 const upserted = yield* Ref.make(0);
1741 const bound = task({ threadId: boundThreadId });
1742 const lookups = yield* Ref.make(0);
1743 // The edit's own check reads the thread; the read after the save fails.
1744 const mcp = yield* OrchestratorMcpService.OrchestratorMcpService.pipe(
1745 Effect.provide(
1746 OrchestratorMcpService.layer.pipe(
1747 Layer.provide(
1748 Layer.mergeAll(
1749 NodeServices.layer,
1750 Layer.mock(ThreadManagementService.ThreadManagementService)({
1751 getThreadShell: (threadId) =>
1752 Ref.getAndUpdate(lookups, (count) => count + 1).pipe(
1753 Effect.flatMap((count) =>
1754 count === 0
1755 ? Effect.succeed(
1756 liveThreadShell(threadId, { runtimeMode: "approval-required" }),
1757 )
1758 : Effect.fail(new OrchestratorProjectionError({ threadId })),
1759 ),
1760 ),
1761 }),
1762 Layer.mock(ProviderRegistry.ProviderRegistry)({
1763 getProviders: Effect.succeed([]),
1764 }),
1765 Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({
1766 list: () => Effect.succeed([]),
1767 }),
1768 Layer.mock(ProjectService.ProjectService)({}),
1769 Layer.mock(SecretRequests.SecretRequests)({}),
1770 Layer.mock(ScheduledTaskService.ScheduledTaskService)({
1771 list: () => Effect.succeed({ tasks: [bound] }),
1772 upsert: () =>
1773 Ref.update(upserted, (count) => count + 1).pipe(Effect.as({ task: bound })),
1774 }),
1775 ),
1776 ),
1777 ),
1778 ),
1779 );
1780 const updated = yield* mcp.updateScheduledTask(supervisedClient, {
1781 scheduledTaskId: bound.id,
1782 prompt: "Something else",
1783 });
1784 assert.equal(yield* Ref.get(upserted), 1);
1785 assert.equal(updated.scheduledTaskId, bound.id);
1786 assert.equal(updated.webhookUrl, undefined);
1787 }),
1788 );
1789 });
1297});1790});
apps/server/src/mcp/OrchestratorMcpService.ts+134-15
...@@ -66,6 +66,7 @@ import * as Effect from "effect/Effect";...@@ -66,6 +66,7 @@ import * as Effect from "effect/Effect";
66import * as Exit from "effect/Exit";66import * as Exit from "effect/Exit";
67import * as Layer from "effect/Layer";67import * as Layer from "effect/Layer";
68import * as Option from "effect/Option";68import * as Option from "effect/Option";
69import * as Ref from "effect/Ref";
69import * as Schema from "effect/Schema";70import * as Schema from "effect/Schema";
70import * as Stream from "effect/Stream";71import * as Stream from "effect/Stream";
7172
...@@ -74,6 +75,10 @@ import {...@@ -74,6 +75,10 @@ import {
74 subagentResultForRun,75 subagentResultForRun,
75 delegatedTaskProgress,76 delegatedTaskProgress,
76} from "../orchestration-v2/SubagentProjection.ts";77} from "../orchestration-v2/SubagentProjection.ts";
78import {
79 DispatchModeLimit,
80 type DispatchModeRefusal,
81} from "../orchestration-v2/DispatchModeLimit.ts";
77import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts";82import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts";
78import * as ProjectService from "../project/ProjectService.ts";83import * as ProjectService from "../project/ProjectService.ts";
79import * as ProviderRegistry from "../provider/ProviderRegistry.ts";84import * as ProviderRegistry from "../provider/ProviderRegistry.ts";
...@@ -225,7 +230,12 @@ function scheduledTaskWorkspaceStrategy(...@@ -225,7 +230,12 @@ function scheduledTaskWorkspaceStrategy(
225 : { type: "worktree", baseRef: "main", startFromOrigin: true };230 : { type: "worktree", baseRef: "main", startFromOrigin: true };
226}231}
227232
228function scheduledTaskSummary(task: ScheduledTask): OrchestratorMcpScheduledTask {233/**
234 * A scheduled task as an agent sees it. `mayRun` says whether the caller may
235 * run it: a webhook's URL carries the secret that starts the task's runs, so
236 * only such a caller sees it.
237 */
238function scheduledTaskSummary(task: ScheduledTask, mayRun: boolean): OrchestratorMcpScheduledTask {
229 return {239 return {
230 scheduledTaskId: task.id,240 scheduledTaskId: task.id,
231 title: task.title,241 title: task.title,
...@@ -237,7 +247,7 @@ function scheduledTaskSummary(task: ScheduledTask): OrchestratorMcpScheduledTask...@@ -237,7 +247,7 @@ function scheduledTaskSummary(task: ScheduledTask): OrchestratorMcpScheduledTask
237 nextRunAt: task.nextRunAt,247 nextRunAt: task.nextRunAt,
238 lastRunStatus: task.lastRunStatus,248 lastRunStatus: task.lastRunStatus,
239 // A bare path is not a URL anyone can call, so agents never get one to share.249 // A bare path is not a URL anyone can call, so agents never get one to share.
240 ...(task.webhook?.url == null ? {} : { webhookUrl: task.webhook.url }),250 ...(task.webhook?.url == null || !mayRun ? {} : { webhookUrl: task.webhook.url }),
241 ...(task.webhook === undefined251 ...(task.webhook === undefined
242 ? {}252 ? {}
243 : { webhookSignature: task.webhook.hasSecret ? "set" : "none" }),253 : { webhookSignature: task.webhook.hasSecret ? "set" : "none" }),
...@@ -1364,9 +1374,65 @@ const make = Effect.gen(function* () {...@@ -1364,9 +1374,65 @@ const make = Effect.gen(function* () {
1364 }).pipe(Effect.timeoutOption(Duration.millis(timeoutMs)), Effect.map(Option.flatten));1374 }).pipe(Effect.timeoutOption(Duration.millis(timeoutMs)), Effect.map(Option.flatten));
13651375
1366 /**1376 /**
1367 * A scheduled task the caller may change: one whose modes are no broader1377 * The modes a task's runs execute at: its own, or for a task bound to a
1368 * than the caller's own, so editing its prompt cannot run work above the1378 * thread, also that thread's modes as they are now, since its runs are
1369 * caller's limits.1379 * messages to that thread.
1380 */
1381 const scheduledTaskRunModes = (task: ScheduledTask) =>
1382 Effect.gen(function* () {
1383 const modes = [{ runtimeMode: task.runtimeMode, interactionMode: task.interactionMode }];
1384 if (task.threadId === null) return modes;
1385 const bound = yield* threadManagement
1386 .getThreadShell(task.threadId)
1387 .pipe(Effect.mapError(threadManagementFailure));
1388 return bound === null || bound.deletedAt !== null ? modes : [...modes, bound];
1389 });
1390
1391 const withinLimits = (
1392 limits: {
1393 readonly runtimeMode: RuntimeMode;
1394 readonly interactionMode: ProviderInteractionMode;
1395 },
1396 modes: { readonly runtimeMode: RuntimeMode; readonly interactionMode: ProviderInteractionMode },
1397 ) =>
1398 runtimeModeRank(modes.runtimeMode) <= runtimeModeRank(limits.runtimeMode) &&
1399 interactionModeRank(modes.interactionMode) <= interactionModeRank(limits.interactionMode);
1400
1401 /**
1402 * A task as the caller may see it. Its webhook URL starts runs, so only a
1403 * caller that may start one sees it: a thread caller with a live turn, at
1404 * modes covering every mode the task runs at.
1405 */
1406 const summarizeScheduledTask = (
1407 scope: McpInvocationScope,
1408 caller: {
1409 readonly parent: Pick<OrchestrationV2ThreadProjection, "thread" | "runs"> | undefined;
1410 readonly limits: {
1411 readonly runtimeMode: RuntimeMode;
1412 readonly interactionMode: ProviderInteractionMode;
1413 };
1414 },
1415 task: ScheduledTask,
1416 ) =>
1417 Effect.gen(function* () {
1418 const live =
1419 caller.parent === undefined ||
1420 Exit.isSuccess(yield* Effect.exit(assertLiveCaller(scope, caller.parent)));
1421 // This runs after a save, so a failed lookup of the bound thread hides
1422 // the webhook URL instead of reporting a saved task as an error.
1423 const modes = yield* scheduledTaskRunModes(task).pipe(Effect.option);
1424 return scheduledTaskSummary(
1425 task,
1426 live &&
1427 Option.isSome(modes) &&
1428 modes.value.every((mode) => withinLimits(caller.limits, mode)),
1429 );
1430 });
1431
1432 /**
1433 * A scheduled task the caller may change: one whose runs execute at modes no
1434 * broader than the caller's own, so editing its prompt cannot run work above
1435 * the caller's limits.
1370 */1436 */
1371 const loadScheduledTask = (1437 const loadScheduledTask = (
1372 scheduledTaskId: ScheduledTask["id"],1438 scheduledTaskId: ScheduledTask["id"],
...@@ -1387,8 +1453,10 @@ const make = Effect.gen(function* () {...@@ -1387,8 +1453,10 @@ const make = Effect.gen(function* () {
1387 if (task === undefined) {1453 if (task === undefined) {
1388 return yield* failure("task_not_found", `Scheduled task ${scheduledTaskId} was not found.`);1454 return yield* failure("task_not_found", `Scheduled task ${scheduledTaskId} was not found.`);
1389 }1455 }
1390 yield* resolveRuntimeMode(limits.runtimeMode, task.runtimeMode);1456 for (const modes of yield* scheduledTaskRunModes(task)) {
1391 yield* resolveInteractionMode(limits.interactionMode, task.interactionMode);1457 yield* resolveRuntimeMode(limits.runtimeMode, modes.runtimeMode);
1458 yield* resolveInteractionMode(limits.interactionMode, modes.interactionMode);
1459 }
1392 return task;1460 return task;
1393 });1461 });
13941462
...@@ -1451,11 +1519,11 @@ const make = Effect.gen(function* () {...@@ -1451,11 +1519,11 @@ const make = Effect.gen(function* () {
1451 failure("orchestration_error", `Could not schedule task: ${error.message}`),1519 failure("orchestration_error", `Could not schedule task: ${error.message}`),
1452 ),1520 ),
1453 );1521 );
1454 return scheduledTaskSummary(task);1522 return yield* summarizeScheduledTask(scope, { parent, limits }, task);
1455 }),1523 }),
1456 listScheduledTasks: (scope, input) =>1524 listScheduledTasks: (scope, input) =>
1457 Effect.gen(function* () {1525 Effect.gen(function* () {
1458 const { parent } = yield* loadCaller(scope);1526 const { parent, limits } = yield* loadCaller(scope);
1459 const projectId = input.projectId ?? parent?.thread.projectId;1527 const projectId = input.projectId ?? parent?.thread.projectId;
1460 const { tasks } = yield* scheduledTasks1528 const { tasks } = yield* scheduledTasks
1461 .list()1529 .list()
...@@ -1465,9 +1533,10 @@ const make = Effect.gen(function* () {...@@ -1465,9 +1533,10 @@ const make = Effect.gen(function* () {
1465 ),1533 ),
1466 );1534 );
1467 return {1535 return {
1468 tasks: tasks1536 tasks: yield* Effect.forEach(
1469 .filter((task) => projectId === undefined || task.projectId === projectId)1537 tasks.filter((task) => projectId === undefined || task.projectId === projectId),
1470 .map(scheduledTaskSummary),1538 (task) => summarizeScheduledTask(scope, { parent, limits }, task),
1539 ),
1471 };1540 };
1472 }),1541 }),
1473 updateScheduledTask: (scope, input) =>1542 updateScheduledTask: (scope, input) =>
...@@ -1521,7 +1590,7 @@ const make = Effect.gen(function* () {...@@ -1521,7 +1590,7 @@ const make = Effect.gen(function* () {
1521 failure("orchestration_error", `Could not update scheduled task: ${error.message}`),1590 failure("orchestration_error", `Could not update scheduled task: ${error.message}`),
1522 ),1591 ),
1523 );1592 );
1524 return scheduledTaskSummary(task);1593 return yield* summarizeScheduledTask(scope, { parent, limits }, task);
1525 }),1594 }),
1526 deleteScheduledTask: (scope, input) =>1595 deleteScheduledTask: (scope, input) =>
1527 Effect.gen(function* () {1596 Effect.gen(function* () {
...@@ -1851,6 +1920,32 @@ const make = Effect.gen(function* () {...@@ -1851,6 +1920,32 @@ const make = Effect.gen(function* () {
1851 const current = yield* readTask(scope, input.taskId);1920 const current = yield* readTask(scope, input.taskId);
1852 const key = yield* requestKey(input.clientRequestId);1921 const key = yield* requestKey(input.clientRequestId);
1853 const parentProjection = yield* loadProjection(scope.thread.threadId);1922 const parentProjection = yield* loadProjection(scope.thread.threadId);
1923 // Cancelling stops the child and every task under it, each a write to a
1924 // thread its user may have raised above the parent's modes since it was
1925 // delegated. All of them are checked before anything is stopped.
1926 const assertStoppable = (threadId: ThreadId): Effect.Effect<void, OrchestratorMcpFailure> =>
1927 Effect.gen(function* () {
1928 const shell = yield* threadManagement
1929 .getThreadShell(threadId)
1930 .pipe(Effect.mapError(threadManagementFailure));
1931 // A deleted thread takes no stop, but the tasks under it still do.
1932 if (shell !== null && shell.deletedAt === null) {
1933 yield* resolveRuntimeMode(parentProjection.thread.runtimeMode, shell.runtimeMode);
1934 yield* resolveInteractionMode(
1935 parentProjection.thread.interactionMode,
1936 shell.interactionMode,
1937 );
1938 }
1939 const { subagents } = yield* threadManagement
1940 .getThreadRecords(threadId, ["subagents"])
1941 .pipe(Effect.mapError(threadManagementFailure));
1942 for (const task of subagents) {
1943 if (task.origin === "app_owned" && task.childThreadId !== null) {
1944 yield* assertStoppable(task.childThreadId);
1945 }
1946 }
1947 });
1948 yield* assertStoppable(current.childThreadId);
1854 const parentTask = parentProjection.subagents.find(1949 const parentTask = parentProjection.subagents.find(
1855 (task) => task.id === input.taskId && task.origin === "app_owned",1950 (task) => task.id === input.taskId && task.origin === "app_owned",
1856 );1951 );
...@@ -1910,8 +2005,32 @@ const make = Effect.gen(function* () {...@@ -1910,8 +2005,32 @@ const make = Effect.gen(function* () {
1910 ),2005 ),
1911 );2006 );
1912 });2007 });
2008 // Checked above; a user raising one of these threads meanwhile is
2009 // caught again under that thread's lock, which leaves it running.
2010 const refused = yield* Ref.make<DispatchModeRefusal | undefined>(undefined);
2011 const stopWithinLimit = stopChild.pipe(
2012 Effect.provideService(DispatchModeLimit, {
2013 runtimeMode: parentProjection.thread.runtimeMode,
2014 interactionMode: parentProjection.thread.interactionMode,
2015 refused,
2016 }),
2017 Effect.catch((error) =>
2018 Effect.flatMap(Ref.get(refused), (refusal) =>
2019 Effect.fail(
2020 refusal === undefined
2021 ? error
2022 : failure(
2023 refusal.mode === "runtime"
2024 ? "runtime_mode_escalation_denied"
2025 : "interaction_mode_escalation_denied",
2026 `Thread ${refusal.threadId} now runs in ${refusal.runtimeMode}/${refusal.interactionMode} mode, above this thread's; its user changed it while the task was being cancelled.`,
2027 ),
2028 ),
2029 ),
2030 ),
2031 );
1913 if (isTerminalTaskStatus(current.status)) {2032 if (isTerminalTaskStatus(current.status)) {
1914 yield* stopChild;2033 yield* stopWithinLimit;
1915 yield* disposeCompletionDelivery;2034 yield* disposeCompletionDelivery;
1916 return {2035 return {
1917 taskId: input.taskId,2036 taskId: input.taskId,
...@@ -1930,7 +2049,7 @@ const make = Effect.gen(function* () {...@@ -1930,7 +2049,7 @@ const make = Effect.gen(function* () {
1930 `Delegated task ${input.taskId} has no interruptible child run.`,2049 `Delegated task ${input.taskId} has no interruptible child run.`,
1931 );2050 );
1932 }2051 }
1933 yield* stopChild;2052 yield* stopWithinLimit;
1934 yield* disposeCompletionDelivery.pipe(2053 yield* disposeCompletionDelivery.pipe(
1935 Effect.catchCause((cause) =>2054 Effect.catchCause((cause) =>
1936 Effect.logWarning("orchestrator-mcp.cancel-task.delivery-dispose-failed", {2055 Effect.logWarning("orchestrator-mcp.cancel-task.delivery-dispose-failed", {
apps/server/src/mcp/ThreadMetadataMcpService.test.ts-88
...@@ -78,91 +78,3 @@ it.effect("keeps calling-thread storage failures as orchestration errors", () =>...@@ -78,91 +78,3 @@ it.effect("keeps calling-thread storage failures as orchestration errors", () =>
78 expect(error.code).toBe("orchestration_error");78 expect(error.code).toBe("orchestration_error");
79 }),79 }),
80);80);
81
82it.effect("refuses to change a thread that runs above the caller's modes", () =>
83 Effect.gen(function* () {
84 const fullAccessThread = ThreadId.make("thread:metadata-full-access");
85 const shells = new Map([
86 [
87 threadId,
88 {
89 id: threadId,
90 projectId: "project",
91 runtimeMode: "auto",
92 interactionMode: "default",
93 activeRunId: "run-live",
94 archivedAt: null,
95 providerInstanceId: "codex",
96 deletedAt: null,
97 },
98 ],
99 [
100 fullAccessThread,
101 {
102 id: fullAccessThread,
103 projectId: "project",
104 runtimeMode: "full-access",
105 interactionMode: "default",
106 deletedAt: null,
107 },
108 ],
109 ]);
110 const error = yield* Effect.gen(function* () {
111 const service = yield* ThreadMetadataMcp.ThreadMetadataMcpService;
112 return yield* service.update(scope, {
113 threadId: fullAccessThread,
114 action: "rename",
115 title: "Renamed from a narrower thread",
116 });
117 }).pipe(
118 Effect.provide(layerService((id) => Effect.succeed((shells.get(id) ?? null) as never))),
119 Effect.flip,
120 );
121
122 expect(error.code).toBe("runtime_mode_escalation_denied");
123 }),
124);
125
126it.effect("refuses another thread's metadata to a thread caller whose run has ended", () =>
127 Effect.gen(function* () {
128 const otherThread = ThreadId.make("thread:metadata-other");
129 const shells = new Map([
130 [
131 threadId,
132 {
133 id: threadId,
134 projectId: "project",
135 runtimeMode: "full-access",
136 interactionMode: "default",
137 activeRunId: null,
138 archivedAt: null,
139 providerInstanceId: "codex",
140 deletedAt: null,
141 },
142 ],
143 [
144 otherThread,
145 {
146 id: otherThread,
147 projectId: "project",
148 runtimeMode: "approval-required",
149 interactionMode: "default",
150 deletedAt: null,
151 },
152 ],
153 ]);
154 const error = yield* Effect.gen(function* () {
155 const service = yield* ThreadMetadataMcp.ThreadMetadataMcpService;
156 return yield* service.update(scope, {
157 threadId: otherThread,
158 action: "rename",
159 title: "Renamed after the run ended",
160 });
161 }).pipe(
162 Effect.provide(layerService((id) => Effect.succeed((shells.get(id) ?? null) as never))),
163 Effect.flip,
164 );
165
166 expect(error.code).toBe("parent_not_active");
167 }),
168);
apps/server/src/mcp/ThreadMetadataMcpService.ts-37
...@@ -16,7 +16,6 @@ import * as Layer from "effect/Layer";...@@ -16,7 +16,6 @@ import * as Layer from "effect/Layer";
1616
17import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts";17import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts";
18import type { McpInvocationScope } from "./McpInvocationContext.ts";18import type { McpInvocationScope } from "./McpInvocationContext.ts";
19import { assertTargetWithinLimits } from "./threadAccess.ts";
2019
21export class ThreadMetadataMcpService extends Context.Service<20export class ThreadMetadataMcpService extends Context.Service<
22 ThreadMetadataMcpService,21 ThreadMetadataMcpService,
...@@ -167,42 +166,6 @@ const make = Effect.gen(function* () {...@@ -167,42 +166,6 @@ const make = Effect.gen(function* () {
167 if (shell === null || shell.deletedAt !== null) {166 if (shell === null || shell.deletedAt !== null) {
168 return yield* failure("thread_not_found", `Thread ${threadId} was not found.`);167 return yield* failure("thread_not_found", `Thread ${threadId} was not found.`);
169 }168 }
170 // Another thread may only be changed if it runs within the caller's own modes.
171 if (threadId !== scope.thread?.threadId) {
172 const limits =
173 scope.thread === undefined
174 ? {
175 runtimeMode: scope.client?.runtimeModeCeiling ?? ("approval-required" as const),
176 interactionMode: "default" as const,
177 }
178 : yield* threadManagement.getThreadShell(scope.thread.threadId).pipe(
179 Effect.mapError((error) =>
180 failure(
181 "orchestration_error",
182 `Unable to locate calling thread: ${errorMessage(error)}`,
183 ),
184 ),
185 Effect.flatMap((caller) =>
186 caller === null
187 ? Effect.fail(failure("thread_not_found", "The calling thread was not found."))
188 : // Like every other cross-thread write, a thread caller needs its live run.
189 caller.archivedAt !== null ||
190 caller.activeRunId === null ||
191 caller.providerInstanceId !== scope.thread?.providerInstanceId
192 ? Effect.fail(
193 failure(
194 "parent_not_active",
195 "The calling provider no longer owns an active thread run.",
196 ),
197 )
198 : Effect.succeed({
199 runtimeMode: caller.runtimeMode,
200 interactionMode: caller.interactionMode,
201 }),
202 ),
203 );
204 yield* assertTargetWithinLimits(limits, shell);
205 }
206 const target = yield* threadManagement169 const target = yield* threadManagement
207 .getProjectThreadRecords({ projectId: shell.projectId, threadId }, [])170 .getProjectThreadRecords({ projectId: shell.projectId, threadId }, [])
208 .pipe(Effect.mapError(threadLookupFailure));171 .pipe(Effect.mapError(threadLookupFailure));
apps/server/src/mcp/threadAccess.ts+22-37
...@@ -60,6 +60,15 @@ export const readCaller = Effect.fn("mcp.readCaller")(function* () {...@@ -60,6 +60,15 @@ export const readCaller = Effect.fn("mcp.readCaller")(function* () {
60 message: "This credential cannot control threads.",60 message: "This credential cannot control threads.",
61 });61 });
62 }62 }
63 return yield* loadCaller();
64});
65
66/**
67 * The caller and its limits, whichever tools its credential grants. Tools
68 * check their own capability; `McpToolAccess` uses this for every tool.
69 */
70export const loadCaller = Effect.fn("mcp.loadCaller")(function* () {
71 const scope = yield* McpInvocationContext.McpInvocationContext;
63 const threads = yield* ThreadManagement.ThreadManagementService;72 const threads = yield* ThreadManagement.ThreadManagementService;
64 if (scope.thread === undefined) {73 if (scope.thread === undefined) {
65 return {74 return {
...@@ -104,7 +113,12 @@ export const assertTargetWithinLimits = (...@@ -104,7 +113,12 @@ export const assertTargetWithinLimits = (
104 Effect.asVoid,113 Effect.asVoid,
105 );114 );
106115
107function assertLiveCaller({ caller, scope }: Caller) {116/**
117 * A thread caller acts only while it owns a live run of a thread that is not
118 * archived, so a provider token that outlived its session cannot act. An
119 * OAuth client has no run; its session and ceiling are its authority.
120 */
121export function assertLiveCaller({ caller, scope }: Caller) {
108 if (caller === undefined) return Effect.void;122 if (caller === undefined) return Effect.void;
109 return caller.archivedAt !== null ||123 return caller.archivedAt !== null ||
110 caller.activeRunId === null ||124 caller.activeRunId === null ||
...@@ -119,34 +133,14 @@ function assertLiveCaller({ caller, scope }: Caller) {...@@ -119,34 +133,14 @@ function assertLiveCaller({ caller, scope }: Caller) {
119}133}
120134
121/**135/**
122 * Mutations from a thread need that thread's live run, so an agent whose turn136 * Actions that change the environment itself (projects, preferences) need full
123 * ended cannot keep acting. An OAuth client has no run; its session and137 * access: a thread caller in full-access/default mode, or a client approved
124 * ceiling are its authority.138 * with a full-access ceiling.
125 */
126export const readMutationCaller = Effect.fn("mcp.readMutationCaller")(function* () {
127 const context = yield* readCaller();
128 yield* assertLiveCaller(context);
129 return context;
130});
131
132/**
133 * Actions that change the environment itself (projects, preferences, launching
134 * outside a project) need full access: a thread caller in full-access/default
135 * mode, or a client approved with a full-access ceiling.
136 */139 */
137export const readFullAccessCaller = Effect.fn("mcp.readFullAccessCaller")(function* (140export const assertFullAccess = (context: Caller, message: string) =>
138 message: string,141 context.limits.runtimeMode === "full-access" && context.limits.interactionMode === "default"
139) {142 ? Effect.void
140 const context = yield* readMutationCaller();143 : Effect.fail(new OrchestratorMcpFailure({ code: "capability_denied", message }));
141 if (
142 (context.caller !== undefined && context.caller.archivedAt !== null) ||
143 context.limits.runtimeMode !== "full-access" ||
144 context.limits.interactionMode !== "default"
145 ) {
146 return yield* new OrchestratorMcpFailure({ code: "capability_denied", message });
147 }
148 return context;
149});
150144
151/** A target project: the one passed, else the calling thread's. */145/** A target project: the one passed, else the calling thread's. */
152export const resolveProjectId = (context: Caller, projectId: ProjectId | undefined) =>146export const resolveProjectId = (context: Caller, projectId: ProjectId | undefined) =>
...@@ -204,15 +198,6 @@ export const readThread = Effect.fn("mcp.readThread")(function* <...@@ -204,15 +198,6 @@ export const readThread = Effect.fn("mcp.readThread")(function* <
204 return { ...context, projection };198 return { ...context, projection };
205});199});
206200
207export const readWritableThread = Effect.fn("mcp.readWritableThread")(function* <
208 K extends ProjectionRecordField = never,
209>(threadId?: ThreadId, fields: ReadonlyArray<K> = []) {
210 const context = yield* readThread(threadId, fields);
211 yield* assertLiveCaller(context);
212 yield* assertTargetWithinLimits(context.limits, context.projection.thread);
213 return context;
214});
215
216export const newCommandId = Effect.fn("mcp.newCommandId")(function* () {201export const newCommandId = Effect.fn("mcp.newCommandId")(function* () {
217 const crypto = yield* Crypto.Crypto;202 const crypto = yield* Crypto.Crypto;
218 return CommandId.make(`mcp:${yield* crypto.randomUUIDv4.pipe(Effect.orDie)}`);203 return CommandId.make(`mcp:${yield* crypto.randomUUIDv4.pipe(Effect.orDie)}`);
apps/server/src/mcp/toolkits/attachment/handlers.ts+51-59
...@@ -3,12 +3,8 @@ import * as Effect from "effect/Effect";...@@ -3,12 +3,8 @@ import * as Effect from "effect/Effect";
3import * as Upload from "../../../assets/AttachmentUpload.ts";3import * as Upload from "../../../assets/AttachmentUpload.ts";
4import * as Claims from "../../../orchestration-v2/AttachmentClaims.ts";4import * as Claims from "../../../orchestration-v2/AttachmentClaims.ts";
5import * as ThreadMessageIntake from "../../../orchestration-v2/ThreadMessageIntake.ts";5import * as ThreadMessageIntake from "../../../orchestration-v2/ThreadMessageIntake.ts";
6import {6import * as McpToolAccess from "../../McpToolAccess.ts";
7 newCommandId,7import { newCommandId, readThread, unavailable } from "../../threadAccess.ts";
8 readMutationCaller,
9 readWritableThread,
10 unavailable,
11} from "../../threadAccess.ts";
12import { AttachmentToolkit } from "./tools.ts";8import { AttachmentToolkit } from "./tools.ts";
139
14export function resolveAttachmentReferences(10export function resolveAttachmentReferences(
...@@ -31,57 +27,53 @@ export function resolveAttachmentReferences(...@@ -31,57 +27,53 @@ export function resolveAttachmentReferences(
31 });27 });
32}28}
3329
34export const layer = AttachmentToolkit.toLayer({30export const layer = McpToolAccess.toLayer(AttachmentToolkit, {
35 t3_attachment_prepare_upload: (input) =>31 t3_attachment_prepare_upload: McpToolAccess.writes((input) =>
36 Effect.gen(function* () {32 Upload.issueAttachmentUploadUrl(input.upload).pipe(Effect.mapError(unavailable)),
37 yield* readMutationCaller();33 ),
38 return yield* Upload.issueAttachmentUploadUrl(input.upload).pipe(34 t3_attachment_discard: McpToolAccess.writes((input) =>
39 Effect.mapError(unavailable),35 Upload.deletePendingAttachment(input.attachmentId).pipe(Effect.as({})),
40 );36 ),
41 }),37 t3_thread_send_attachments: McpToolAccess.writesThreads(
42 t3_attachment_discard: (input) =>38 (input) => [input.threadId],
43 Effect.gen(function* () {39 (input) =>
44 yield* readMutationCaller();40 Effect.gen(function* () {
45 yield* Upload.deletePendingAttachment(input.attachmentId);41 const { caller, projection } = yield* readThread(input.threadId, ["messages"]);
46 return {};42 if (projection.thread.archivedAt !== null)
47 }),43 return yield* new OrchestratorMcpFailure({
48 t3_thread_send_attachments: (input) =>44 code: "invalid_request",
49 Effect.gen(function* () {45 message: "Unarchive the target thread before sending attachments.",
50 const { caller, projection } = yield* readWritableThread(input.threadId, ["messages"]);46 });
51 if (projection.thread.archivedAt !== null)47 const attachments = yield* resolveAttachmentReferences(
52 return yield* new OrchestratorMcpFailure({48 input.attachments,
53 code: "invalid_request",49 projection.messages.flatMap((message) => message.attachments),
54 message: "Unarchive the target thread before sending attachments.",50 );
55 });51 const commandId = yield* newCommandId();
56 const attachments = yield* resolveAttachmentReferences(52 const messageId = MessageId.make(commandId);
57 input.attachments,53 const result = yield* ThreadMessageIntake.sendToThread({
58 projection.messages.flatMap((message) => message.attachments),54 projectId: projection.thread.projectId,
59 );55 threadId: projection.thread.id,
60 const commandId = yield* newCommandId();56 commandId,
61 const messageId = MessageId.make(commandId);57 messageId,
62 const result = yield* ThreadMessageIntake.sendToThread({58 ...(caller === undefined ? {} : { senderThreadId: caller.id }),
63 projectId: projection.thread.projectId,59 text: input.message ?? "",
64 threadId: projection.thread.id,60 attachments,
65 commandId,61 mode: "auto",
66 messageId,62 createdBy: "agent",
67 ...(caller === undefined ? {} : { senderThreadId: caller.id }),63 creationSource: "mcp",
68 text: input.message ?? "",64 }).pipe(
69 attachments,65 Effect.mapError((error) =>
70 mode: "auto",66 error._tag === "AttachmentClaimError"
71 createdBy: "agent",67 ? new OrchestratorMcpFailure({ code: "orchestration_error", message: error.message })
72 creationSource: "mcp",68 : unavailable(),
73 }).pipe(69 ),
74 Effect.mapError((error) =>70 );
75 error._tag === "AttachmentClaimError"71 return {
76 ? new OrchestratorMcpFailure({ code: "orchestration_error", message: error.message })72 threadId: projection.thread.id,
77 : unavailable(),73 messageId,
78 ),74 runId: result.run.id,
79 );75 status: result.run.status,
80 return {76 };
81 threadId: projection.thread.id,77 }),
82 messageId,78 ),
83 runId: result.run.id,
84 status: result.run.status,
85 };
86 }),
87});79});
apps/server/src/mcp/toolkits/core.test.ts+6-9
...@@ -36,6 +36,7 @@ import * as SecretRequests from "../../secrets/SecretRequests.ts";...@@ -36,6 +36,7 @@ import * as SecretRequests from "../../secrets/SecretRequests.ts";
36import * as ScheduledTaskService from "../../scheduledTasks/ScheduledTaskService.ts";36import * as ScheduledTaskService from "../../scheduledTasks/ScheduledTaskService.ts";
37import * as McpHttpServer from "../McpHttpServer.ts";37import * as McpHttpServer from "../McpHttpServer.ts";
38import * as McpInvocationContext from "../McpInvocationContext.ts";38import * as McpInvocationContext from "../McpInvocationContext.ts";
39import * as McpToolAccessTestkit from "../McpToolAccess.testkit.ts";
39import { dispatchFailure } from "../threadAccess.ts";40import { dispatchFailure } from "../threadAccess.ts";
40import { OrchestratorToolkit } from "./orchestrator/tools.ts";41import { OrchestratorToolkit } from "./orchestrator/tools.ts";
41import { PreviewToolkit } from "./preview/tools.ts";42import { PreviewToolkit } from "./preview/tools.ts";
...@@ -131,7 +132,7 @@ const client = McpSchema.McpServerClient.of({...@@ -131,7 +132,7 @@ const client = McpSchema.McpServerClient.of({
131 getClient: Effect.die("unused"),132 getClient: Effect.die("unused"),
132});133});
133134
134it.effect("checks capability before accessing services through the production registration", () =>135it.effect("checks capability through the production registration", () =>
135 Effect.gen(function* () {136 Effect.gen(function* () {
136 const server = yield* McpServer.McpServer;137 const server = yield* McpServer.McpServer;
137 expect(server.tools.some(({ tool }) => tool.name === "t3_thread_organize")).toBe(true);138 expect(server.tools.some(({ tool }) => tool.name === "t3_thread_organize")).toBe(true);
...@@ -152,7 +153,7 @@ it.effect("checks capability before accessing services through the production re...@@ -152,7 +153,7 @@ it.effect("checks capability before accessing services through the production re
152 McpHttpServer.layerThreadToolkit.pipe(153 McpHttpServer.layerThreadToolkit.pipe(
153 Layer.provideMerge(McpServer.McpServer.layer),154 Layer.provideMerge(McpServer.McpServer.layer),
154 Layer.provide(NodeCrypto.layer),155 Layer.provide(NodeCrypto.layer),
155 Layer.provide(Layer.mock(ThreadManagement.ThreadManagementService)({})),156 Layer.provide(McpToolAccessTestkit.liveThreadsLayer),
156 ),157 ),
157 ),158 ),
158 ),159 ),
...@@ -453,12 +454,8 @@ it.effect("a client caller targets any thread within its ceiling and cannot act...@@ -453,12 +454,8 @@ it.effect("a client caller targets any thread within its ceiling and cannot act
453 Layer.provide(NodeCrypto.layer),454 Layer.provide(NodeCrypto.layer),
454 Layer.provide(455 Layer.provide(
455 Layer.mock(ThreadManagement.ThreadManagementService)({456 Layer.mock(ThreadManagement.ThreadManagementService)({
456 getThreadShell: () =>457 getThreadShell: (id) =>
457 Effect.succeed({458 Effect.succeed(McpToolAccessTestkit.liveThreadShell(id, { runtimeMode: "auto" })),
458 id: ThreadId.make("other-project-thread"),
459 projectId: "other-project",
460 deletedAt: null,
461 } as never),
462 getProjectThreadRecords: () =>459 getProjectThreadRecords: () =>
463 Effect.succeed({460 Effect.succeed({
464 thread: {461 thread: {
...@@ -469,7 +466,7 @@ it.effect("a client caller targets any thread within its ceiling and cannot act...@@ -469,7 +466,7 @@ it.effect("a client caller targets any thread within its ceiling and cannot act
469 deletedAt: null,466 deletedAt: null,
470 },467 },
471 } as never),468 } as never),
472 dispatch: () => Effect.succeed({ sequence: 7 } as never),469 dispatch: () => Effect.succeed({ sequence: 7, storedEvents: [] }),
473 }),470 }),
474 ),471 ),
475 ),472 ),
apps/server/src/mcp/toolkits/device/handlers.ts+12-7
...@@ -16,6 +16,7 @@ import { nodeRuntimeUnavailableMessage } from "@t3tools/shared/nodeRuntime";...@@ -16,6 +16,7 @@ import { nodeRuntimeUnavailableMessage } from "@t3tools/shared/nodeRuntime";
1616
17import * as DeviceService from "../../../device/DeviceService.ts";17import * as DeviceService from "../../../device/DeviceService.ts";
18import * as McpInvocationContext from "../../McpInvocationContext.ts";18import * as McpInvocationContext from "../../McpInvocationContext.ts";
19import * as McpToolAccess from "../../McpToolAccess.ts";
19import { DeviceScreenshotToolkit, DeviceStandardToolkit, DeviceToolkit } from "./tools.ts";20import { DeviceScreenshotToolkit, DeviceStandardToolkit, DeviceToolkit } from "./tools.ts";
2021
21/** The flags that pin every agent-device command to one device. */22/** The flags that pin every agent-device command to one device. */
...@@ -119,7 +120,7 @@ const pickDevice = (...@@ -119,7 +120,7 @@ const pickDevice = (
119const toolError = (error: DeviceError | DeviceToolUnavailableError) => error;120const toolError = (error: DeviceError | DeviceToolUnavailableError) => error;
120121
121const handlers = {122const handlers = {
122 device_list: (input) =>123 device_list: McpToolAccess.readsAsCaller((input) =>
123 Effect.gen(function* () {124 Effect.gen(function* () {
124 const scope = yield* requireDeviceAccess;125 const scope = yield* requireDeviceAccess;
125 const devices = yield* DeviceService.DeviceService;126 const devices = yield* DeviceService.DeviceService;
...@@ -145,7 +146,8 @@ const handlers = {...@@ -145,7 +146,8 @@ const handlers = {
145 open,146 open,
146 };147 };
147 }).pipe(Effect.mapError(toolError)),148 }).pipe(Effect.mapError(toolError)),
148 device_open: (input) =>149 ),
150 device_open: McpToolAccess.actsAsCaller((input) =>
149 Effect.gen(function* () {151 Effect.gen(function* () {
150 const scope = yield* requireDeviceAccess;152 const scope = yield* requireDeviceAccess;
151 const devices = yield* DeviceService.DeviceService;153 const devices = yield* DeviceService.DeviceService;
...@@ -203,7 +205,8 @@ const handlers = {...@@ -203,7 +205,8 @@ const handlers = {
203 quickStart: agentDeviceQuickStart(device, targetArgs, command),205 quickStart: agentDeviceQuickStart(device, targetArgs, command),
204 };206 };
205 }).pipe(Effect.mapError(toolError)),207 }).pipe(Effect.mapError(toolError)),
206 device_screenshot: (input) =>208 ),
209 device_screenshot: McpToolAccess.readsAsCaller((input) =>
207 Effect.gen(function* () {210 Effect.gen(function* () {
208 const scope = yield* requireDeviceAccess;211 const scope = yield* requireDeviceAccess;
209 const devices = yield* DeviceService.DeviceService;212 const devices = yield* DeviceService.DeviceService;
...@@ -229,7 +232,8 @@ const handlers = {...@@ -229,7 +232,8 @@ const handlers = {
229 },232 },
230 };233 };
231 }).pipe(Effect.mapError(toolError)),234 }).pipe(Effect.mapError(toolError)),
232 device_close: (input) =>235 ),
236 device_close: McpToolAccess.actsAsCaller((input) =>
233 Effect.gen(function* () {237 Effect.gen(function* () {
234 const scope = yield* requireDeviceAccess;238 const scope = yield* requireDeviceAccess;
235 const devices = yield* DeviceService.DeviceService;239 const devices = yield* DeviceService.DeviceService;
...@@ -241,7 +245,8 @@ const handlers = {...@@ -241,7 +245,8 @@ const handlers = {
241 });245 });
242 return {};246 return {};
243 }).pipe(Effect.mapError(toolError)),247 }).pipe(Effect.mapError(toolError)),
244} satisfies Parameters<typeof DeviceToolkit.toLayer>[0];248 ),
249} satisfies McpToolAccess.Handlers<typeof DeviceToolkit.tools>;
245250
246/** Width and height from the IHDR chunk; a PNG that lacks one reports 0×0. */251/** Width and height from the IHDR chunk; a PNG that lacks one reports 0×0. */
247export function pngDimensions(png: Uint8Array): { width: number; height: number } {252export function pngDimensions(png: Uint8Array): { width: number; height: number } {
...@@ -258,8 +263,8 @@ export function pngDimensions(png: Uint8Array): { width: number; height: number...@@ -258,8 +263,8 @@ export function pngDimensions(png: Uint8Array): { width: number; height: number
258263
259const { device_screenshot, ...standardHandlers } = handlers;264const { device_screenshot, ...standardHandlers } = handlers;
260265
261export const layerStandard = DeviceStandardToolkit.toLayer(standardHandlers);266export const layerStandard = McpToolAccess.toLayer(DeviceStandardToolkit, standardHandlers);
262267
263export const layerScreenshot = DeviceScreenshotToolkit.toLayer({268export const layerScreenshot = McpToolAccess.toLayer(DeviceScreenshotToolkit, {
264 device_screenshot,269 device_screenshot,
265});270});
apps/server/src/mcp/toolkits/device/tools.ts+14-5
...@@ -1,4 +1,5 @@...@@ -1,4 +1,5 @@
1import {1import {
2 OrchestratorMcpFailure,
2 DeviceToolCloseInput,3 DeviceToolCloseInput,
3 DeviceToolError,4 DeviceToolError,
4 DeviceToolListResult,5 DeviceToolListResult,
...@@ -15,8 +16,16 @@ import { Tool, Toolkit } from "effect/ai";...@@ -15,8 +16,16 @@ import { Tool, Toolkit } from "effect/ai";
1516
16import * as McpInvocationContext from "../../McpInvocationContext.ts";17import * as McpInvocationContext from "../../McpInvocationContext.ts";
17import * as DeviceService from "../../../device/DeviceService.ts";18import * as DeviceService from "../../../device/DeviceService.ts";
19import * as ThreadManagementService from "../../../orchestration-v2/ThreadManagementService.ts";
1820
19const dependencies = [McpInvocationContext.McpInvocationContext, DeviceService.DeviceService];21const dependencies = [
22 McpInvocationContext.McpInvocationContext,
23 ThreadManagementService.ThreadManagementService,
24 DeviceService.DeviceService,
25];
26
27/** What a device tool fails with, including the access gate's refusal. */
28const DeviceToolFailure = Schema.Union([DeviceToolError, OrchestratorMcpFailure]);
2029
21/**30/**
22 * Deliberately a small surface: lifecycle, visibility for the user, and one31 * Deliberately a small surface: lifecycle, visibility for the user, and one
...@@ -36,7 +45,7 @@ const DeviceListTool = Tool.make("device_list", {...@@ -36,7 +45,7 @@ const DeviceListTool = Tool.make("device_list", {
36 ),45 ),
37 }),46 }),
38 success: DeviceToolListResult,47 success: DeviceToolListResult,
39 failure: DeviceToolError,48 failure: DeviceToolFailure,
40 dependencies,49 dependencies,
41})50})
42 .annotate(Tool.Title, "List devices")51 .annotate(Tool.Title, "List devices")
...@@ -50,7 +59,7 @@ const DeviceOpenTool = Tool.make("device_open", {...@@ -50,7 +59,7 @@ const DeviceOpenTool = Tool.make("device_open", {
50 "Open a simulator or emulator for this thread: boots it if needed, starts its live stream, and shows it in the user's Device panel so they can watch. Returns the agent-device CLI invocation pinned to the device; drive the device with that CLI afterwards.",59 "Open a simulator or emulator for this thread: boots it if needed, starts its live stream, and shows it in the user's Device panel so they can watch. Returns the agent-device CLI invocation pinned to the device; drive the device with that CLI afterwards.",
51 parameters: DeviceToolOpenInput,60 parameters: DeviceToolOpenInput,
52 success: DeviceToolOpenResult,61 success: DeviceToolOpenResult,
53 failure: DeviceToolError,62 failure: DeviceToolFailure,
54 dependencies: [...dependencies, FileSystem.FileSystem, Path.Path, ServerConfig.ServerConfig],63 dependencies: [...dependencies, FileSystem.FileSystem, Path.Path, ServerConfig.ServerConfig],
55})64})
56 .annotate(Tool.Title, "Open device")65 .annotate(Tool.Title, "Open device")
...@@ -64,7 +73,7 @@ export const DeviceScreenshotTool = Tool.make("device_screenshot", {...@@ -64,7 +73,7 @@ export const DeviceScreenshotTool = Tool.make("device_screenshot", {
64 "Capture the current screen of an open device as a PNG image. Use it to see what the user sees; for taps and text use the agent-device CLI.",73 "Capture the current screen of an open device as a PNG image. Use it to see what the user sees; for taps and text use the agent-device CLI.",
65 parameters: DeviceToolTargetInput,74 parameters: DeviceToolTargetInput,
66 success: DeviceToolScreenshotResult,75 success: DeviceToolScreenshotResult,
67 failure: DeviceToolError,76 failure: DeviceToolFailure,
68 dependencies,77 dependencies,
69})78})
70 .annotate(Tool.Title, "Screenshot device")79 .annotate(Tool.Title, "Screenshot device")
...@@ -80,7 +89,7 @@ const DeviceCloseTool = Tool.make("device_close", {...@@ -80,7 +89,7 @@ const DeviceCloseTool = Tool.make("device_close", {
80 success: Schema.Record(Schema.String, Schema.Never).annotate({89 success: Schema.Record(Schema.String, Schema.Never).annotate({
81 description: "The device was closed.",90 description: "The device was closed.",
82 }),91 }),
83 failure: DeviceToolError,92 failure: DeviceToolFailure,
84 dependencies,93 dependencies,
85})94})
86 .annotate(Tool.Title, "Close device")95 .annotate(Tool.Title, "Close device")
apps/server/src/mcp/toolkits/environment/handlers.test.ts created+100
...@@ -0,0 +1,100 @@
1import { expect, it } from "@effect/vitest";
2import {
3 DEFAULT_SERVER_SETTINGS,
4 EnvironmentId,
5 ProviderInstanceId,
6 ThreadId,
7 type OrchestrationV2ThreadShell,
8} from "@t3tools/contracts";
9import * as Deferred from "effect/Deferred";
10import * as Effect from "effect/Effect";
11import * as Fiber from "effect/Fiber";
12import * as Layer from "effect/Layer";
13import * as Ref from "effect/Ref";
14import * as Stream from "effect/Stream";
15
16import * as Environment from "../../../environment/ServerEnvironment.ts";
17import * as ThreadCommandExecutor from "../../../orchestration-v2/ThreadCommandExecutor.ts";
18import * as ThreadManagement from "../../../orchestration-v2/ThreadManagementService.ts";
19import * as Settings from "../../../serverSettings.ts";
20import * as McpInvocationContext from "../../McpInvocationContext.ts";
21import * as McpToolAccess from "../../McpToolAccess.ts";
22import { liveThreadShell } from "../../McpToolAccess.testkit.ts";
23import * as EnvironmentHandlers from "./handlers.ts";
24import { EnvironmentToolkit } from "./tools.ts";
25
26const environmentId = EnvironmentId.make("environment:preferences");
27const threadId = ThreadId.make("thread:preferences");
28
29it.effect("refuses a preferences update when the caller's turn ends while it waits", () =>
30 Effect.gen(function* () {
31 const caller = yield* Ref.make<OrchestrationV2ThreadShell>(liveThreadShell(threadId));
32 const updates = yield* Ref.make(0);
33 // Completes once the declaration's own check has read the caller.
34 const checked = yield* Deferred.make<void>();
35 const layerDependencies = Layer.mergeAll(
36 ThreadCommandExecutor.layer,
37 Layer.succeed(McpInvocationContext.McpInvocationContext, {
38 environmentId,
39 requestNamespace: "provider:preferences",
40 thread: {
41 threadId,
42 providerSessionId: "provider:preferences",
43 providerInstanceId: ProviderInstanceId.make("codex"),
44 },
45 client: undefined,
46 capabilities: new Set(["orchestration" as const]),
47 issuedAt: 0,
48 }),
49 Layer.mock(ThreadManagement.ThreadManagementService)({
50 getThreadShell: () =>
51 Ref.get(caller).pipe(Effect.tap(() => Deferred.succeed(checked, undefined))),
52 }),
53 Layer.mock(Environment.ServerEnvironment)({
54 getDescriptor: Effect.succeed({
55 environmentId,
56 label: "Test",
57 platform: { os: "linux", arch: "x64" },
58 serverVersion: "0.0.0",
59 capabilities: { repositoryIdentity: false },
60 }),
61 }),
62 Layer.mock(Settings.ServerSettingsService)({
63 getSettings: Effect.succeed(DEFAULT_SERVER_SETTINGS),
64 updateSettings: () =>
65 Ref.update(updates, (count) => count + 1).pipe(Effect.as(DEFAULT_SERVER_SETTINGS)),
66 }),
67 );
68 yield* Effect.gen(function* () {
69 const toolkit = yield* EnvironmentToolkit;
70 const executor = yield* ThreadCommandExecutor.ThreadCommandExecutor;
71 const held = yield* Deferred.make<void>();
72 const release = yield* Deferred.make<void>();
73 // A turn-completion command holds the thread's lock.
74 const holder = yield* executor
75 .withLock(
76 threadId,
77 Deferred.succeed(held, undefined).pipe(Effect.andThen(Deferred.await(release))),
78 )
79 .pipe(Effect.forkChild);
80 yield* Deferred.await(held);
81 const update = yield* toolkit
82 .handle("t3_environment_preferences_update", { newWorktreesStartFromOrigin: true })
83 .pipe(Stream.unwrap, Stream.runCollect, Effect.forkChild);
84 // The update passed its first check and waits for the lock; the turn then ends.
85 yield* Deferred.await(checked);
86 yield* Ref.update(caller, (shell) => ({ ...shell, activeRunId: null }));
87 yield* Deferred.succeed(release, undefined);
88 yield* Fiber.join(holder);
89 const result = yield* Fiber.join(update);
90 expect(result.at(-1)?.result).toMatchObject({ code: "parent_not_active" });
91 expect(yield* Ref.get(updates)).toBe(0);
92 }).pipe(
93 Effect.provide(
94 McpToolAccess.HandlersLayer.layer(EnvironmentHandlers.layer).pipe(
95 Layer.provideMerge(layerDependencies),
96 ),
97 ),
98 );
99 }),
100);
apps/server/src/mcp/toolkits/environment/handlers.ts+22-22
...@@ -4,7 +4,8 @@ import * as Environment from "../../../environment/ServerEnvironment.ts";...@@ -4,7 +4,8 @@ import * as Environment from "../../../environment/ServerEnvironment.ts";
4import * as ThreadCommandExecutor from "../../../orchestration-v2/ThreadCommandExecutor.ts";4import * as ThreadCommandExecutor from "../../../orchestration-v2/ThreadCommandExecutor.ts";
5import * as Settings from "../../../serverSettings.ts";5import * as Settings from "../../../serverSettings.ts";
6import * as McpInvocationContext from "../../McpInvocationContext.ts";6import * as McpInvocationContext from "../../McpInvocationContext.ts";
7import { readCaller, readFullAccessCaller, unavailable } from "../../threadAccess.ts";7import * as McpToolAccess from "../../McpToolAccess.ts";
8import { readCaller, unavailable } from "../../threadAccess.ts";
8import { EnvironmentToolkit } from "./tools.ts";9import { EnvironmentToolkit } from "./tools.ts";
910
10export function preferences(settings: ServerSettings) {11export function preferences(settings: ServerSettings) {
...@@ -28,26 +29,21 @@ export function preferences(settings: ServerSettings) {...@@ -28,26 +29,21 @@ export function preferences(settings: ServerSettings) {
28 },29 },
29 };30 };
30}31}
31const access = (writable = false) =>32const access = Effect.gen(function* () {
32 Effect.gen(function* () {33 const context = yield* readCaller();
33 const context = yield* writable34 const environment = yield* Environment.ServerEnvironment;
34 ? readFullAccessCaller(35 const descriptor = yield* environment.getDescriptor;
35 "Preference updates require a live full-access/default thread or a full-access client.",36 if (descriptor.environmentId !== context.scope.environmentId)
36 )37 return yield* new OrchestratorMcpFailure({
37 : readCaller();38 code: "capability_denied",
38 const environment = yield* Environment.ServerEnvironment;39 message: "This credential belongs to another environment.",
39 const descriptor = yield* environment.getDescriptor;40 });
40 if (descriptor.environmentId !== context.scope.environmentId)41 return { ...context, descriptor, settings: yield* Settings.ServerSettingsService };
41 return yield* new OrchestratorMcpFailure({42});
42 code: "capability_denied",43export const layer = McpToolAccess.toLayer(EnvironmentToolkit, {
43 message: "This credential belongs to another environment.",44 t3_environment_read: McpToolAccess.reads(() =>
44 });
45 return { ...context, descriptor, settings: yield* Settings.ServerSettingsService };
46 });
47export const layer = EnvironmentToolkit.toLayer({
48 t3_environment_read: () =>
49 Effect.gen(function* () {45 Effect.gen(function* () {
50 const { descriptor, settings } = yield* access();46 const { descriptor, settings } = yield* access;
51 const current = yield* settings.getSettings.pipe(Effect.mapError(unavailable));47 const current = yield* settings.getSettings.pipe(Effect.mapError(unavailable));
52 return {48 return {
53 environmentId: descriptor.environmentId,49 environmentId: descriptor.environmentId,
...@@ -57,12 +53,15 @@ export const layer = EnvironmentToolkit.toLayer({...@@ -57,12 +53,15 @@ export const layer = EnvironmentToolkit.toLayer({
57 preferences: preferences(current),53 preferences: preferences(current),
58 };54 };
59 }),55 }),
60 t3_environment_preferences_update: (patch) =>56 ),
57 t3_environment_preferences_update: McpToolAccess.writesEnvironment((patch, check) =>
61 Effect.gen(function* () {58 Effect.gen(function* () {
62 const scope = yield* McpInvocationContext.McpInvocationContext;59 const scope = yield* McpInvocationContext.McpInvocationContext;
63 const executor = yield* ThreadCommandExecutor.ThreadCommandExecutor;60 const executor = yield* ThreadCommandExecutor.ThreadCommandExecutor;
64 const update = Effect.gen(function* () {61 const update = Effect.gen(function* () {
65 const { settings } = yield* access(true);62 // The turn may have ended, or the thread's modes changed, while this waited for the lock.
63 yield* check;
64 const { settings } = yield* access;
66 return preferences(65 return preferences(
67 yield* settings.updateSettings(patch).pipe(Effect.mapError(unavailable)),66 yield* settings.updateSettings(patch).pipe(Effect.mapError(unavailable)),
68 );67 );
...@@ -72,4 +71,5 @@ export const layer = EnvironmentToolkit.toLayer({...@@ -72,4 +71,5 @@ export const layer = EnvironmentToolkit.toLayer({
72 ? update71 ? update
73 : executor.withLock(scope.thread.threadId, update);72 : executor.withLock(scope.thread.threadId, update);
74 }),73 }),
74 ),
75});75});
apps/server/src/mcp/toolkits/html/handlers.ts+12-15
...@@ -3,7 +3,7 @@ import * as Effect from "effect/Effect";...@@ -3,7 +3,7 @@ import * as Effect from "effect/Effect";
33
4import * as HtmlRender from "../../../htmlRender/HtmlRender.ts";4import * as HtmlRender from "../../../htmlRender/HtmlRender.ts";
5import * as McpInvocationContext from "../../McpInvocationContext.ts";5import * as McpInvocationContext from "../../McpInvocationContext.ts";
6import { readMutationCaller } from "../../threadAccess.ts";6import * as McpToolAccess from "../../McpToolAccess.ts";
7import { HtmlPreviewToolkit, HtmlRenderToolkit, type HtmlToolkit } from "./tools.ts";7import { HtmlPreviewToolkit, HtmlRenderToolkit, type HtmlToolkit } from "./tools.ts";
88
9const INVALID_PAGE_ERRORS = new Set([9const INVALID_PAGE_ERRORS = new Set([
...@@ -20,14 +20,10 @@ const toFailure = (error: { readonly _tag: string; readonly message: string }) =...@@ -20,14 +20,10 @@ const toFailure = (error: { readonly _tag: string; readonly message: string }) =
20 });20 });
2121
22const handlers = {22const handlers = {
23 html_preview: (input) =>23 // The headless browser runs on the host and can open local files, so only
24 // agents T3 launched, which already work on this machine, get it.
25 html_preview: McpToolAccess.readsAsCaller((input) =>
24 Effect.gen(function* () {26 Effect.gen(function* () {
25 // The headless browser runs on the host and can open local files, so
26 // only agents T3 launched, which already work on this machine, get it.
27 yield* McpInvocationContext.requireThreadScope(
28 yield* McpInvocationContext.McpInvocationContext,
29 "html_preview",
30 );
31 const htmlRender = yield* HtmlRender.HtmlRender;27 const htmlRender = yield* HtmlRender.HtmlRender;
32 const { png, ...preview } = yield* htmlRender.preview(input).pipe(Effect.mapError(toFailure));28 const { png, ...preview } = yield* htmlRender.preview(input).pipe(Effect.mapError(toFailure));
33 return {29 return {
...@@ -40,11 +36,11 @@ const handlers = {...@@ -40,11 +36,11 @@ const handlers = {
40 },36 },
41 };37 };
42 }),38 }),
43 html_render: (input) =>39 ),
40 // The page is stored in the calling thread, so it needs that thread's live run.
41 html_render: McpToolAccess.actsAsCaller((input) =>
44 Effect.gen(function* () {42 Effect.gen(function* () {
45 // The page is stored in the calling thread, so it needs that thread's43 const scope = yield* McpInvocationContext.McpInvocationContext;
46 // live run, like any other write.
47 const { scope } = yield* readMutationCaller();
48 const { thread } = yield* McpInvocationContext.requireThreadScope(scope, "html_render");44 const { thread } = yield* McpInvocationContext.requireThreadScope(scope, "html_render");
49 const htmlRender = yield* HtmlRender.HtmlRender;45 const htmlRender = yield* HtmlRender.HtmlRender;
50 const reference = yield* htmlRender46 const reference = yield* htmlRender
...@@ -56,12 +52,13 @@ const handlers = {...@@ -56,12 +52,13 @@ const handlers = {
56 "Shown to the reader above your reply. Don't mention or describe the page; reply with only what it doesn't already say.",52 "Shown to the reader above your reply. Don't mention or describe the page; reply with only what it doesn't already say.",
57 };53 };
58 }),54 }),
59} satisfies Parameters<typeof HtmlToolkit.toLayer>[0];55 ),
56} satisfies McpToolAccess.Handlers<typeof HtmlToolkit.tools>;
6057
61export const layerPreview = HtmlPreviewToolkit.toLayer({58export const layerPreview = McpToolAccess.toLayer(HtmlPreviewToolkit, {
62 html_preview: handlers.html_preview,59 html_preview: handlers.html_preview,
63});60});
6461
65export const layerRender = HtmlRenderToolkit.toLayer({62export const layerRender = McpToolAccess.toLayer(HtmlRenderToolkit, {
66 html_render: handlers.html_render,63 html_render: handlers.html_render,
67});64});
apps/server/src/mcp/toolkits/html/tools.ts+5-1
...@@ -56,7 +56,11 @@ export const HtmlPreviewTool = Tool.make("html_preview", {...@@ -56,7 +56,11 @@ export const HtmlPreviewTool = Tool.make("html_preview", {
56 }),56 }),
57 }),57 }),
58 failure: OrchestratorMcpFailure,58 failure: OrchestratorMcpFailure,
59 dependencies: [McpInvocationContext.McpInvocationContext, HtmlRender.HtmlRender],59 dependencies: [
60 McpInvocationContext.McpInvocationContext,
61 ThreadManagementService.ThreadManagementService,
62 HtmlRender.HtmlRender,
63 ],
60})64})
61 .annotate(Tool.Title, "Preview HTML")65 .annotate(Tool.Title, "Preview HTML")
62 .annotate(Tool.Readonly, true)66 .annotate(Tool.Readonly, true)
apps/server/src/mcp/toolkits/orchestrator/handlers.ts+67-38
...@@ -2,106 +2,135 @@ import { OrchestratorToolkit } from "./tools.ts";...@@ -2,106 +2,135 @@ import { OrchestratorToolkit } from "./tools.ts";
2import * as Effect from "effect/Effect";2import * as Effect from "effect/Effect";
33
4import * as McpInvocationContext from "../../McpInvocationContext.ts";4import * as McpInvocationContext from "../../McpInvocationContext.ts";
5import * as McpToolAccess from "../../McpToolAccess.ts";
5import * as OrchestratorMcpService from "../../OrchestratorMcpService.ts";6import * as OrchestratorMcpService from "../../OrchestratorMcpService.ts";
6import * as ThreadMetadataMcpService from "../../ThreadMetadataMcpService.ts";7import * as ThreadMetadataMcpService from "../../ThreadMetadataMcpService.ts";
78
8const handlers = {9const handlers = {
9 orchestrator_capabilities: () =>10 orchestrator_capabilities: McpToolAccess.reads(() =>
10 Effect.gen(function* () {11 Effect.gen(function* () {
11 const scope = yield* McpInvocationContext.McpInvocationContext;12 const scope = yield* McpInvocationContext.McpInvocationContext;
12 const service = yield* OrchestratorMcpService.OrchestratorMcpService;13 const service = yield* OrchestratorMcpService.OrchestratorMcpService;
13 return yield* service.capabilities(scope);14 return yield* service.capabilities(scope);
14 }),15 }),
15 delegate_task: (input) =>16 ),
17 delegate_task: McpToolAccess.actsAsCaller((input) =>
16 Effect.gen(function* () {18 Effect.gen(function* () {
17 const scope = yield* McpInvocationContext.McpInvocationContext;19 const scope = yield* McpInvocationContext.McpInvocationContext;
18 const service = yield* OrchestratorMcpService.OrchestratorMcpService;20 const service = yield* OrchestratorMcpService.OrchestratorMcpService;
19 return yield* service.delegateTask(scope, input);21 return yield* service.delegateTask(scope, input);
20 }),22 }),
21 task_status: ({ taskId }) =>23 ),
24 task_status: McpToolAccess.actsAsCaller(({ taskId }) =>
22 Effect.gen(function* () {25 Effect.gen(function* () {
23 const scope = yield* McpInvocationContext.McpInvocationContext;26 const scope = yield* McpInvocationContext.McpInvocationContext;
24 const service = yield* OrchestratorMcpService.OrchestratorMcpService;27 const service = yield* OrchestratorMcpService.OrchestratorMcpService;
25 return yield* service.taskStatus(scope, taskId);28 return yield* service.taskStatus(scope, taskId);
26 }),29 }),
27 task_cancel: (input) =>30 ),
31 task_cancel: McpToolAccess.actsAsCaller((input) =>
28 Effect.gen(function* () {32 Effect.gen(function* () {
29 const scope = yield* McpInvocationContext.McpInvocationContext;33 const scope = yield* McpInvocationContext.McpInvocationContext;
30 const service = yield* OrchestratorMcpService.OrchestratorMcpService;34 const service = yield* OrchestratorMcpService.OrchestratorMcpService;
31 return yield* service.cancelTask(scope, input);35 return yield* service.cancelTask(scope, input);
32 }),36 }),
33 schedule_task: (input) =>37 ),
34 Effect.gen(function* () {38 schedule_task: McpToolAccess.startsThreads(
35 const scope = yield* McpInvocationContext.McpInvocationContext;39 // A scheduled task runs with the caller's own modes.
36 const service = yield* OrchestratorMcpService.OrchestratorMcpService;40 () => ({}),
37 return yield* service.scheduleTask(scope, input);41 (input) =>
38 }),42 Effect.gen(function* () {
39 list_scheduled_tasks: (input) =>43 const scope = yield* McpInvocationContext.McpInvocationContext;
44 const service = yield* OrchestratorMcpService.OrchestratorMcpService;
45 return yield* service.scheduleTask(scope, input);
46 }),
47 ),
48 list_scheduled_tasks: McpToolAccess.reads((input) =>
40 Effect.gen(function* () {49 Effect.gen(function* () {
41 const scope = yield* McpInvocationContext.McpInvocationContext;50 const scope = yield* McpInvocationContext.McpInvocationContext;
42 const service = yield* OrchestratorMcpService.OrchestratorMcpService;51 const service = yield* OrchestratorMcpService.OrchestratorMcpService;
43 return yield* service.listScheduledTasks(scope, input);52 return yield* service.listScheduledTasks(scope, input);
44 }),53 }),
45 update_scheduled_task: (input) =>54 ),
55 update_scheduled_task: McpToolAccess.writes((input) =>
46 Effect.gen(function* () {56 Effect.gen(function* () {
47 const scope = yield* McpInvocationContext.McpInvocationContext;57 const scope = yield* McpInvocationContext.McpInvocationContext;
48 const service = yield* OrchestratorMcpService.OrchestratorMcpService;58 const service = yield* OrchestratorMcpService.OrchestratorMcpService;
49 return yield* service.updateScheduledTask(scope, input);59 return yield* service.updateScheduledTask(scope, input);
50 }),60 }),
51 delete_scheduled_task: (input) =>61 ),
62 delete_scheduled_task: McpToolAccess.writes((input) =>
52 Effect.gen(function* () {63 Effect.gen(function* () {
53 const scope = yield* McpInvocationContext.McpInvocationContext;64 const scope = yield* McpInvocationContext.McpInvocationContext;
54 const service = yield* OrchestratorMcpService.OrchestratorMcpService;65 const service = yield* OrchestratorMcpService.OrchestratorMcpService;
55 return yield* service.deleteScheduledTask(scope, input);66 return yield* service.deleteScheduledTask(scope, input);
56 }),67 }),
57 request_secret: (input) =>68 ),
69 request_secret: McpToolAccess.actsAsCaller((input) =>
58 Effect.gen(function* () {70 Effect.gen(function* () {
59 const scope = yield* McpInvocationContext.McpInvocationContext;71 const scope = yield* McpInvocationContext.McpInvocationContext;
60 const service = yield* OrchestratorMcpService.OrchestratorMcpService;72 const service = yield* OrchestratorMcpService.OrchestratorMcpService;
61 return yield* service.requestSecret(scope, input);73 return yield* service.requestSecret(scope, input);
62 }),74 }),
63 create_threads: (input) =>75 ),
76 create_threads: McpToolAccess.actsAsCaller((input) =>
64 Effect.gen(function* () {77 Effect.gen(function* () {
65 const scope = yield* McpInvocationContext.McpInvocationContext;78 const scope = yield* McpInvocationContext.McpInvocationContext;
66 const service = yield* OrchestratorMcpService.OrchestratorMcpService;79 const service = yield* OrchestratorMcpService.OrchestratorMcpService;
67 return yield* service.createThreads(scope, input);80 return yield* service.createThreads(scope, input);
68 }),81 }),
69 t3_thread_list: (input) =>82 ),
83 t3_thread_list: McpToolAccess.reads((input) =>
70 Effect.gen(function* () {84 Effect.gen(function* () {
71 const scope = yield* McpInvocationContext.McpInvocationContext;85 const scope = yield* McpInvocationContext.McpInvocationContext;
72 const service = yield* OrchestratorMcpService.OrchestratorMcpService;86 const service = yield* OrchestratorMcpService.OrchestratorMcpService;
73 return yield* service.listThreads(scope, input);87 return yield* service.listThreads(scope, input);
74 }),88 }),
75 t3_thread_read: (input) =>89 ),
90 // Reading a child's finished result also acknowledges its delivery to the
91 // reader's own thread. That is bookkeeping on the caller's own subagent, not
92 // a change to anything it reads, so this stays a read.
93 t3_thread_read: McpToolAccess.reads((input) =>
76 Effect.gen(function* () {94 Effect.gen(function* () {
77 const scope = yield* McpInvocationContext.McpInvocationContext;95 const scope = yield* McpInvocationContext.McpInvocationContext;
78 const service = yield* OrchestratorMcpService.OrchestratorMcpService;96 const service = yield* OrchestratorMcpService.OrchestratorMcpService;
79 return yield* service.readThread(scope, input);97 return yield* service.readThread(scope, input);
80 }),98 }),
81 t3_thread_update: (input) =>99 ),
82 Effect.gen(function* () {100 t3_thread_update: McpToolAccess.writesThreads(
83 const scope = yield* McpInvocationContext.McpInvocationContext;101 (input) => [input.threadId],
84 const service = yield* ThreadMetadataMcpService.ThreadMetadataMcpService;102 (input) =>
85 return yield* service.update(scope, input);103 Effect.gen(function* () {
86 }),104 const scope = yield* McpInvocationContext.McpInvocationContext;
87 t3_thread_send: (input) =>105 const service = yield* ThreadMetadataMcpService.ThreadMetadataMcpService;
88 Effect.gen(function* () {106 return yield* service.update(scope, input);
89 const scope = yield* McpInvocationContext.McpInvocationContext;107 }),
90 const service = yield* OrchestratorMcpService.OrchestratorMcpService;108 ),
91 return yield* service.sendToThread(scope, input);109 t3_thread_send: McpToolAccess.writesThreads(
92 }),110 (input) => [input.threadId],
93 t3_thread_wait: (input) =>111 (input) =>
112 Effect.gen(function* () {
113 const scope = yield* McpInvocationContext.McpInvocationContext;
114 const service = yield* OrchestratorMcpService.OrchestratorMcpService;
115 return yield* service.sendToThread(scope, input);
116 }),
117 ),
118 t3_thread_wait: McpToolAccess.reads((input) =>
94 Effect.gen(function* () {119 Effect.gen(function* () {
95 const scope = yield* McpInvocationContext.McpInvocationContext;120 const scope = yield* McpInvocationContext.McpInvocationContext;
96 const service = yield* OrchestratorMcpService.OrchestratorMcpService;121 const service = yield* OrchestratorMcpService.OrchestratorMcpService;
97 return yield* service.waitForThread(scope, input);122 return yield* service.waitForThread(scope, input);
98 }),123 }),
99 t3_thread_interrupt: (input) =>124 ),
100 Effect.gen(function* () {125 t3_thread_interrupt: McpToolAccess.writesThreads(
101 const scope = yield* McpInvocationContext.McpInvocationContext;126 (input) => [input.threadId],
102 const service = yield* OrchestratorMcpService.OrchestratorMcpService;127 (input) =>
103 return yield* service.interruptThread(scope, input);128 Effect.gen(function* () {
104 }),129 const scope = yield* McpInvocationContext.McpInvocationContext;
105} satisfies Parameters<typeof OrchestratorToolkit.toLayer>[0];130 const service = yield* OrchestratorMcpService.OrchestratorMcpService;
131 return yield* service.interruptThread(scope, input);
132 }),
133 ),
134} satisfies McpToolAccess.Handlers<typeof OrchestratorToolkit.tools>;
106135
107export const layer = OrchestratorToolkit.toLayer(handlers);136export const layer = McpToolAccess.toLayer(OrchestratorToolkit, handlers);
apps/server/src/mcp/toolkits/orchestrator/tools.ts+3
...@@ -32,16 +32,19 @@ import {...@@ -32,16 +32,19 @@ import {
32} from "@t3tools/contracts";32} from "@t3tools/contracts";
33import { Tool, Toolkit } from "effect/ai";33import { Tool, Toolkit } from "effect/ai";
3434
35import * as ThreadManagementService from "../../../orchestration-v2/ThreadManagementService.ts";
35import * as McpInvocationContext from "../../McpInvocationContext.ts";36import * as McpInvocationContext from "../../McpInvocationContext.ts";
36import * as OrchestratorMcpService from "../../OrchestratorMcpService.ts";37import * as OrchestratorMcpService from "../../OrchestratorMcpService.ts";
37import * as ThreadMetadataMcpService from "../../ThreadMetadataMcpService.ts";38import * as ThreadMetadataMcpService from "../../ThreadMetadataMcpService.ts";
3839
39const dependencies = [40const dependencies = [
40 McpInvocationContext.McpInvocationContext,41 McpInvocationContext.McpInvocationContext,
42 ThreadManagementService.ThreadManagementService,
41 OrchestratorMcpService.OrchestratorMcpService,43 OrchestratorMcpService.OrchestratorMcpService,
42];44];
43const threadMetadataDependencies = [45const threadMetadataDependencies = [
44 McpInvocationContext.McpInvocationContext,46 McpInvocationContext.McpInvocationContext,
47 ThreadManagementService.ThreadManagementService,
45 ThreadMetadataMcpService.ThreadMetadataMcpService,48 ThreadMetadataMcpService.ThreadMetadataMcpService,
46];49];
4750
apps/server/src/mcp/toolkits/preview/handlers.ts+48-23
...@@ -29,6 +29,7 @@ import {...@@ -29,6 +29,7 @@ import {
29import { resolveAttachmentRelativePath } from "../../../attachmentPaths.ts";29import { resolveAttachmentRelativePath } from "../../../attachmentPaths.ts";
30import * as ServerConfig from "../../../config.ts";30import * as ServerConfig from "../../../config.ts";
31import * as McpInvocationContext from "../../McpInvocationContext.ts";31import * as McpInvocationContext from "../../McpInvocationContext.ts";
32import * as McpToolAccess from "../../McpToolAccess.ts";
32import * as PreviewAutomationBroker from "../../PreviewAutomationBroker.ts";33import * as PreviewAutomationBroker from "../../PreviewAutomationBroker.ts";
33import { PreviewSnapshotToolkit, PreviewStandardToolkit, PreviewToolkit } from "./tools.ts";34import { PreviewSnapshotToolkit, PreviewStandardToolkit, PreviewToolkit } from "./tools.ts";
3435
...@@ -189,41 +190,64 @@ export const claimPreviewRecording = Effect.fn("PreviewToolkit.claimRecording")(...@@ -189,41 +190,64 @@ export const claimPreviewRecording = Effect.fn("PreviewToolkit.claimRecording")(
189});190});
190191
191const handlers = {192const handlers = {
192 preview_dialog: (input) => invokeTargeted<PreviewAutomationStatus>("dialog", input),193 preview_dialog: McpToolAccess.actsAsCaller((input) =>
193 preview_status: (input) => invokeTargeted<PreviewAutomationStatus>("status", input ?? {}),194 invokeTargeted<PreviewAutomationStatus>("dialog", input),
194 preview_open: (input) =>195 ),
196 preview_status: McpToolAccess.readsAsCaller((input) =>
197 invokeTargeted<PreviewAutomationStatus>("status", input ?? {}),
198 ),
199 preview_open: McpToolAccess.actsAsCaller((input) =>
195 invokeTargeted<PreviewAutomationStatus>("open", normalizePreviewOpenInput(input)),200 invokeTargeted<PreviewAutomationStatus>("open", normalizePreviewOpenInput(input)),
196 preview_navigate: (input) =>201 ),
202 preview_navigate: McpToolAccess.actsAsCaller((input) =>
197 invokeTargeted<PreviewAutomationStatus>("navigate", input, input.timeoutMs),203 invokeTargeted<PreviewAutomationStatus>("navigate", input, input.timeoutMs),
198 preview_resize: (input) =>204 ),
205 preview_resize: McpToolAccess.actsAsCaller((input) =>
199 invokeTargeted<PreviewAutomationResizeResult>("resize", input, input.timeoutMs),206 invokeTargeted<PreviewAutomationResizeResult>("resize", input, input.timeoutMs),
200 preview_set_appearance: (input) =>207 ),
208 preview_set_appearance: McpToolAccess.actsAsCaller((input) =>
201 invokeTargeted<PreviewAutomationSetColorSchemeResult>("setColorScheme", input),209 invokeTargeted<PreviewAutomationSetColorSchemeResult>("setColorScheme", input),
202 preview_snapshot: (input) => {210 ),
211 preview_snapshot: McpToolAccess.readsAsCaller((input) => {
203 // Output selection and saving are MCP-only; the browser still produces a complete snapshot.212 // Output selection and saving are MCP-only; the browser still produces a complete snapshot.
204 const { includeImage: _includeImage, save: _save, ...operationInput } = input ?? {};213 const { includeImage: _includeImage, save: _save, ...operationInput } = input ?? {};
205 return invokeTargeted<PreviewAutomationSnapshot>("snapshot", operationInput);214 return invokeTargeted<PreviewAutomationSnapshot>("snapshot", operationInput);
206 },215 }),
207 preview_click: (input) => invokeTargeted<object>("click", input, input.timeoutMs),216 preview_click: McpToolAccess.actsAsCaller((input) =>
208 preview_type: (input) => invokeTargeted<object>("type", input, input.timeoutMs),217 invokeTargeted<object>("click", input, input.timeoutMs),
209 preview_hover: (input) => invokeTargeted<object>("hover", input, input.timeoutMs),218 ),
210 preview_select: (input) =>219 preview_type: McpToolAccess.actsAsCaller((input) =>
220 invokeTargeted<object>("type", input, input.timeoutMs),
221 ),
222 preview_hover: McpToolAccess.actsAsCaller((input) =>
223 invokeTargeted<object>("hover", input, input.timeoutMs),
224 ),
225 preview_select: McpToolAccess.actsAsCaller((input) =>
211 invokeTargeted<PreviewAutomationSelectResult>("select", input, input.timeoutMs),226 invokeTargeted<PreviewAutomationSelectResult>("select", input, input.timeoutMs),
212 preview_drag: (input) => invokeTargeted<object>("drag", input, input.timeoutMs),227 ),
213 preview_upload: (input) => invokeTargeted<object>("upload", input, input.timeoutMs),228 preview_drag: McpToolAccess.actsAsCaller((input) =>
214 preview_press: (input) => invokeTargeted<object>("press", input),229 invokeTargeted<object>("drag", input, input.timeoutMs),
215 preview_scroll: (input) => invokeTargeted<object>("scroll", input),230 ),
216 preview_evaluate: ({ tabId, ...input }) =>231 preview_upload: McpToolAccess.actsAsCaller((input) =>
232 invokeTargeted<object>("upload", input, input.timeoutMs),
233 ),
234 preview_press: McpToolAccess.actsAsCaller((input) => invokeTargeted<object>("press", input)),
235 preview_scroll: McpToolAccess.actsAsCaller((input) => invokeTargeted<object>("scroll", input)),
236 preview_evaluate: McpToolAccess.actsAsCaller(({ tabId, ...input }) =>
217 invoke<unknown>("evaluate", input, undefined, tabId).pipe(237 invoke<unknown>("evaluate", input, undefined, tabId).pipe(
218 Effect.map(({ result, toolIcon }) => ({238 Effect.map(({ result, toolIcon }) => ({
219 value: result ?? null,239 value: result ?? null,
220 ...(toolIcon ? { toolIcon } : {}),240 ...(toolIcon ? { toolIcon } : {}),
221 })),241 })),
222 ),242 ),
223 preview_wait_for: (input) => invokeTargeted<object>("waitFor", input, input.timeoutMs),243 ),
224 preview_recording_start: (input) =>244 preview_wait_for: McpToolAccess.readsAsCaller((input) =>
245 invokeTargeted<object>("waitFor", input, input.timeoutMs),
246 ),
247 preview_recording_start: McpToolAccess.actsAsCaller((input) =>
225 invokeTargeted<PreviewAutomationRecordingStatus>("recordingStart", input ?? {}),248 invokeTargeted<PreviewAutomationRecordingStatus>("recordingStart", input ?? {}),
226 preview_recording_stop: (input) =>249 ),
250 preview_recording_stop: McpToolAccess.actsAsCaller((input) =>
227 Effect.gen(function* () {251 Effect.gen(function* () {
228 const scope = yield* McpInvocationContext.requireThreadMcpCapability("preview");252 const scope = yield* McpInvocationContext.requireThreadMcpCapability("preview");
229 const { tabId, ...operationInput } = input;253 const { tabId, ...operationInput } = input;
...@@ -236,12 +260,13 @@ const handlers = {...@@ -236,12 +260,13 @@ const handlers = {
236 const artifact = yield* claimPreviewRecording(scope.thread.threadId, response.result);260 const artifact = yield* claimPreviewRecording(scope.thread.threadId, response.result);
237 return { ...artifact, ...(response.toolIcon ? { toolIcon: response.toolIcon } : {}) };261 return { ...artifact, ...(response.toolIcon ? { toolIcon: response.toolIcon } : {}) };
238 }),262 }),
239} satisfies Parameters<typeof PreviewToolkit.toLayer>[0];263 ),
264} satisfies McpToolAccess.Handlers<typeof PreviewToolkit.tools>;
240265
241const { preview_snapshot, ...standardHandlers } = handlers;266const { preview_snapshot, ...standardHandlers } = handlers;
242267
243export const layerStandard = PreviewStandardToolkit.toLayer(standardHandlers);268export const layerStandard = McpToolAccess.toLayer(PreviewStandardToolkit, standardHandlers);
244269
245export const layerSnapshot = PreviewSnapshotToolkit.toLayer({270export const layerSnapshot = McpToolAccess.toLayer(PreviewSnapshotToolkit, {
246 preview_snapshot,271 preview_snapshot,
247});272});
apps/server/src/mcp/toolkits/preview/tools.ts+25-19
...@@ -1,4 +1,5 @@...@@ -1,4 +1,5 @@
1import {1import {
2 OrchestratorMcpFailure,
2 ToolActivityIcon,3 ToolActivityIcon,
3 PreviewAutomationClickInput,4 PreviewAutomationClickInput,
4 PreviewAutomationDialogInput,5 PreviewAutomationDialogInput,
...@@ -32,12 +33,17 @@ import { Tool, Toolkit } from "effect/ai";...@@ -32,12 +33,17 @@ import { Tool, Toolkit } from "effect/ai";
32import * as McpInvocationContext from "../../McpInvocationContext.ts";33import * as McpInvocationContext from "../../McpInvocationContext.ts";
33import * as PreviewAutomationBroker from "../../PreviewAutomationBroker.ts";34import * as PreviewAutomationBroker from "../../PreviewAutomationBroker.ts";
34import * as ServerConfig from "../../../config.ts";35import * as ServerConfig from "../../../config.ts";
36import * as ThreadManagementService from "../../../orchestration-v2/ThreadManagementService.ts";
3537
36const dependencies = [38const dependencies = [
37 McpInvocationContext.McpInvocationContext,39 McpInvocationContext.McpInvocationContext,
40 ThreadManagementService.ThreadManagementService,
38 PreviewAutomationBroker.PreviewAutomationBroker,41 PreviewAutomationBroker.PreviewAutomationBroker,
39];42];
4043
44/** What a browser action fails with, including the access gate's refusal. */
45const PreviewToolFailure = Schema.Union([PreviewAutomationError, OrchestratorMcpFailure]);
46
41const presentationFields = { toolIcon: Schema.optional(ToolActivityIcon) };47const presentationFields = { toolIcon: Schema.optional(ToolActivityIcon) };
4248
43const PreviewActionResult = Schema.Struct(presentationFields).annotate({49const PreviewActionResult = Schema.Struct(presentationFields).annotate({
...@@ -61,7 +67,7 @@ const PreviewStatusTool = Tool.make("preview_status", {...@@ -61,7 +67,7 @@ const PreviewStatusTool = Tool.make("preview_status", {
61 "Report whether a collaborative browser tab is automation-capable, including its control owner, pending dialog, URL, title, visibility, loading state, viewport mode, and measured CSS-pixel size. Pass tabId to inspect a specific tab; omit it to use this agent session's current tab.",67 "Report whether a collaborative browser tab is automation-capable, including its control owner, pending dialog, URL, title, visibility, loading state, viewport mode, and measured CSS-pixel size. Pass tabId to inspect a specific tab; omit it to use this agent session's current tab.",
62 parameters: PreviewAutomationTabTargetInput,68 parameters: PreviewAutomationTabTargetInput,
63 success: PreviewAutomationStatus,69 success: PreviewAutomationStatus,
64 failure: PreviewAutomationError,70 failure: PreviewToolFailure,
65 dependencies,71 dependencies,
66})72})
67 .annotate(Tool.Title, "Get preview status")73 .annotate(Tool.Title, "Get preview status")
...@@ -75,7 +81,7 @@ const PreviewOpenTool = browserTool(...@@ -75,7 +81,7 @@ const PreviewOpenTool = browserTool(
75 "Initialize a collaborative browser tab and open its thread-bound inline preview by default. Set open=false for background-only automation. Pass tabId to reuse a specific existing tab, set reuseExistingTab=false to create another tab, or omit both to use this agent session's current tab. Parallel subagents sharing a provider session must each open with reuseExistingTab=false and pass their returned tabId on every call. Server tabs use isolated storage and cannot be operated by a different agent session.",81 "Initialize a collaborative browser tab and open its thread-bound inline preview by default. Set open=false for background-only automation. Pass tabId to reuse a specific existing tab, set reuseExistingTab=false to create another tab, or omit both to use this agent session's current tab. Parallel subagents sharing a provider session must each open with reuseExistingTab=false and pass their returned tabId on every call. Server tabs use isolated storage and cannot be operated by a different agent session.",
76 parameters: PreviewAutomationOpenInput,82 parameters: PreviewAutomationOpenInput,
77 success: PreviewAutomationStatus,83 success: PreviewAutomationStatus,
78 failure: PreviewAutomationError,84 failure: PreviewToolFailure,
79 dependencies,85 dependencies,
80 })86 })
81 .annotate(Tool.Title, "Open browser preview")87 .annotate(Tool.Title, "Open browser preview")
...@@ -88,7 +94,7 @@ const PreviewDialogTool = browserTool(...@@ -88,7 +94,7 @@ const PreviewDialogTool = browserTool(
88 "Accept or dismiss the server browser dialog reported by preview_status. For a prompt, supply promptText when accepting. Requires this agent to own the tab. Desktop hosts may not support this operation.",94 "Accept or dismiss the server browser dialog reported by preview_status. For a prompt, supply promptText when accepting. Requires this agent to own the tab. Desktop hosts may not support this operation.",
89 parameters: PreviewAutomationDialogInput,95 parameters: PreviewAutomationDialogInput,
90 success: PreviewAutomationStatus,96 success: PreviewAutomationStatus,
91 failure: PreviewAutomationError,97 failure: PreviewToolFailure,
92 dependencies,98 dependencies,
93 }).annotate(Tool.Title, "Resolve browser dialog"),99 }).annotate(Tool.Title, "Resolve browser dialog"),
94);100);
...@@ -99,7 +105,7 @@ const PreviewNavigateTool = safeBrowserTool(...@@ -99,7 +105,7 @@ const PreviewNavigateTool = safeBrowserTool(
99 "Navigate a collaborative browser tab. Pass tabId to target a specific tab, plus {url:'https://t3.chat'} for a website or {target:{kind:'environment-port',port:5173}} for a dev server. Exactly one of url or target is required.",105 "Navigate a collaborative browser tab. Pass tabId to target a specific tab, plus {url:'https://t3.chat'} for a website or {target:{kind:'environment-port',port:5173}} for a dev server. Exactly one of url or target is required.",
100 parameters: PreviewAutomationNavigateInput,106 parameters: PreviewAutomationNavigateInput,
101 success: PreviewAutomationStatus,107 success: PreviewAutomationStatus,
102 failure: PreviewAutomationError,108 failure: PreviewToolFailure,
103 dependencies,109 dependencies,
104 }).annotate(Tool.Title, "Navigate browser preview"),110 }).annotate(Tool.Title, "Navigate browser preview"),
105);111);
...@@ -110,7 +116,7 @@ const PreviewResizeTool = safeBrowserTool(...@@ -110,7 +116,7 @@ const PreviewResizeTool = safeBrowserTool(
110 "Resize a collaborative browser tab, optionally selected by tabId. Use {mode:'fill'}, {mode:'freeform',width:1024,height:768}, or {mode:'preset',preset:'iphone-12-pro',orientation:'portrait'}. This changes CSS layout breakpoints without changing the desktop browser user agent.",116 "Resize a collaborative browser tab, optionally selected by tabId. Use {mode:'fill'}, {mode:'freeform',width:1024,height:768}, or {mode:'preset',preset:'iphone-12-pro',orientation:'portrait'}. This changes CSS layout breakpoints without changing the desktop browser user agent.",
111 parameters: PreviewAutomationResizeInput,117 parameters: PreviewAutomationResizeInput,
112 success: Schema.Struct({ ...PreviewAutomationResizeResult.fields, ...presentationFields }),118 success: Schema.Struct({ ...PreviewAutomationResizeResult.fields, ...presentationFields }),
113 failure: PreviewAutomationError,119 failure: PreviewToolFailure,
114 dependencies,120 dependencies,
115 })121 })
116 .annotate(Tool.Title, "Resize browser viewport")122 .annotate(Tool.Title, "Resize browser viewport")
...@@ -126,7 +132,7 @@ const PreviewSetAppearanceTool = safeBrowserTool(...@@ -126,7 +132,7 @@ const PreviewSetAppearanceTool = safeBrowserTool(
126 ...PreviewAutomationSetColorSchemeResult.fields,132 ...PreviewAutomationSetColorSchemeResult.fields,
127 ...presentationFields,133 ...presentationFields,
128 }),134 }),
129 failure: PreviewAutomationError,135 failure: PreviewToolFailure,
130 dependencies,136 dependencies,
131 })137 })
132 .annotate(Tool.Title, "Set preview appearance")138 .annotate(Tool.Title, "Set preview appearance")
...@@ -153,7 +159,7 @@ export const PreviewSnapshotTool = readonlyBrowserTool(...@@ -153,7 +159,7 @@ export const PreviewSnapshotTool = readonlyBrowserTool(
153 ),159 ),
154 }),160 }),
155 success: PreviewAutomationSnapshot,161 success: PreviewAutomationSnapshot,
156 failure: PreviewAutomationError,162 failure: PreviewToolFailure,
157 dependencies,163 dependencies,
158 }).annotate(Tool.Title, "Inspect browser page"),164 }).annotate(Tool.Title, "Inspect browser page"),
159);165);
...@@ -164,7 +170,7 @@ const PreviewClickTool = browserTool(...@@ -164,7 +170,7 @@ const PreviewClickTool = browserTool(
164 "Click exactly one target in the tab selected by tabId, or this agent session's current tab when omitted. Prefer a Playwright locator; selector accepts legacy CSS; x and y must be supplied together. Set button=right for a context menu or clickCount=2 for a double-click; server browser tabs only.",170 "Click exactly one target in the tab selected by tabId, or this agent session's current tab when omitted. Prefer a Playwright locator; selector accepts legacy CSS; x and y must be supplied together. Set button=right for a context menu or clickCount=2 for a double-click; server browser tabs only.",
165 parameters: PreviewAutomationClickInput,171 parameters: PreviewAutomationClickInput,
166 success: PreviewActionResult,172 success: PreviewActionResult,
167 failure: PreviewAutomationError,173 failure: PreviewToolFailure,
168 dependencies,174 dependencies,
169 }).annotate(Tool.Title, "Click preview page"),175 }).annotate(Tool.Title, "Click preview page"),
170);176);
...@@ -175,7 +181,7 @@ const PreviewTypeTool = browserTool(...@@ -175,7 +181,7 @@ const PreviewTypeTool = browserTool(
175 "Insert literal text into one input in the tab selected by tabId, or this agent session's current tab when omitted. Prefer a Playwright locator; set clear=true to replace existing text.",181 "Insert literal text into one input in the tab selected by tabId, or this agent session's current tab when omitted. Prefer a Playwright locator; set clear=true to replace existing text.",
176 parameters: PreviewAutomationTypeInput,182 parameters: PreviewAutomationTypeInput,
177 success: PreviewActionResult,183 success: PreviewActionResult,
178 failure: PreviewAutomationError,184 failure: PreviewToolFailure,
179 dependencies,185 dependencies,
180 }).annotate(Tool.Title, "Type into preview page"),186 }).annotate(Tool.Title, "Type into preview page"),
181);187);
...@@ -186,7 +192,7 @@ const PreviewHoverTool = safeBrowserTool(...@@ -186,7 +192,7 @@ const PreviewHoverTool = safeBrowserTool(
186 "Move the mouse over exactly one target in the tab selected by tabId, or this agent session's current tab when omitted, to reveal hover menus and tooltips. Server browser tabs only.",192 "Move the mouse over exactly one target in the tab selected by tabId, or this agent session's current tab when omitted, to reveal hover menus and tooltips. Server browser tabs only.",
187 parameters: PreviewAutomationHoverInput,193 parameters: PreviewAutomationHoverInput,
188 success: PreviewActionResult,194 success: PreviewActionResult,
189 failure: PreviewAutomationError,195 failure: PreviewToolFailure,
190 dependencies,196 dependencies,
191 }).annotate(Tool.Title, "Hover preview page"),197 }).annotate(Tool.Title, "Hover preview page"),
192);198);
...@@ -200,7 +206,7 @@ const PreviewSelectTool = browserTool(...@@ -200,7 +206,7 @@ const PreviewSelectTool = browserTool(
200 ...PreviewAutomationSelectResult.fields,206 ...PreviewAutomationSelectResult.fields,
201 ...presentationFields,207 ...presentationFields,
202 }),208 }),
203 failure: PreviewAutomationError,209 failure: PreviewToolFailure,
204 dependencies,210 dependencies,
205 }).annotate(Tool.Title, "Select preview option"),211 }).annotate(Tool.Title, "Select preview option"),
206);212);
...@@ -211,7 +217,7 @@ const PreviewDragTool = browserTool(...@@ -211,7 +217,7 @@ const PreviewDragTool = browserTool(
211 "Drag one element onto another in the tab selected by tabId, or this agent session's current tab when omitted. Server browser tabs only.",217 "Drag one element onto another in the tab selected by tabId, or this agent session's current tab when omitted. Server browser tabs only.",
212 parameters: PreviewAutomationDragInput,218 parameters: PreviewAutomationDragInput,
213 success: PreviewActionResult,219 success: PreviewActionResult,
214 failure: PreviewAutomationError,220 failure: PreviewToolFailure,
215 dependencies,221 dependencies,
216 }).annotate(Tool.Title, "Drag in preview page"),222 }).annotate(Tool.Title, "Drag in preview page"),
217);223);
...@@ -222,7 +228,7 @@ const PreviewUploadTool = browserTool(...@@ -222,7 +228,7 @@ const PreviewUploadTool = browserTool(
222 "Give files on the environment to the page in the tab selected by tabId, or this agent session's current tab when omitted. After clicking an upload control, preview_status reports the open fileChooser; call this with absolute paths to answer it, or with an empty list to cancel. Pass a locator for an <input type=file> to set its files without a picker. Server browser tabs only.",228 "Give files on the environment to the page in the tab selected by tabId, or this agent session's current tab when omitted. After clicking an upload control, preview_status reports the open fileChooser; call this with absolute paths to answer it, or with an empty list to cancel. Pass a locator for an <input type=file> to set its files without a picker. Server browser tabs only.",
223 parameters: PreviewAutomationUploadInput,229 parameters: PreviewAutomationUploadInput,
224 success: PreviewActionResult,230 success: PreviewActionResult,
225 failure: PreviewAutomationError,231 failure: PreviewToolFailure,
226 dependencies,232 dependencies,
227 }).annotate(Tool.Title, "Upload files to preview page"),233 }).annotate(Tool.Title, "Upload files to preview page"),
228);234);
...@@ -233,7 +239,7 @@ const PreviewPressTool = browserTool(...@@ -233,7 +239,7 @@ const PreviewPressTool = browserTool(
233 "Press one keyboard key in the tab selected by tabId, or this agent session's current tab when omitted. Examples: {key:'Enter'}, {key:'Escape'}, or {key:'a',modifiers:['Meta']}.",239 "Press one keyboard key in the tab selected by tabId, or this agent session's current tab when omitted. Examples: {key:'Enter'}, {key:'Escape'}, or {key:'a',modifiers:['Meta']}.",
234 parameters: PreviewAutomationPressInput,240 parameters: PreviewAutomationPressInput,
235 success: PreviewActionResult,241 success: PreviewActionResult,
236 failure: PreviewAutomationError,242 failure: PreviewToolFailure,
237 dependencies,243 dependencies,
238 }).annotate(Tool.Title, "Press key in preview page"),244 }).annotate(Tool.Title, "Press key in preview page"),
239);245);
...@@ -244,7 +250,7 @@ const PreviewScrollTool = safeBrowserTool(...@@ -244,7 +250,7 @@ const PreviewScrollTool = safeBrowserTool(
244 "Scroll the tab selected by tabId, or this agent session's current tab when omitted. Positive deltaY scrolls down and positive deltaX scrolls right; a locator/selector targets a container.",250 "Scroll the tab selected by tabId, or this agent session's current tab when omitted. Positive deltaY scrolls down and positive deltaX scrolls right; a locator/selector targets a container.",
245 parameters: PreviewAutomationScrollInput,251 parameters: PreviewAutomationScrollInput,
246 success: PreviewActionResult,252 success: PreviewActionResult,
247 failure: PreviewAutomationError,253 failure: PreviewToolFailure,
248 dependencies,254 dependencies,
249 }).annotate(Tool.Title, "Scroll preview page"),255 }).annotate(Tool.Title, "Scroll preview page"),
250);256);
...@@ -267,7 +273,7 @@ const PreviewEvaluateTool = browserTool(...@@ -267,7 +273,7 @@ const PreviewEvaluateTool = browserTool(
267 "Evaluate JavaScript in the tab selected by tabId, or this agent session's current tab when omitted. Returns {value} with a serializable result up to 64 KB; the expression may mutate page state.",273 "Evaluate JavaScript in the tab selected by tabId, or this agent session's current tab when omitted. Returns {value} with a serializable result up to 64 KB; the expression may mutate page state.",
268 parameters: PreviewAutomationEvaluateInput,274 parameters: PreviewAutomationEvaluateInput,
269 success: PreviewEvaluateResult,275 success: PreviewEvaluateResult,
270 failure: PreviewAutomationError,276 failure: PreviewToolFailure,
271 dependencies,277 dependencies,
272 }).annotate(Tool.Title, "Evaluate JavaScript in preview"),278 }).annotate(Tool.Title, "Evaluate JavaScript in preview"),
273);279);
...@@ -278,7 +284,7 @@ const PreviewWaitForTool = readonlyBrowserTool(...@@ -278,7 +284,7 @@ const PreviewWaitForTool = readonlyBrowserTool(
278 "Wait in the tab selected by tabId, or this agent session's current tab when omitted, until all supplied locator, selector, text, and URL conditions match.",284 "Wait in the tab selected by tabId, or this agent session's current tab when omitted, until all supplied locator, selector, text, and URL conditions match.",
279 parameters: PreviewAutomationWaitForInput,285 parameters: PreviewAutomationWaitForInput,
280 success: PreviewActionResult,286 success: PreviewActionResult,
281 failure: PreviewAutomationError,287 failure: PreviewToolFailure,
282 dependencies,288 dependencies,
283 }).annotate(Tool.Title, "Wait for preview page condition"),289 }).annotate(Tool.Title, "Wait for preview page condition"),
284);290);
...@@ -289,7 +295,7 @@ const PreviewRecordingStartTool = safeBrowserTool(...@@ -289,7 +295,7 @@ const PreviewRecordingStartTool = safeBrowserTool(
289 "Start recording the collaborative browser tab selected by tabId, or this agent session's current tab when omitted.",295 "Start recording the collaborative browser tab selected by tabId, or this agent session's current tab when omitted.",
290 parameters: PreviewAutomationTabTargetInput,296 parameters: PreviewAutomationTabTargetInput,
291 success: Schema.Struct({ ...PreviewAutomationRecordingStatus.fields, ...presentationFields }),297 success: Schema.Struct({ ...PreviewAutomationRecordingStatus.fields, ...presentationFields }),
292 failure: PreviewAutomationError,298 failure: PreviewToolFailure,
293 dependencies,299 dependencies,
294 }).annotate(Tool.Title, "Start browser recording"),300 }).annotate(Tool.Title, "Start browser recording"),
295);301);
...@@ -300,7 +306,7 @@ const PreviewRecordingStopTool = safeBrowserTool(...@@ -300,7 +306,7 @@ const PreviewRecordingStopTool = safeBrowserTool(
300 "Stop recording the collaborative browser tab selected by tabId, or this agent session's current tab when omitted, and transfer the compressed recording once (up to 50 MiB) to an evidence file readable in this agent's environment. Returns its environment-local path after transfer succeeds.",306 "Stop recording the collaborative browser tab selected by tabId, or this agent session's current tab when omitted, and transfer the compressed recording once (up to 50 MiB) to an evidence file readable in this agent's environment. Returns its environment-local path after transfer succeeds.",
301 parameters: PreviewAutomationTabTargetInput,307 parameters: PreviewAutomationTabTargetInput,
302 success: Schema.Struct({ ...PreviewAutomationRecordingArtifact.fields, ...presentationFields }),308 success: Schema.Struct({ ...PreviewAutomationRecordingArtifact.fields, ...presentationFields }),
303 failure: PreviewAutomationError,309 failure: PreviewToolFailure,
304 dependencies: [...dependencies, FileSystem.FileSystem, ServerConfig.ServerConfig],310 dependencies: [...dependencies, FileSystem.FileSystem, ServerConfig.ServerConfig],
305 }).annotate(Tool.Title, "Stop browser recording"),311 }).annotate(Tool.Title, "Stop browser recording"),
306);312);
apps/server/src/mcp/toolkits/previewControls/handlers.test.ts+8-1
...@@ -17,6 +17,8 @@ import * as ServerConfig from "../../../config.ts";...@@ -17,6 +17,8 @@ import * as ServerConfig from "../../../config.ts";
17import * as Preview from "../../../preview/Manager.ts";17import * as Preview from "../../../preview/Manager.ts";
18import * as ServerSettings from "../../../serverSettings.ts";18import * as ServerSettings from "../../../serverSettings.ts";
19import * as McpInvocationContext from "../../McpInvocationContext.ts";19import * as McpInvocationContext from "../../McpInvocationContext.ts";
20import * as McpToolAccess from "../../McpToolAccess.ts";
21import * as McpToolAccessTestkit from "../../McpToolAccess.testkit.ts";
20import * as PreviewAutomationBroker from "../../PreviewAutomationBroker.ts";22import * as PreviewAutomationBroker from "../../PreviewAutomationBroker.ts";
21import * as PreviewControlsHandlers from "./handlers.ts";23import * as PreviewControlsHandlers from "./handlers.ts";
22import { PreviewControlsToolkit } from "./tools.ts";24import { PreviewControlsToolkit } from "./tools.ts";
...@@ -64,12 +66,17 @@ it.effect.each([...@@ -64,12 +66,17 @@ it.effect.each([
64 PreviewAutomationBroker.layer.pipe(Layer.provide(NodeServices.layer)),66 PreviewAutomationBroker.layer.pipe(Layer.provide(NodeServices.layer)),
65 Layer.succeed(Preview.PreviewManager, manager),67 Layer.succeed(Preview.PreviewManager, manager),
66 Layer.succeed(McpInvocationContext.McpInvocationContext, scope),68 Layer.succeed(McpInvocationContext.McpInvocationContext, scope),
69 McpToolAccessTestkit.liveThreadsLayer,
67 Layer.mock(ServerSettings.ServerSettingsService)({70 Layer.mock(ServerSettings.ServerSettingsService)({
68 getSettings: Effect.succeed(settings),71 getSettings: Effect.succeed(settings),
69 }),72 }),
70 );73 );
71 const toolkit = yield* PreviewControlsToolkit.pipe(74 const toolkit = yield* PreviewControlsToolkit.pipe(
72 Effect.provide(PreviewControlsHandlers.layer.pipe(Layer.provide(layerDependencies))),75 Effect.provide(
76 McpToolAccess.HandlersLayer.layer(PreviewControlsHandlers.layer).pipe(
77 Layer.provide(layerDependencies),
78 ),
79 ),
73 );80 );
74 const listed = yield* toolkit81 const listed = yield* toolkit
75 .handle("t3_preview_list", {})82 .handle("t3_preview_list", {})
apps/server/src/mcp/toolkits/previewControls/handlers.ts+6-3
...@@ -1,6 +1,7 @@...@@ -1,6 +1,7 @@
1import * as Effect from "effect/Effect";1import * as Effect from "effect/Effect";
2import * as Preview from "../../../preview/Manager.ts";2import * as Preview from "../../../preview/Manager.ts";
3import { requireThreadMcpCapability } from "../../McpInvocationContext.ts";3import { requireThreadMcpCapability } from "../../McpInvocationContext.ts";
4import * as McpToolAccess from "../../McpToolAccess.ts";
4import { unavailable } from "../../threadAccess.ts";5import { unavailable } from "../../threadAccess.ts";
5import { PreviewControlsToolkit } from "./tools.ts";6import { PreviewControlsToolkit } from "./tools.ts";
6import * as PreviewAutomationBroker from "../../PreviewAutomationBroker.ts";7import * as PreviewAutomationBroker from "../../PreviewAutomationBroker.ts";
...@@ -10,8 +11,8 @@ const access = Effect.gen(function* () {...@@ -10,8 +11,8 @@ const access = Effect.gen(function* () {
10 const scope = yield* requireThreadMcpCapability("preview");11 const scope = yield* requireThreadMcpCapability("preview");
11 return { scope, manager: yield* Preview.PreviewManager };12 return { scope, manager: yield* Preview.PreviewManager };
12});13});
13export const layer = PreviewControlsToolkit.toLayer({14export const layer = McpToolAccess.toLayer(PreviewControlsToolkit, {
14 t3_preview_list: (input) =>15 t3_preview_list: McpToolAccess.readsAsCaller((input) =>
15 Effect.gen(function* () {16 Effect.gen(function* () {
16 const { scope, manager } = yield* access;17 const { scope, manager } = yield* access;
17 const result = yield* manager.list({ threadId: scope.thread.threadId });18 const result = yield* manager.list({ threadId: scope.thread.threadId });
...@@ -23,7 +24,8 @@ export const layer = PreviewControlsToolkit.toLayer({...@@ -23,7 +24,8 @@ export const layer = PreviewControlsToolkit.toLayer({
23 nextCursor: end < result.sessions.length ? end : null,24 nextCursor: end < result.sessions.length ? end : null,
24 };25 };
25 }),26 }),
26 t3_preview_close: (input) =>27 ),
28 t3_preview_close: McpToolAccess.actsAsCaller((input) =>
27 Effect.gen(function* () {29 Effect.gen(function* () {
28 const { scope, manager } = yield* access;30 const { scope, manager } = yield* access;
29 const { sessions } = yield* manager.list({ threadId: scope.thread.threadId });31 const { sessions } = yield* manager.list({ threadId: scope.thread.threadId });
...@@ -41,4 +43,5 @@ export const layer = PreviewControlsToolkit.toLayer({...@@ -41,4 +43,5 @@ export const layer = PreviewControlsToolkit.toLayer({
41 .pipe(Effect.mapError(unavailable));43 .pipe(Effect.mapError(unavailable));
42 return {};44 return {};
43 }),45 }),
46 ),
44});47});
apps/server/src/mcp/toolkits/previewControls/tools.ts+2
...@@ -7,6 +7,7 @@ import {...@@ -7,6 +7,7 @@ import {
7} from "@t3tools/contracts";7} from "@t3tools/contracts";
8import * as Schema from "effect/Schema";8import * as Schema from "effect/Schema";
9import { Tool, Toolkit } from "effect/ai";9import { Tool, Toolkit } from "effect/ai";
10import * as ThreadManagementService from "../../../orchestration-v2/ThreadManagementService.ts";
10import * as PreviewManager from "../../../preview/Manager.ts";11import * as PreviewManager from "../../../preview/Manager.ts";
11import * as PreviewAutomationBroker from "../../PreviewAutomationBroker.ts";12import * as PreviewAutomationBroker from "../../PreviewAutomationBroker.ts";
12import * as McpInvocationContext from "../../McpInvocationContext.ts";13import * as McpInvocationContext from "../../McpInvocationContext.ts";
...@@ -16,6 +17,7 @@ const shared = {...@@ -16,6 +17,7 @@ const shared = {
16 failureMode: "return" as const,17 failureMode: "return" as const,
17 dependencies: [18 dependencies: [
18 McpInvocationContext.McpInvocationContext,19 McpInvocationContext.McpInvocationContext,
20 ThreadManagementService.ThreadManagementService,
19 PreviewManager.PreviewManager,21 PreviewManager.PreviewManager,
20 PreviewAutomationBroker.PreviewAutomationBroker,22 PreviewAutomationBroker.PreviewAutomationBroker,
21 ],23 ],
apps/server/src/mcp/toolkits/project/handlers.test.ts+101-7
...@@ -10,6 +10,8 @@ import {...@@ -10,6 +10,8 @@ import {
10import * as NodeCrypto from "@effect/platform-node/NodeCrypto";10import * as NodeCrypto from "@effect/platform-node/NodeCrypto";
11import * as NodeServices from "@effect/platform-node/NodeServices";11import * as NodeServices from "@effect/platform-node/NodeServices";
12import * as Effect from "effect/Effect";12import * as Effect from "effect/Effect";
13import * as FileSystem from "effect/FileSystem";
14import * as Path from "effect/Path";
13import * as Layer from "effect/Layer";15import * as Layer from "effect/Layer";
14import * as Option from "effect/Option";16import * as Option from "effect/Option";
15import * as Stream from "effect/Stream";17import * as Stream from "effect/Stream";
...@@ -19,7 +21,10 @@ import * as ThreadManagement from "../../../orchestration-v2/ThreadManagementSer...@@ -19,7 +21,10 @@ import * as ThreadManagement from "../../../orchestration-v2/ThreadManagementSer
19import * as ServerConfig from "../../../config.ts";21import * as ServerConfig from "../../../config.ts";
20import * as Project from "../../../project/ProjectService.ts";22import * as Project from "../../../project/ProjectService.ts";
21import * as ManagedProjectFolders from "../../../project/ManagedProjectFolders.ts";23import * as ManagedProjectFolders from "../../../project/ManagedProjectFolders.ts";
24import * as GitVcsDriver from "../../../vcs/GitVcsDriver.ts";
25import * as VcsProcess from "../../../vcs/VcsProcess.ts";
22import * as McpInvocationContext from "../../McpInvocationContext.ts";26import * as McpInvocationContext from "../../McpInvocationContext.ts";
27import * as McpToolAccess from "../../McpToolAccess.ts";
23import * as ProjectHandlers from "./handlers.ts";28import * as ProjectHandlers from "./handlers.ts";
24import { ProjectToolkit } from "./tools.ts";29import { ProjectToolkit } from "./tools.ts";
2530
...@@ -73,13 +78,18 @@ it.effect("attributes a launched thread's first message to the calling thread",...@@ -73,13 +78,18 @@ it.effect("attributes a launched thread's first message to the calling thread",
73 }),78 }),
74 Layer.mock(Project.ProjectService)({}),79 Layer.mock(Project.ProjectService)({}),
75 Layer.mock(ManagedProjectFolders.ManagedProjectFolders)({ namedProjectsRoot: "/projects" }),80 Layer.mock(ManagedProjectFolders.ManagedProjectFolders)({ namedProjectsRoot: "/projects" }),
81 Layer.mock(GitVcsDriver.GitVcsDriver)({}),
76 NodeServices.layer,82 NodeServices.layer,
77 ServerConfig.layerTest(process.cwd(), { prefix: "t3-source-link-" }).pipe(83 ServerConfig.layerTest(process.cwd(), { prefix: "t3-source-link-" }).pipe(
78 Layer.provide(NodeServices.layer),84 Layer.provide(NodeServices.layer),
79 ),85 ),
80 );86 );
81 const toolkit = yield* ProjectToolkit.pipe(87 const toolkit = yield* ProjectToolkit.pipe(
82 Effect.provide(ProjectHandlers.layer.pipe(Layer.provide(layerDependencies))),88 Effect.provide(
89 McpToolAccess.HandlersLayer.layer(ProjectHandlers.layer).pipe(
90 Layer.provide(layerDependencies),
91 ),
92 ),
83 );93 );
84 const result = yield* toolkit94 const result = yield* toolkit
85 .handle("t3_thread_launch", { title: "Audit", message: "Review the change" })95 .handle("t3_thread_launch", { title: "Audit", message: "Review the change" })
...@@ -142,13 +152,18 @@ it.effect("launches a scratch thread into the Scratch project", () =>...@@ -142,13 +152,18 @@ it.effect("launches a scratch thread into the Scratch project", () =>
142 namedProjectsRoot: "/projects",152 namedProjectsRoot: "/projects",
143 ensureScratchProject: Effect.succeed({ projectId: scratchProjectId }),153 ensureScratchProject: Effect.succeed({ projectId: scratchProjectId }),
144 }),154 }),
155 Layer.mock(GitVcsDriver.GitVcsDriver)({}),
145 NodeServices.layer,156 NodeServices.layer,
146 ServerConfig.layerTest(process.cwd(), { prefix: "t3-scratch-launch-" }).pipe(157 ServerConfig.layerTest(process.cwd(), { prefix: "t3-scratch-launch-" }).pipe(
147 Layer.provide(NodeServices.layer),158 Layer.provide(NodeServices.layer),
148 ),159 ),
149 );160 );
150 const toolkit = yield* ProjectToolkit.pipe(161 const toolkit = yield* ProjectToolkit.pipe(
151 Effect.provide(ProjectHandlers.layer.pipe(Layer.provide(layerDependencies))),162 Effect.provide(
163 McpToolAccess.HandlersLayer.layer(ProjectHandlers.layer).pipe(
164 Layer.provide(layerDependencies),
165 ),
166 ),
152 );167 );
153 const handle = (params: Parameters<typeof toolkit.handle<"t3_thread_launch">>[1]) =>168 const handle = (params: Parameters<typeof toolkit.handle<"t3_thread_launch">>[1]) =>
154 toolkit169 toolkit
...@@ -240,13 +255,18 @@ it.effect("starts a project from just a title when workspaceRoot is omitted", ()...@@ -240,13 +255,18 @@ it.effect("starts a project from just a title when workspaceRoot is omitted", ()
240 };255 };
241 }),256 }),
242 }),257 }),
258 Layer.mock(GitVcsDriver.GitVcsDriver)({}),
243 NodeServices.layer,259 NodeServices.layer,
244 ServerConfig.layerTest(process.cwd(), { prefix: "t3-named-project-" }).pipe(260 ServerConfig.layerTest(process.cwd(), { prefix: "t3-named-project-" }).pipe(
245 Layer.provide(NodeServices.layer),261 Layer.provide(NodeServices.layer),
246 ),262 ),
247 );263 );
248 const toolkit = yield* ProjectToolkit.pipe(264 const toolkit = yield* ProjectToolkit.pipe(
249 Effect.provide(ProjectHandlers.layer.pipe(Layer.provide(layerDependencies))),265 Effect.provide(
266 McpToolAccess.HandlersLayer.layer(ProjectHandlers.layer).pipe(
267 Layer.provide(layerDependencies),
268 ),
269 ),
250 );270 );
251 const handle = (params: Parameters<typeof toolkit.handle<"t3_project_create">>[1]) =>271 const handle = (params: Parameters<typeof toolkit.handle<"t3_project_create">>[1]) =>
252 toolkit272 toolkit
...@@ -280,6 +300,7 @@ it.effect("starts a project from just a title when workspaceRoot is omitted", ()...@@ -280,6 +300,7 @@ it.effect("starts a project from just a title when workspaceRoot is omitted", ()
280const clientLaunchHarness = (input: {300const clientLaunchHarness = (input: {
281 readonly runtimeModeCeiling: "approval-required" | "auto-accept-edits" | "auto" | "full-access";301 readonly runtimeModeCeiling: "approval-required" | "auto-accept-edits" | "auto" | "full-access";
282 readonly launched: Array<ThreadLaunch.ThreadLaunchInput>;302 readonly launched: Array<ThreadLaunch.ThreadLaunchInput>;
303 readonly workspaceRoot?: string;
283}) => {304}) => {
284 const projectId = ProjectId.make("project:client-target");305 const projectId = ProjectId.make("project:client-target");
285 const modelSelection = { instanceId: ProviderInstanceId.make("claude"), model: "claude-opus" };306 const modelSelection = { instanceId: ProviderInstanceId.make("claude"), model: "claude-opus" };
...@@ -319,15 +340,25 @@ const clientLaunchHarness = (input: {...@@ -319,15 +340,25 @@ const clientLaunchHarness = (input: {
319 getById: (id) =>340 getById: (id) =>
320 Effect.succeed(341 Effect.succeed(
321 id === projectId342 id === projectId
322 ? Option.some({ id, defaultModelSelection: modelSelection } as unknown as ProjectRecord)343 ? Option.some({
344 id,
345 workspaceRoot: input.workspaceRoot ?? "/projects/client-target",
346 defaultModelSelection: modelSelection,
347 } as unknown as ProjectRecord)
323 : Option.none(),348 : Option.none(),
324 ),349 ),
325 }),350 }),
326 Layer.mock(ManagedProjectFolders.ManagedProjectFolders)({ namedProjectsRoot: "/projects" }),351 Layer.mock(ManagedProjectFolders.ManagedProjectFolders)({ namedProjectsRoot: "/projects" }),
327 NodeServices.layer,352 NodeServices.layer,
328 ServerConfig.layerTest(process.cwd(), { prefix: "t3-client-launch-" }).pipe(353 ).pipe(
329 Layer.provide(NodeServices.layer),354 Layer.provideMerge(GitVcsDriver.layer),
355 Layer.provideMerge(VcsProcess.layer),
356 Layer.provideMerge(
357 ServerConfig.layerTest(process.cwd(), { prefix: "t3-client-launch-" }).pipe(
358 Layer.provide(NodeServices.layer),
359 ),
330 ),360 ),
361 Layer.provideMerge(NodeServices.layer),
331 );362 );
332 return { projectId, modelSelection, dependencies: layerDependencies };363 return { projectId, modelSelection, dependencies: layerDependencies };
333};364};
...@@ -340,7 +371,9 @@ it.effect("a client launches at its ceiling with the project's default model", (...@@ -340,7 +371,9 @@ it.effect("a client launches at its ceiling with the project's default model", (
340 launched,371 launched,
341 });372 });
342 const toolkit = yield* ProjectToolkit.pipe(373 const toolkit = yield* ProjectToolkit.pipe(
343 Effect.provide(ProjectHandlers.layer.pipe(Layer.provide(dependencies))),374 Effect.provide(
375 McpToolAccess.HandlersLayer.layer(ProjectHandlers.layer).pipe(Layer.provide(dependencies)),
376 ),
344 );377 );
345 const handle = (params: Parameters<typeof toolkit.handle<"t3_thread_launch">>[1]) =>378 const handle = (params: Parameters<typeof toolkit.handle<"t3_thread_launch">>[1]) =>
346 toolkit379 toolkit
...@@ -360,3 +393,64 @@ it.effect("a client launches at its ceiling with the project's default model", (...@@ -360,3 +393,64 @@ it.effect("a client launches at its ceiling with the project's default model", (
360 expect(launched).toHaveLength(1);393 expect(launched).toHaveLength(1);
361 }),394 }),
362);395);
396
397it.effect("a launch binds only an existing checkout that is one of the project's worktrees", () =>
398 Effect.gen(function* () {
399 const fileSystem = yield* FileSystem.FileSystem;
400 const path = yield* Path.Path;
401 const root = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-launch-worktree-" });
402 const repo = path.join(root, "repo");
403 const worktree = path.join(root, "feature");
404 const outside = path.join(root, "outside");
405 yield* fileSystem.makeDirectory(repo);
406 yield* fileSystem.makeDirectory(outside);
407
408 const launched: Array<ThreadLaunch.ThreadLaunchInput> = [];
409 const { projectId, dependencies } = clientLaunchHarness({
410 runtimeModeCeiling: "auto",
411 launched,
412 workspaceRoot: repo,
413 });
414 const git = yield* GitVcsDriver.GitVcsDriver.pipe(Effect.provide(dependencies));
415 for (const args of [
416 ["init", "-b", "main"],
417 ["-c", "user.email=t@t", "-c", "user.name=t", "commit", "--allow-empty", "-m", "init"],
418 ["worktree", "add", "-b", "feature", worktree],
419 ]) {
420 yield* git.execute({ operation: "test.setupRepo", cwd: repo, args });
421 }
422 const toolkit = yield* ProjectToolkit.pipe(
423 Effect.provide(
424 McpToolAccess.HandlersLayer.layer(ProjectHandlers.layer).pipe(Layer.provide(dependencies)),
425 ),
426 );
427 const launchInto = (worktreePath: string) =>
428 toolkit
429 .handle("t3_thread_launch", {
430 title: "Fix",
431 projectId,
432 workspaceStrategy: { type: "existing_worktree", worktreePath },
433 })
434 .pipe(Stream.unwrap, Stream.runCollect, Effect.provide(dependencies));
435
436 expect((yield* launchInto(worktree)).at(-1)?.result).toMatchObject({ projectId });
437 expect((yield* launchInto(repo)).at(-1)?.result).toMatchObject({ projectId });
438 for (const elsewhere of [outside, path.join(worktree, "..", "outside"), "/"]) {
439 expect((yield* launchInto(elsewhere)).at(-1)?.result).toMatchObject({
440 code: "invalid_request",
441 });
442 }
443 expect(launched.map((launch) => launch.workspaceStrategy)).toEqual([
444 { type: "existing_worktree", worktreePath: worktree },
445 { type: "existing_worktree", worktreePath: repo },
446 ]);
447
448 // A removed worktree stays listed as prunable until `git worktree prune`;
449 // whatever directory is later made at its path is not one of the project's.
450 yield* fileSystem.remove(worktree, { recursive: true });
451 yield* fileSystem.makeDirectory(worktree);
452 expect((yield* launchInto(worktree)).at(-1)?.result).toMatchObject({
453 code: "invalid_request",
454 });
455 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
456);
apps/server/src/mcp/toolkits/project/handlers.ts+142-126
...@@ -1,20 +1,15 @@...@@ -1,20 +1,15 @@
1import { MessageId, ThreadId, OrchestratorMcpFailure, ProjectId } from "@t3tools/contracts";1import { MessageId, ThreadId, OrchestratorMcpFailure, ProjectId } from "@t3tools/contracts";
2import * as Effect from "effect/Effect";2import * as Effect from "effect/Effect";
3import * as FileSystem from "effect/FileSystem";
3import * as Option from "effect/Option";4import * as Option from "effect/Option";
4import * as ThreadMessageIntake from "../../../orchestration-v2/ThreadMessageIntake.ts";5import * as ThreadMessageIntake from "../../../orchestration-v2/ThreadMessageIntake.ts";
5import * as Claims from "../../../orchestration-v2/AttachmentClaims.ts";6import * as Claims from "../../../orchestration-v2/AttachmentClaims.ts";
6import * as Project from "../../../project/ProjectService.ts";7import * as Project from "../../../project/ProjectService.ts";
7import * as ManagedProjectFolders from "../../../project/ManagedProjectFolders.ts";8import * as ManagedProjectFolders from "../../../project/ManagedProjectFolders.ts";
8import * as Repositories from "../../../sourceControl/SourceControlRepositoryService.ts";9import * as Repositories from "../../../sourceControl/SourceControlRepositoryService.ts";
9import { resolveRuntimeMode } from "../../OrchestratorMcpService.ts";10import * as GitVcsDriver from "../../../vcs/GitVcsDriver.ts";
10import {11import * as McpToolAccess from "../../McpToolAccess.ts";
11 newCommandId,12import { newCommandId, readCaller, resolveProjectId, unavailable } from "../../threadAccess.ts";
12 readCaller,
13 readFullAccessCaller,
14 readMutationCaller,
15 resolveProjectId,
16 unavailable,
17} from "../../threadAccess.ts";
18import { ProjectToolkit } from "./tools.ts";13import { ProjectToolkit } from "./tools.ts";
1914
20function projectFailure(error: Project.ProjectServiceError) {15function projectFailure(error: Project.ProjectServiceError) {
...@@ -28,121 +23,137 @@ function projectFailure(error: Project.ProjectServiceError) {...@@ -28,121 +23,137 @@ function projectFailure(error: Project.ProjectServiceError) {
28 return new OrchestratorMcpFailure({ code: "invalid_request", message });23 return new OrchestratorMcpFailure({ code: "invalid_request", message });
29}24}
3025
26/**
27 * An existing checkout a launch may bind: one of the project's own git
28 * worktrees. Without this check a launch could point an agent at any directory
29 * on the machine.
30 */
31const assertProjectWorktree = Effect.fn("mcp.assertProjectWorktree")(function* (
32 workspaceRoot: string,
33 worktreePath: string,
34) {
35 const git = yield* GitVcsDriver.GitVcsDriver;
36 const fileSystem = yield* FileSystem.FileSystem;
37 const real = (path: string) => fileSystem.realPath(path).pipe(Effect.orElseSucceed(() => path));
38 const worktrees = yield* git.listWorktreePaths(workspaceRoot).pipe(
39 Effect.flatMap((paths) => Effect.forEach(paths, real)),
40 Effect.orElseSucceed((): ReadonlyArray<string> => []),
41 );
42 if (!worktrees.includes(yield* real(worktreePath)))
43 return yield* new OrchestratorMcpFailure({
44 code: "invalid_request",
45 message:
46 "worktreePath must be one of the project's git worktrees. t3_worktree_list shows them.",
47 });
48});
49
31const access = Effect.gen(function* () {50const access = Effect.gen(function* () {
32 yield* readCaller();51 yield* readCaller();
33 return yield* Project.ProjectService;52 return yield* Project.ProjectService;
34});53});
35const mutation = Effect.gen(function* () {54export const layer = McpToolAccess.toLayer(ProjectToolkit, {
36 yield* readFullAccessCaller(55 t3_thread_launch: McpToolAccess.startsThreads(
37 "Project changes require a live full-access/default calling thread or a full-access client.",56 (input) => input,
38 );57 (input, { runtimeMode, interactionMode }) =>
39 return yield* Project.ProjectService;58 Effect.gen(function* () {
40});59 const context = yield* readCaller();
41export const layer = ProjectToolkit.toLayer({60 const { caller } = context;
42 t3_thread_launch: (input) =>61 const commandId = yield* newCommandId();
43 Effect.gen(function* () {62 const threadId = ThreadId.make(commandId);
44 const context = yield* readMutationCaller();63 const messageId = MessageId.make(commandId);
45 const { caller, limits } = context;64 const attachments = input.attachments ?? [];
46 // A thread caller launches only as itself (full-access/default), as before. A client65 if (attachments.some((attachment) => !Claims.attachmentIsPendingUpload(attachment)))
47 // launches anything up to its ceiling.66 return yield* new OrchestratorMcpFailure({
48 if (67 code: "invalid_request",
49 caller !== undefined &&68 message: "A new thread accepts only pending attachment uploads.",
50 (caller.runtimeMode !== "full-access" || caller.interactionMode !== "default")69 });
51 )70 if (
52 return yield* new OrchestratorMcpFailure({71 input.scratch === true &&
53 code: "capability_denied",72 (input.projectId !== undefined || input.workspaceStrategy !== undefined)
54 message: "Project launches require a full-access/default calling thread.",73 )
55 });74 return yield* new OrchestratorMcpFailure({
56 const runtimeMode = yield* resolveRuntimeMode(75 code: "invalid_request",
57 limits.runtimeMode,76 message:
58 input.runtimeMode ?? caller?.runtimeMode,77 "scratch:true picks its own project and folder; omit projectId and workspaceStrategy.",
59 );78 });
60 const commandId = yield* newCommandId();79 const projectId =
61 const threadId = ThreadId.make(commandId);80 input.scratch === true
62 const messageId = MessageId.make(commandId);81 ? (yield* ManagedProjectFolders.ManagedProjectFolders.pipe(
63 const attachments = input.attachments ?? [];82 Effect.flatMap((folders) => folders.ensureScratchProject),
64 if (attachments.some((attachment) => !Claims.attachmentIsPendingUpload(attachment)))83 Effect.mapError(
65 return yield* new OrchestratorMcpFailure({84 (error) =>
66 code: "invalid_request",85 new OrchestratorMcpFailure({
67 message: "A new thread accepts only pending attachment uploads.",86 code: "orchestration_error",
68 });87 message: error.message,
69 if (88 }),
70 input.scratch === true &&89 ),
71 (input.projectId !== undefined || input.workspaceStrategy !== undefined)90 )).projectId
72 )91 : yield* resolveProjectId(context, input.projectId);
73 return yield* new OrchestratorMcpFailure({92 const readProject = Project.ProjectService.pipe(
74 code: "invalid_request",
75 message:
76 "scratch:true picks its own project and folder; omit projectId and workspaceStrategy.",
77 });
78 const projectId =
79 input.scratch === true
80 ? (yield* ManagedProjectFolders.ManagedProjectFolders.pipe(
81 Effect.flatMap((folders) => folders.ensureScratchProject),
82 Effect.mapError(
83 (error) =>
84 new OrchestratorMcpFailure({
85 code: "orchestration_error",
86 message: error.message,
87 }),
88 ),
89 )).projectId
90 : yield* resolveProjectId(context, input.projectId);
91 const modelSelection =
92 input.modelSelection ??
93 caller?.modelSelection ??
94 (yield* Project.ProjectService.pipe(
95 Effect.flatMap((projects) => projects.getById(projectId)),93 Effect.flatMap((projects) => projects.getById(projectId)),
96 Effect.mapError(unavailable),94 Effect.mapError(unavailable),
97 Effect.map((project) =>95 Effect.map(Option.getOrUndefined),
98 Option.getOrUndefined(Option.flatMapNullishOr(project, (p) => p.defaultModelSelection)),96 );
97 if (input.workspaceStrategy?.type === "existing_worktree") {
98 const project = yield* readProject;
99 if (project === undefined)
100 return yield* new OrchestratorMcpFailure({
101 code: "invalid_request",
102 message: "The project was not found.",
103 });
104 yield* assertProjectWorktree(project.workspaceRoot, input.workspaceStrategy.worktreePath);
105 }
106 const modelSelection =
107 input.modelSelection ??
108 caller?.modelSelection ??
109 (yield* readProject)?.defaultModelSelection ??
110 undefined;
111 if (modelSelection === undefined)
112 return yield* new OrchestratorMcpFailure({
113 code: "invalid_request",
114 message:
115 "Pass modelSelection: the project has no default model. orchestrator_capabilities lists providers and models.",
116 });
117 const result = yield* ThreadMessageIntake.launchThread({
118 commandId,
119 threadId,
120 projectId,
121 title: input.title,
122 modelSelection,
123 runtimeMode,
124 interactionMode,
125 workspaceStrategy: input.workspaceStrategy ?? { type: "root" },
126 ...(input.message === undefined && attachments.length === 0
127 ? {}
128 : {
129 initialMessage: {
130 messageId,
131 ...(caller === undefined ? {} : { senderThreadId: caller.id }),
132 text: input.message ?? "",
133 attachments,
134 },
135 }),
136 createdBy: "agent",
137 creationSource: "mcp",
138 }).pipe(
139 Effect.mapError((error) =>
140 error._tag === "AttachmentClaimError"
141 ? new OrchestratorMcpFailure({ code: "orchestration_error", message: error.message })
142 : unavailable(),
99 ),143 ),
100 ));144 );
101 if (modelSelection === undefined)145 const thread = result.projection.thread;
102 return yield* new OrchestratorMcpFailure({146 const run = result.projection.runs.find((run) => run.userMessageId === messageId);
103 code: "invalid_request",147 return {
104 message:148 threadId: thread.id,
105 "Pass modelSelection: the project has no default model. orchestrator_capabilities lists providers and models.",149 projectId: thread.projectId,
106 });150 modelSelection: thread.modelSelection,
107 const result = yield* ThreadMessageIntake.launchThread({151 runId: run?.id ?? null,
108 commandId,152 status: run?.status ?? null,
109 threadId,153 };
110 projectId,154 }),
111 title: input.title,155 ),
112 modelSelection,156 t3_project_list: McpToolAccess.reads((input) =>
113 runtimeMode,
114 interactionMode: input.interactionMode ?? caller?.interactionMode ?? "default",
115 workspaceStrategy: input.workspaceStrategy ?? { type: "root" },
116 ...(input.message === undefined && attachments.length === 0
117 ? {}
118 : {
119 initialMessage: {
120 messageId,
121 ...(caller === undefined ? {} : { senderThreadId: caller.id }),
122 text: input.message ?? "",
123 attachments,
124 },
125 }),
126 createdBy: "agent",
127 creationSource: "mcp",
128 }).pipe(
129 Effect.mapError((error) =>
130 error._tag === "AttachmentClaimError"
131 ? new OrchestratorMcpFailure({ code: "orchestration_error", message: error.message })
132 : unavailable(),
133 ),
134 );
135 const thread = result.projection.thread;
136 const run = result.projection.runs.find((run) => run.userMessageId === messageId);
137 return {
138 threadId: thread.id,
139 projectId: thread.projectId,
140 modelSelection: thread.modelSelection,
141 runId: run?.id ?? null,
142 status: run?.status ?? null,
143 };
144 }),
145 t3_project_list: (input) =>
146 Effect.gen(function* () {157 Effect.gen(function* () {
147 const projects = yield* access;158 const projects = yield* access;
148 const snapshot = yield* projects.snapshot.pipe(Effect.mapError(unavailable));159 const snapshot = yield* projects.snapshot.pipe(Effect.mapError(unavailable));
...@@ -151,7 +162,8 @@ export const layer = ProjectToolkit.toLayer({...@@ -151,7 +162,8 @@ export const layer = ProjectToolkit.toLayer({
151 end = start + (input.limit ?? 20);162 end = start + (input.limit ?? 20);
152 return { projects: rows.slice(start, end), nextCursor: end < rows.length ? end : null };163 return { projects: rows.slice(start, end), nextCursor: end < rows.length ? end : null };
153 }),164 }),
154 t3_project_read: (input) =>165 ),
166 t3_project_read: McpToolAccess.reads((input) =>
155 Effect.gen(function* () {167 Effect.gen(function* () {
156 const projects = yield* access;168 const projects = yield* access;
157 const result = yield* projects.getById(input.projectId).pipe(Effect.mapError(unavailable));169 const result = yield* projects.getById(input.projectId).pipe(Effect.mapError(unavailable));
...@@ -162,9 +174,10 @@ export const layer = ProjectToolkit.toLayer({...@@ -162,9 +174,10 @@ export const layer = ProjectToolkit.toLayer({
162 });174 });
163 return result.value;175 return result.value;
164 }),176 }),
165 t3_project_create: ({ workspaceRoot, ...input }) =>177 ),
178 t3_project_create: McpToolAccess.writesEnvironment(({ workspaceRoot, ...input }) =>
166 Effect.gen(function* () {179 Effect.gen(function* () {
167 const projects = yield* mutation;180 const projects = yield* Project.ProjectService;
168 if (workspaceRoot === undefined) {181 if (workspaceRoot === undefined) {
169 // Project creation records no model default (only an update does), so182 // Project creation records no model default (only an update does), so
170 // reject what this mode would otherwise drop silently.183 // reject what this mode would otherwise drop silently.
...@@ -205,23 +218,25 @@ export const layer = ProjectToolkit.toLayer({...@@ -205,23 +218,25 @@ export const layer = ProjectToolkit.toLayer({
205 .create({ ...input, workspaceRoot, commandId, projectId: ProjectId.make(commandId) })218 .create({ ...input, workspaceRoot, commandId, projectId: ProjectId.make(commandId) })
206 .pipe(Effect.mapError(projectFailure));219 .pipe(Effect.mapError(projectFailure));
207 }),220 }),
208 t3_project_update: (input) =>221 ),
222 t3_project_update: McpToolAccess.writesEnvironment((input) =>
209 Effect.gen(function* () {223 Effect.gen(function* () {
210 const projects = yield* mutation;224 const projects = yield* Project.ProjectService;
211 return yield* projects225 return yield* projects
212 .update({ ...input, commandId: yield* newCommandId() })226 .update({ ...input, commandId: yield* newCommandId() })
213 .pipe(Effect.mapError(projectFailure));227 .pipe(Effect.mapError(projectFailure));
214 }),228 }),
215 t3_project_delete: (input) =>229 ),
230 t3_project_delete: McpToolAccess.writesEnvironment((input) =>
216 Effect.gen(function* () {231 Effect.gen(function* () {
217 const projects = yield* mutation;232 const projects = yield* Project.ProjectService;
218 return yield* projects233 return yield* projects
219 .delete({ ...input, commandId: yield* newCommandId() })234 .delete({ ...input, commandId: yield* newCommandId() })
220 .pipe(Effect.mapError(projectFailure));235 .pipe(Effect.mapError(projectFailure));
221 }),236 }),
222 t3_project_clone: (input) =>237 ),
238 t3_project_clone: McpToolAccess.writesEnvironment((input) =>
223 Effect.gen(function* () {239 Effect.gen(function* () {
224 yield* mutation;
225 const repositories = yield* Repositories.SourceControlRepositoryService;240 const repositories = yield* Repositories.SourceControlRepositoryService;
226 return yield* repositories.cloneRepository(input).pipe(241 return yield* repositories.cloneRepository(input).pipe(
227 Effect.mapError(242 Effect.mapError(
...@@ -233,4 +248,5 @@ export const layer = ProjectToolkit.toLayer({...@@ -233,4 +248,5 @@ export const layer = ProjectToolkit.toLayer({
233 ),248 ),
234 );249 );
235 }),250 }),
251 ),
236});252});
apps/server/src/mcp/toolkits/project/tools.ts+4-2
...@@ -27,6 +27,7 @@ import * as ProjectService from "../../../project/ProjectService.ts";...@@ -27,6 +27,7 @@ import * as ProjectService from "../../../project/ProjectService.ts";
27import * as ManagedProjectFolders from "../../../project/ManagedProjectFolders.ts";27import * as ManagedProjectFolders from "../../../project/ManagedProjectFolders.ts";
28import * as ThreadManagementService from "../../../orchestration-v2/ThreadManagementService.ts";28import * as ThreadManagementService from "../../../orchestration-v2/ThreadManagementService.ts";
29import * as SourceControlRepositoryService from "../../../sourceControl/SourceControlRepositoryService.ts";29import * as SourceControlRepositoryService from "../../../sourceControl/SourceControlRepositoryService.ts";
30import * as GitVcsDriver from "../../../vcs/GitVcsDriver.ts";
30import * as McpInvocationContext from "../../McpInvocationContext.ts";31import * as McpInvocationContext from "../../McpInvocationContext.ts";
3132
32const shared = {33const shared = {
...@@ -102,7 +103,7 @@ const ProjectCloneTool = Tool.make("t3_project_clone", {...@@ -102,7 +103,7 @@ const ProjectCloneTool = Tool.make("t3_project_clone", {
102const ThreadLaunchTool = Tool.make("t3_thread_launch", {103const ThreadLaunchTool = Tool.make("t3_thread_launch", {
103 ...shared,104 ...shared,
104 description:105 description:
105 'Create an ordinary TOP-LEVEL thread with an explicit workspace binding before its agent starts. Use this when the user requests independent work, a new thread, or a PR stack in its own worktree; use delegate_task for child subagents. Set workspaceStrategy to {type:"worktree",baseRef:"parent-branch",branch:"new-branch",startFromOrigin:false} for a new worktree based on local commits, or {type:"existing_worktree",worktreePath:"/absolute/path",branch:"existing-branch"} to use an existing checkout. For upstream commits, set startFromOrigin:true. Omitted workspaceStrategy means the project root, NOT the caller\'s worktree. Omit projectId/modelSelection/modes to inherit those settings from the calling thread; a caller outside a T3 thread must pass projectId and gets the project\'s default model. Set scratch:true instead of projectId for a thread without a project: it runs in a fresh folder of its own, outside any repository. Put the task in message. Do not ask the agent to create its own worktree via shell: that does not update the thread binding. Each call creates a new launch with no retry key; retain threadId and use t3_thread_read/t3_thread_wait to follow preparation. After errors or lost responses, inspect t3_thread_list before retrying. Attachments must be pending uploads. Requires a full-access/default calling thread; a caller outside a T3 thread launches up to its approved permission mode.',106 'Create an ordinary TOP-LEVEL thread with an explicit workspace binding before its agent starts. Use this when the user requests independent work, a new thread, or a PR stack in its own worktree; use delegate_task for child subagents. Set workspaceStrategy to {type:"worktree",baseRef:"parent-branch",branch:"new-branch",startFromOrigin:false} for a new worktree based on local commits, or {type:"existing_worktree",worktreePath:"/absolute/path",branch:"existing-branch"} to use an existing checkout. For upstream commits, set startFromOrigin:true. Omitted workspaceStrategy means the project root, NOT the caller\'s worktree. Omit projectId/modelSelection/modes to inherit those settings from the calling thread; a caller outside a T3 thread must pass projectId and gets the project\'s default model. Set scratch:true instead of projectId for a thread without a project: it runs in a fresh folder of its own, outside any repository. Put the task in message. Do not ask the agent to create its own worktree via shell: that does not update the thread binding. Each call creates a new launch with no retry key; retain threadId and use t3_thread_read/t3_thread_wait to follow preparation. After errors or lost responses, inspect t3_thread_list before retrying. Attachments must be pending uploads. The new thread may not run with broader runtime or interaction modes than the caller: the calling T3 thread\'s own modes, or the permission mode an outside agent was approved with.',
106 parameters: Schema.Struct({107 parameters: Schema.Struct({
107 projectId: Schema.optional(ProjectId),108 projectId: Schema.optional(ProjectId),
108 scratch: Schema.optional(109 scratch: Schema.optional(
...@@ -118,7 +119,7 @@ const ThreadLaunchTool = Tool.make("t3_thread_launch", {...@@ -118,7 +119,7 @@ const ThreadLaunchTool = Tool.make("t3_thread_launch", {
118 workspaceStrategy: Schema.optional(119 workspaceStrategy: Schema.optional(
119 OrchestrationV2ThreadLaunchWorkspaceStrategy.annotate({120 OrchestrationV2ThreadLaunchWorkspaceStrategy.annotate({
120 description:121 description:
121 "Choose where this thread runs before starting its agent: worktree creates and binds a new checkout from baseRef; existing_worktree binds worktreePath; root uses the project checkout. Omitted means root, not the caller's worktree. For a PR stack use the parent branch as baseRef and startFromOrigin:false. Uncommitted changes are not copied.",122 "Choose where this thread runs before starting its agent: worktree creates and binds a new checkout from baseRef; existing_worktree binds worktreePath, which must be one of the project's git worktrees; root uses the project checkout. Omitted means root, not the caller's worktree. For a PR stack use the parent branch as baseRef and startFromOrigin:false. Uncommitted changes are not copied.",
122 }),123 }),
123 ),124 ),
124 message: Schema.optional(125 message: Schema.optional(
...@@ -140,6 +141,7 @@ const ThreadLaunchTool = Tool.make("t3_thread_launch", {...@@ -140,6 +141,7 @@ const ThreadLaunchTool = Tool.make("t3_thread_launch", {
140 ...shared.dependencies,141 ...shared.dependencies,
141 ThreadLaunchService.ThreadLaunchService,142 ThreadLaunchService.ThreadLaunchService,
142 ManagedProjectFolders.ManagedProjectFolders,143 ManagedProjectFolders.ManagedProjectFolders,
144 GitVcsDriver.GitVcsDriver,
143 FileSystem.FileSystem,145 FileSystem.FileSystem,
144 ServerConfig.ServerConfig,146 ServerConfig.ServerConfig,
145 ],147 ],
apps/server/src/mcp/toolkits/pullRequests/handlers.test.ts+8-1
...@@ -23,6 +23,8 @@ import {...@@ -23,6 +23,8 @@ import {
23} from "../../../orchestration-v2/testkit/pullRequestFixtures.ts";23} from "../../../orchestration-v2/testkit/pullRequestFixtures.ts";
24import * as ProjectService from "../../../project/ProjectService.ts";24import * as ProjectService from "../../../project/ProjectService.ts";
25import * as McpInvocationContext from "../../McpInvocationContext.ts";25import * as McpInvocationContext from "../../McpInvocationContext.ts";
26import * as McpToolAccess from "../../McpToolAccess.ts";
27import * as McpToolAccessTestkit from "../../McpToolAccess.testkit.ts";
26import { listThreadPullRequests } from "./handlers.ts";28import { listThreadPullRequests } from "./handlers.ts";
27import * as PullRequestsHandlers from "./handlers.ts";29import * as PullRequestsHandlers from "./handlers.ts";
28import { PullRequestLinkFailedError, PullRequestsToolkit } from "./tools.ts";30import { PullRequestLinkFailedError, PullRequestsToolkit } from "./tools.ts";
...@@ -163,9 +165,14 @@ const makeHarness = Effect.fn("makePullRequestsToolkitHarness")(function* (...@@ -163,9 +165,14 @@ const makeHarness = Effect.fn("makePullRequestsToolkitHarness")(function* (
163 dispatch,165 dispatch,
164 }),166 }),
165 Layer.succeed(Crypto.Crypto, testCrypto),167 Layer.succeed(Crypto.Crypto, testCrypto),
168 McpToolAccessTestkit.liveThreadsLayer,
166 );169 );
167 const toolkit = yield* PullRequestsToolkit.pipe(170 const toolkit = yield* PullRequestsToolkit.pipe(
168 Effect.provide(PullRequestsHandlers.layer.pipe(Layer.provide(layerDependencies))),171 Effect.provide(
172 McpToolAccess.HandlersLayer.layer(PullRequestsHandlers.layer).pipe(
173 Layer.provide(layerDependencies),
174 ),
175 ),
169 );176 );
170 const call = <Name extends keyof typeof PullRequestsToolkit.tools>(177 const call = <Name extends keyof typeof PullRequestsToolkit.tools>(
171 name: Name,178 name: Name,
apps/server/src/mcp/toolkits/pullRequests/handlers.ts+20-54
...@@ -26,7 +26,7 @@ import * as Option from "effect/Option";...@@ -26,7 +26,7 @@ import * as Option from "effect/Option";
26import * as Orchestrator from "../../../orchestration-v2/Orchestrator.ts";26import * as Orchestrator from "../../../orchestration-v2/Orchestrator.ts";
27import * as ProjectService from "../../../project/ProjectService.ts";27import * as ProjectService from "../../../project/ProjectService.ts";
28import * as McpInvocationContext from "../../McpInvocationContext.ts";28import * as McpInvocationContext from "../../McpInvocationContext.ts";
29import { assertTargetWithinLimits } from "../../threadAccess.ts";29import * as McpToolAccess from "../../McpToolAccess.ts";
30import {30import {
31 type ListThreadPullRequestsResult,31 type ListThreadPullRequestsResult,
32 PullRequestLinkFailedError,32 PullRequestLinkFailedError,
...@@ -40,7 +40,6 @@ import {...@@ -40,7 +40,6 @@ import {
40 PullRequestWatchFailedError,40 PullRequestWatchFailedError,
41 PullRequestWatchFromSubagentError,41 PullRequestWatchFromSubagentError,
42 PullRequestThreadNotFoundError,42 PullRequestThreadNotFoundError,
43 PullRequestThreadAboveLimitsError,
44 PullRequestThreadRequiredError,43 PullRequestThreadRequiredError,
45 PullRequestsToolkit,44 PullRequestsToolkit,
46 type ThreadPullRequestEntry,45 type ThreadPullRequestEntry,
...@@ -189,47 +188,6 @@ const make = Effect.gen(function* () {...@@ -189,47 +188,6 @@ const make = Effect.gen(function* () {
189 return thread.value;188 return thread.value;
190 });189 });
191190
192 /**
193 * A thread whose pull requests the caller may change: its own, or one that
194 * runs within the caller's modes.
195 */
196 const requireWritableThread = Effect.fn("PullRequestsToolkit.requireWritableThread")(function* (
197 Failure:
198 | typeof PullRequestLinkFailedError
199 | typeof PullRequestUnlinkFailedError
200 | typeof PullRequestWatchFailedError,
201 requested: ThreadId | undefined,
202 ) {
203 const thread = yield* requireThread(Failure, requested);
204 const scope = yield* McpInvocationContext.McpInvocationContext;
205 if (thread.id === scope.thread?.threadId) return thread;
206 const limits =
207 scope.thread === undefined
208 ? {
209 runtimeMode: scope.client?.runtimeModeCeiling ?? ("approval-required" as const),
210 interactionMode: "default" as const,
211 }
212 : yield* engine.getThreadShell(scope.thread.threadId).pipe(
213 Effect.mapError((cause) => new Failure({ cause })),
214 Effect.map((caller) =>
215 // A thread caller changes other threads only while its own run is live.
216 caller === null ||
217 caller.archivedAt !== null ||
218 caller.activeRunId === null ||
219 caller.providerInstanceId !== scope.thread?.providerInstanceId
220 ? undefined
221 : { runtimeMode: caller.runtimeMode, interactionMode: caller.interactionMode },
222 ),
223 );
224 if (limits === undefined) {
225 return yield* new PullRequestThreadAboveLimitsError({ threadId: thread.id });
226 }
227 yield* assertTargetWithinLimits(limits, thread).pipe(
228 Effect.mapError(() => new PullRequestThreadAboveLimitsError({ threadId: thread.id })),
229 );
230 return thread;
231 });
232
233 const projectOf = (191 const projectOf = (
234 thread: OrchestrationV2ThreadShell,192 thread: OrchestrationV2ThreadShell,
235 Failure:193 Failure:
...@@ -267,7 +225,7 @@ const make = Effect.gen(function* () {...@@ -267,7 +225,7 @@ const make = Effect.gen(function* () {
267 input: PullRequestTargetInput,225 input: PullRequestTargetInput,
268 watching: boolean,226 watching: boolean,
269 ) {227 ) {
270 const thread = yield* requireWritableThread(PullRequestWatchFailedError, input.threadId);228 const thread = yield* requireThread(PullRequestWatchFailedError, input.threadId);
271 const project = yield* projectOf(thread, PullRequestWatchFailedError);229 const project = yield* projectOf(thread, PullRequestWatchFailedError);
272 const target = yield* resolveTarget(input, project);230 const target = yield* resolveTarget(input, project);
273 const watchedLink = (shell: OrchestrationV2ThreadShell) =>231 const watchedLink = (shell: OrchestrationV2ThreadShell) =>
...@@ -306,10 +264,15 @@ const make = Effect.gen(function* () {...@@ -306,10 +264,15 @@ const make = Effect.gen(function* () {
306 };264 };
307 });265 });
308266
309 return PullRequestsToolkit.of({267 /** A tool that changes `threadId`, or the caller's own thread when it is omitted. */
310 link_pull_request: (input) =>268 const writesThread = <P extends { readonly threadId?: ThreadId | undefined }, A, E, R>(
269 handle: (params: P) => Effect.Effect<A, E, R>,
270 ) => McpToolAccess.writesThreads((params: P) => [params.threadId], handle);
271
272 return {
273 link_pull_request: writesThread((input) =>
311 Effect.gen(function* () {274 Effect.gen(function* () {
312 const thread = yield* requireWritableThread(PullRequestLinkFailedError, input.threadId);275 const thread = yield* requireThread(PullRequestLinkFailedError, input.threadId);
313 const project = yield* projectOf(thread, PullRequestLinkFailedError);276 const project = yield* projectOf(thread, PullRequestLinkFailedError);
314 const target = yield* resolveTarget(input, project);277 const target = yield* resolveTarget(input, project);
315 const existing = threadPullRequestsOf(thread).find((link) =>278 const existing = threadPullRequestsOf(thread).find((link) =>
...@@ -337,9 +300,10 @@ const make = Effect.gen(function* () {...@@ -337,9 +300,10 @@ const make = Effect.gen(function* () {
337 );300 );
338 return { ...target, alreadyLinked };301 return { ...target, alreadyLinked };
339 }),302 }),
340 unlink_pull_request: (input) =>303 ),
304 unlink_pull_request: writesThread((input) =>
341 Effect.gen(function* () {305 Effect.gen(function* () {
342 const thread = yield* requireWritableThread(PullRequestUnlinkFailedError, input.threadId);306 const thread = yield* requireThread(PullRequestUnlinkFailedError, input.threadId);
343 const project = yield* projectOf(thread, PullRequestUnlinkFailedError);307 const project = yield* projectOf(thread, PullRequestUnlinkFailedError);
344 const target = yield* resolveTarget(input, project);308 const target = yield* resolveTarget(input, project);
345 if (!threadPullRequestsOf(thread).some((link) => threadPullRequestKeysEqual(link, target)))309 if (!threadPullRequestsOf(thread).some((link) => threadPullRequestKeysEqual(link, target)))
...@@ -370,13 +334,15 @@ const make = Effect.gen(function* () {...@@ -370,13 +334,15 @@ const make = Effect.gen(function* () {
370 wasLinked,334 wasLinked,
371 };335 };
372 }),336 }),
373 list_thread_pull_requests: (input) =>337 ),
338 list_thread_pull_requests: McpToolAccess.reads((input) =>
374 requireThread(PullRequestListFailedError, input.threadId).pipe(339 requireThread(PullRequestListFailedError, input.threadId).pipe(
375 Effect.map(listThreadPullRequests),340 Effect.map(listThreadPullRequests),
376 ),341 ),
377 watch_pull_request: (input) => setWatching(input, true),342 ),
378 unwatch_pull_request: (input) => setWatching(input, false),343 watch_pull_request: writesThread((input) => setWatching(input, true)),
379 });344 unwatch_pull_request: writesThread((input) => setWatching(input, false)),
345 } satisfies McpToolAccess.Handlers<typeof PullRequestsToolkit.tools>;
380});346});
381347
382export const layer = PullRequestsToolkit.toLayer(make);348export const layer = McpToolAccess.toLayer(PullRequestsToolkit, make);
apps/server/src/mcp/toolkits/pullRequests/tools.ts+4-10
...@@ -1,5 +1,6 @@...@@ -1,5 +1,6 @@
1import {1import {
2 McpCapabilityUnavailableError,2 McpCapabilityUnavailableError,
3 OrchestratorMcpFailure,
3 PositiveInt,4 PositiveInt,
4 PullRequestState,5 PullRequestState,
5 ThreadPullRequestLinkSource,6 ThreadPullRequestLinkSource,
...@@ -12,10 +13,12 @@ import * as Toolkit from "effect/ai/Toolkit";...@@ -12,10 +13,12 @@ import * as Toolkit from "effect/ai/Toolkit";
1213
13import * as McpInvocationContext from "../../McpInvocationContext.ts";14import * as McpInvocationContext from "../../McpInvocationContext.ts";
14import * as Orchestrator from "../../../orchestration-v2/Orchestrator.ts";15import * as Orchestrator from "../../../orchestration-v2/Orchestrator.ts";
16import * as ThreadManagementService from "../../../orchestration-v2/ThreadManagementService.ts";
15import * as ProjectService from "../../../project/ProjectService.ts";17import * as ProjectService from "../../../project/ProjectService.ts";
1618
17const dependencies = [19const dependencies = [
18 McpInvocationContext.McpInvocationContext,20 McpInvocationContext.McpInvocationContext,
21 ThreadManagementService.ThreadManagementService,
19 Orchestrator.OrchestratorV2,22 Orchestrator.OrchestratorV2,
20 ProjectService.ProjectService,23 ProjectService.ProjectService,
21];24];
...@@ -96,15 +99,6 @@ export class PullRequestThreadRequiredError extends Schema.TaggedError<PullReque...@@ -96,15 +99,6 @@ export class PullRequestThreadRequiredError extends Schema.TaggedError<PullReque
96 }99 }
97}100}
98101
99export class PullRequestThreadAboveLimitsError extends Schema.TaggedError<PullRequestThreadAboveLimitsError>()(
100 "PullRequestThreadAboveLimitsError",
101 { threadId: Schema.String },
102) {
103 override get message(): string {
104 return `Thread ${this.threadId} cannot be changed from here: it runs with broader permissions than this caller, or the calling thread has no active run.`;
105 }
106}
107
108export class PullRequestThreadNotFoundError extends Schema.TaggedError<PullRequestThreadNotFoundError>()(102export class PullRequestThreadNotFoundError extends Schema.TaggedError<PullRequestThreadNotFoundError>()(
109 "PullRequestThreadNotFoundError",103 "PullRequestThreadNotFoundError",
110 { threadId: Schema.String },104 { threadId: Schema.String },
...@@ -169,12 +163,12 @@ export class PullRequestListFailedError extends Schema.TaggedError<PullRequestLi...@@ -169,12 +163,12 @@ export class PullRequestListFailedError extends Schema.TaggedError<PullRequestLi
169}163}
170164
171export const PullRequestToolError = Schema.Union([165export const PullRequestToolError = Schema.Union([
166 OrchestratorMcpFailure,
172 McpCapabilityUnavailableError,167 McpCapabilityUnavailableError,
173 PullRequestUrlInvalidError,168 PullRequestUrlInvalidError,
174 PullRequestTargetIncompleteError,169 PullRequestTargetIncompleteError,
175 PullRequestHostRequiredError,170 PullRequestHostRequiredError,
176 PullRequestThreadRequiredError,171 PullRequestThreadRequiredError,
177 PullRequestThreadAboveLimitsError,
178 PullRequestThreadNotFoundError,172 PullRequestThreadNotFoundError,
179 PullRequestLinkFailedError,173 PullRequestLinkFailedError,
180 PullRequestUnlinkFailedError,174 PullRequestUnlinkFailedError,
apps/server/src/mcp/toolkits/thread/handlers.ts+69-54
...@@ -10,13 +10,12 @@ import {...@@ -10,13 +10,12 @@ import {
10import * as Effect from "effect/Effect";10import * as Effect from "effect/Effect";
11import { modelSelectionCommandType } from "@t3tools/shared/model";11import { modelSelectionCommandType } from "@t3tools/shared/model";
1212
13import * as McpToolAccess from "../../McpToolAccess.ts";
13import {14import {
14 dispatchFailure,15 dispatchFailure,
15 newCommandId,16 newCommandId,
16 readCaller,17 readCaller,
17 readFullAccessCaller,
18 readThread,18 readThread,
19 readWritableThread,
20 unavailable,19 unavailable,
21} from "../../threadAccess.ts";20} from "../../threadAccess.ts";
22import * as ThreadSearch from "../../../orchestration-v2/ThreadSearch.ts";21import * as ThreadSearch from "../../../orchestration-v2/ThreadSearch.ts";
...@@ -43,23 +42,18 @@ const dispatch = Effect.fn("mcp.dispatchThreadCommand")(function* (...@@ -43,23 +42,18 @@ const dispatch = Effect.fn("mcp.dispatchThreadCommand")(function* (
43 threadId: ThreadId | undefined,42 threadId: ThreadId | undefined,
44 command: (common: { commandId: CommandId; threadId: ThreadId }) => OrchestrationV2Command,43 command: (common: { commandId: CommandId; threadId: ThreadId }) => OrchestrationV2Command,
45) {44) {
46 const { threads, projection } = yield* readWritableThread(threadId);45 const { threads, projection } = yield* readThread(threadId);
47 const result = yield* threads46 const result = yield* threads
48 .dispatch(command({ commandId: yield* newCommandId(), threadId: projection.thread.id }))47 .dispatch(command({ commandId: yield* newCommandId(), threadId: projection.thread.id }))
49 .pipe(Effect.mapError(dispatchFailure));48 .pipe(Effect.mapError(dispatchFailure));
50 return { sequence: result.sequence };49 return { sequence: result.sequence };
51});50});
5251
53const readQuestion = Effect.fn("mcp.readQuestion")(function* (52const readQuestion = Effect.fn("mcp.readQuestion")(function* (input: {
54 input: {53 threadId?: ThreadId | undefined;
55 threadId?: ThreadId | undefined;54 requestId: RuntimeRequestId;
56 requestId: RuntimeRequestId;55}) {
57 },56 const context = yield* readThread(input.threadId, ["runtimeRequests", "turnItems"]);
58 writable = false,
59) {
60 const context = yield* writable
61 ? readWritableThread(input.threadId, ["runtimeRequests", "turnItems"])
62 : readThread(input.threadId, ["runtimeRequests", "turnItems"]);
63 const request = context.projection.runtimeRequests.find(57 const request = context.projection.runtimeRequests.find(
64 (request) =>58 (request) =>
65 request.id === input.requestId &&59 request.id === input.requestId &&
...@@ -76,12 +70,14 @@ const readQuestion = Effect.fn("mcp.readQuestion")(function* (...@@ -76,12 +70,14 @@ const readQuestion = Effect.fn("mcp.readQuestion")(function* (
76 });70 });
77 return { ...context, request, item };71 return { ...context, request, item };
78});72});
79export const layer = ThreadToolkit.toLayer({73/** A tool that changes `threadId`, or the caller's own thread when it is omitted. */
80 run_scheduled_task_now: (input) =>74const writesThread = <P extends { readonly threadId?: ThreadId | undefined }, A, E, R>(
75 handle: (params: P) => Effect.Effect<A, E, R>,
76) => McpToolAccess.writesThreads((params: P) => [params.threadId], handle);
77
78export const layer = McpToolAccess.toLayer(ThreadToolkit, {
79 run_scheduled_task_now: McpToolAccess.writesEnvironment((input) =>
81 Effect.gen(function* () {80 Effect.gen(function* () {
82 yield* readFullAccessCaller(
83 "Running a scheduled task requires a live full-access/default thread or a full-access client.",
84 );
85 const scheduler = yield* ScheduledTasks.ScheduledTaskService;81 const scheduler = yield* ScheduledTasks.ScheduledTaskService;
86 const { tasks } = yield* scheduler.list().pipe(Effect.mapError(unavailable));82 const { tasks } = yield* scheduler.list().pipe(Effect.mapError(unavailable));
87 if (!tasks.some((task) => task.id === input.taskId))83 if (!tasks.some((task) => task.id === input.taskId))
...@@ -100,7 +96,8 @@ export const layer = ThreadToolkit.toLayer({...@@ -100,7 +96,8 @@ export const layer = ThreadToolkit.toLayer({
100 nextRunAt: task.nextRunAt,96 nextRunAt: task.nextRunAt,
101 };97 };
102 }),98 }),
103 t3_thread_search: (input) =>99 ),
100 t3_thread_search: McpToolAccess.reads((input) =>
104 Effect.gen(function* () {101 Effect.gen(function* () {
105 const { caller } = yield* readCaller();102 const { caller } = yield* readCaller();
106 const { projectId: requested, ...query } = input;103 const { projectId: requested, ...query } = input;
...@@ -116,9 +113,10 @@ export const layer = ThreadToolkit.toLayer({...@@ -116,9 +113,10 @@ export const layer = ThreadToolkit.toLayer({
116 : result.matches.filter((match) => match.projectId === projectId),113 : result.matches.filter((match) => match.projectId === projectId),
117 };114 };
118 }),115 }),
119 t3_thread_fork: (input) =>116 ),
117 t3_thread_fork: writesThread((input) =>
120 Effect.gen(function* () {118 Effect.gen(function* () {
121 const { threads, projection } = yield* readWritableThread(input.threadId);119 const { threads, projection } = yield* readThread(input.threadId);
122 const commandId = yield* newCommandId();120 const commandId = yield* newCommandId();
123 const targetThreadId = ThreadId.make(`${commandId}:fork`);121 const targetThreadId = ThreadId.make(`${commandId}:fork`);
124 const result = yield* threads122 const result = yield* threads
...@@ -135,24 +133,28 @@ export const layer = ThreadToolkit.toLayer({...@@ -135,24 +133,28 @@ export const layer = ThreadToolkit.toLayer({
135 .pipe(Effect.mapError(dispatchFailure));133 .pipe(Effect.mapError(dispatchFailure));
136 return { sequence: result.sequence, targetThreadId };134 return { sequence: result.sequence, targetThreadId };
137 }),135 }),
138 t3_thread_merge_back: (input) =>136 ),
139 Effect.gen(function* () {137 t3_thread_merge_back: McpToolAccess.writesThreads(
140 const context = yield* readWritableThread(input.targetThreadId);138 (input) => [input.targetThreadId, input.sourceThreadId],
141 const source = yield* readWritableThread(input.sourceThreadId);139 (input) =>
142 const result = yield* context.threads140 Effect.gen(function* () {
143 .dispatch({141 const context = yield* readThread(input.targetThreadId);
144 type: "thread.merge_back",142 const source = yield* readThread(input.sourceThreadId);
145 commandId: yield* newCommandId(),143 const result = yield* context.threads
146 sourceThreadId: source.projection.thread.id,144 .dispatch({
147 targetThreadId: input.targetThreadId,145 type: "thread.merge_back",
148 sourcePoint: input.sourcePoint,146 commandId: yield* newCommandId(),
149 createdBy: "agent",147 sourceThreadId: source.projection.thread.id,
150 creationSource: "mcp",148 targetThreadId: input.targetThreadId,
151 })149 sourcePoint: input.sourcePoint,
152 .pipe(Effect.mapError(dispatchFailure));150 createdBy: "agent",
153 return { sequence: result.sequence, targetThreadId: input.targetThreadId };151 creationSource: "mcp",
154 }),152 })
155 t3_thread_transfers: (input) =>153 .pipe(Effect.mapError(dispatchFailure));
154 return { sequence: result.sequence, targetThreadId: input.targetThreadId };
155 }),
156 ),
157 t3_thread_transfers: McpToolAccess.reads((input) =>
156 Effect.gen(function* () {158 Effect.gen(function* () {
157 const { projection } = yield* readThread(input.threadId, ["contextTransfers"]);159 const { projection } = yield* readThread(input.threadId, ["contextTransfers"]);
158 return {160 return {
...@@ -166,7 +168,8 @@ export const layer = ThreadToolkit.toLayer({...@@ -166,7 +168,8 @@ export const layer = ThreadToolkit.toLayer({
166 ),168 ),
167 };169 };
168 }),170 }),
169 t3_thread_configuration: (input) =>171 ),
172 t3_thread_configuration: McpToolAccess.reads((input) =>
170 Effect.gen(function* () {173 Effect.gen(function* () {
171 const {174 const {
172 projection: { thread },175 projection: { thread },
...@@ -178,12 +181,13 @@ export const layer = ThreadToolkit.toLayer({...@@ -178,12 +181,13 @@ export const layer = ThreadToolkit.toLayer({
178 interactionMode: thread.interactionMode,181 interactionMode: thread.interactionMode,
179 };182 };
180 }),183 }),
181 t3_thread_configure: (input) =>184 ),
185 t3_thread_configure: writesThread((input) =>
182 Effect.gen(function* () {186 Effect.gen(function* () {
183 const {187 const {
184 threads,188 threads,
185 projection: { thread },189 projection: { thread },
186 } = yield* readWritableThread(input.threadId);190 } = yield* readThread(input.threadId);
187 const type = modelSelectionCommandType(thread.providerInstanceId, input.modelSelection);191 const type = modelSelectionCommandType(thread.providerInstanceId, input.modelSelection);
188 const result = yield* threads192 const result = yield* threads
189 .dispatch({193 .dispatch({
...@@ -195,7 +199,8 @@ export const layer = ThreadToolkit.toLayer({...@@ -195,7 +199,8 @@ export const layer = ThreadToolkit.toLayer({
195 .pipe(Effect.mapError(dispatchFailure));199 .pipe(Effect.mapError(dispatchFailure));
196 return { sequence: result.sequence };200 return { sequence: result.sequence };
197 }),201 }),
198 t3_pending_request_list: (input) =>202 ),
203 t3_pending_request_list: McpToolAccess.reads((input) =>
199 Effect.gen(function* () {204 Effect.gen(function* () {
200 const { projection } = yield* readThread(input.threadId, ["runtimeRequests"]);205 const { projection } = yield* readThread(input.threadId, ["runtimeRequests"]);
201 return {206 return {
...@@ -204,14 +209,16 @@ export const layer = ThreadToolkit.toLayer({...@@ -204,14 +209,16 @@ export const layer = ThreadToolkit.toLayer({
204 .map((request) => request.id),209 .map((request) => request.id),
205 };210 };
206 }),211 }),
207 t3_pending_request_read: (input) =>212 ),
213 t3_pending_request_read: McpToolAccess.reads((input) =>
208 Effect.gen(function* () {214 Effect.gen(function* () {
209 const { item } = yield* readQuestion(input);215 const { item } = yield* readQuestion(input);
210 return { requestId: input.requestId, questions: item.questions };216 return { requestId: input.requestId, questions: item.questions };
211 }),217 }),
212 t3_pending_request_respond: (input) =>218 ),
219 t3_pending_request_respond: writesThread((input) =>
213 Effect.gen(function* () {220 Effect.gen(function* () {
214 const { threads, projection } = yield* readQuestion(input, true);221 const { threads, projection } = yield* readQuestion(input);
215 const result = yield* threads222 const result = yield* threads
216 .dispatch({223 .dispatch({
217 type: "runtime-request.respond",224 type: "runtime-request.respond",
...@@ -223,7 +230,8 @@ export const layer = ThreadToolkit.toLayer({...@@ -223,7 +230,8 @@ export const layer = ThreadToolkit.toLayer({
223 .pipe(Effect.mapError(dispatchFailure));230 .pipe(Effect.mapError(dispatchFailure));
224 return { sequence: result.sequence };231 return { sequence: result.sequence };
225 }),232 }),
226 t3_queue_list: (input) =>233 ),
234 t3_queue_list: McpToolAccess.reads((input) =>
227 Effect.gen(function* () {235 Effect.gen(function* () {
228 const { projection } = yield* readThread(input.threadId, ["runs", "messages"]);236 const { projection } = yield* readThread(input.threadId, ["runs", "messages"]);
229 const runs = queuedRunsInDeliveryOrder(projection);237 const runs = queuedRunsInDeliveryOrder(projection);
...@@ -237,7 +245,8 @@ export const layer = ThreadToolkit.toLayer({...@@ -237,7 +245,8 @@ export const layer = ThreadToolkit.toLayer({
237 nextCursor: end < runs.length ? end : null,245 nextCursor: end < runs.length ? end : null,
238 };246 };
239 }),247 }),
240 t3_queue_read: (input) =>248 ),
249 t3_queue_read: McpToolAccess.reads((input) =>
241 Effect.gen(function* () {250 Effect.gen(function* () {
242 const { projection } = yield* readThread(input.threadId, ["runs", "messages"]);251 const { projection } = yield* readThread(input.threadId, ["runs", "messages"]);
243 const entry = queueEntry(projection, input.queuedRunId, 16000);252 const entry = queueEntry(projection, input.queuedRunId, 16000);
...@@ -249,36 +258,41 @@ export const layer = ThreadToolkit.toLayer({...@@ -249,36 +258,41 @@ export const layer = ThreadToolkit.toLayer({
249 }))258 }))
250 );259 );
251 }),260 }),
252 t3_queue_edit: (input) =>261 ),
262 t3_queue_edit: writesThread((input) =>
253 dispatch(input.threadId, (common) => ({263 dispatch(input.threadId, (common) => ({
254 ...common,264 ...common,
255 type: "queued-run.edit",265 type: "queued-run.edit",
256 runId: input.queuedRunId,266 runId: input.queuedRunId,
257 text: input.text,267 text: input.text,
258 })),268 })),
259 t3_queue_cancel: (input) =>269 ),
270 t3_queue_cancel: writesThread((input) =>
260 dispatch(input.threadId, (common) => ({271 dispatch(input.threadId, (common) => ({
261 ...common,272 ...common,
262 type: "queued-run.cancel",273 type: "queued-run.cancel",
263 runId: input.queuedRunId,274 runId: input.queuedRunId,
264 })),275 })),
265 t3_queue_reorder: (input) =>276 ),
277 t3_queue_reorder: writesThread((input) =>
266 dispatch(input.threadId, (common) => ({278 dispatch(input.threadId, (common) => ({
267 ...common,279 ...common,
268 type: "queued-run.reorder",280 type: "queued-run.reorder",
269 runId: input.queuedRunId,281 runId: input.queuedRunId,
270 beforeRunId: input.beforeRunId,282 beforeRunId: input.beforeRunId,
271 })),283 })),
272 t3_queue_promote_to_steer: (input) =>284 ),
285 t3_queue_promote_to_steer: writesThread((input) =>
273 dispatch(input.threadId, (common) => ({286 dispatch(input.threadId, (common) => ({
274 ...common,287 ...common,
275 type: "queued-message.promote-to-steer",288 type: "queued-message.promote-to-steer",
276 queuedRunId: input.queuedRunId,289 queuedRunId: input.queuedRunId,
277 targetRunId: input.targetRunId,290 targetRunId: input.targetRunId,
278 })),291 })),
279 t3_thread_organize: (input) =>292 ),
293 t3_thread_organize: writesThread((input) =>
280 Effect.gen(function* () {294 Effect.gen(function* () {
281 const { threads, projection } = yield* readWritableThread(input.threadId);295 const { threads, projection } = yield* readThread(input.threadId);
282 const common = { commandId: yield* newCommandId(), threadId: projection.thread.id };296 const common = { commandId: yield* newCommandId(), threadId: projection.thread.id };
283 let command: OrchestrationV2Command;297 let command: OrchestrationV2Command;
284 switch (input.action) {298 switch (input.action) {
...@@ -304,4 +318,5 @@ export const layer = ThreadToolkit.toLayer({...@@ -304,4 +318,5 @@ export const layer = ThreadToolkit.toLayer({
304 const result = yield* threads.dispatch(command).pipe(Effect.mapError(dispatchFailure));318 const result = yield* threads.dispatch(command).pipe(Effect.mapError(dispatchFailure));
305 return { sequence: result.sequence };319 return { sequence: result.sequence };
306 }),320 }),
321 ),
307});322});
apps/server/src/mcp/toolkits/worktree/handlers.ts+9-5
...@@ -6,11 +6,12 @@ import { readThread, unavailable } from "../../threadAccess.ts";...@@ -6,11 +6,12 @@ import { readThread, unavailable } from "../../threadAccess.ts";
6import * as Effect from "effect/Effect";6import * as Effect from "effect/Effect";
77
8import * as McpInvocationContext from "../../McpInvocationContext.ts";8import * as McpInvocationContext from "../../McpInvocationContext.ts";
9import * as McpToolAccess from "../../McpToolAccess.ts";
9import * as WorktreeMcpService from "../../WorktreeMcpService.ts";10import * as WorktreeMcpService from "../../WorktreeMcpService.ts";
10import { WorktreeToolkit } from "./tools.ts";11import { WorktreeToolkit } from "./tools.ts";
1112
12const handlers = {13const handlers = {
13 t3_worktree_list: (input) =>14 t3_worktree_list: McpToolAccess.reads((input) =>
14 Effect.gen(function* () {15 Effect.gen(function* () {
15 const context = yield* McpInvocationContext.McpInvocationContext;16 const context = yield* McpInvocationContext.McpInvocationContext;
16 if (!context.capabilities.has("worktree"))17 if (!context.capabilities.has("worktree"))
...@@ -34,18 +35,21 @@ const handlers = {...@@ -34,18 +35,21 @@ const handlers = {
34 .listRefs({ ...refs, cwd: thread.worktreePath ?? project.value.workspaceRoot })35 .listRefs({ ...refs, cwd: thread.worktreePath ?? project.value.workspaceRoot })
35 .pipe(Effect.mapError(unavailable));36 .pipe(Effect.mapError(unavailable));
36 }),37 }),
37 t3_worktree_handoff: (input) =>38 ),
39 t3_worktree_handoff: McpToolAccess.actsAsCaller((input) =>
38 Effect.gen(function* () {40 Effect.gen(function* () {
39 const scope = yield* McpInvocationContext.McpInvocationContext;41 const scope = yield* McpInvocationContext.McpInvocationContext;
40 const service = yield* WorktreeMcpService.WorktreeMcpService;42 const service = yield* WorktreeMcpService.WorktreeMcpService;
41 return yield* service.handoff(scope, input);43 return yield* service.handoff(scope, input);
42 }),44 }),
43 t3_worktree_status: () =>45 ),
46 t3_worktree_status: McpToolAccess.readsAsCaller(() =>
44 Effect.gen(function* () {47 Effect.gen(function* () {
45 const scope = yield* McpInvocationContext.McpInvocationContext;48 const scope = yield* McpInvocationContext.McpInvocationContext;
46 const service = yield* WorktreeMcpService.WorktreeMcpService;49 const service = yield* WorktreeMcpService.WorktreeMcpService;
47 return yield* service.status(scope);50 return yield* service.status(scope);
48 }),51 }),
49} satisfies Parameters<typeof WorktreeToolkit.toLayer>[0];52 ),
53} satisfies McpToolAccess.Handlers<typeof WorktreeToolkit.tools>;
5054
51export const layer = WorktreeToolkit.toLayer(handlers);55export const layer = McpToolAccess.toLayer(WorktreeToolkit, handlers);
apps/server/src/mcp/toolkits/worktree/tools.ts+6-2
...@@ -19,15 +19,19 @@ import * as WorktreeMcpService from "../../WorktreeMcpService.ts";...@@ -19,15 +19,19 @@ import * as WorktreeMcpService from "../../WorktreeMcpService.ts";
1919
20const dependencies = [20const dependencies = [
21 McpInvocationContext.McpInvocationContext,21 McpInvocationContext.McpInvocationContext,
22 ThreadManagementService.ThreadManagementService,
22 WorktreeMcpService.WorktreeMcpService,23 WorktreeMcpService.WorktreeMcpService,
23];24];
2425
26/** What handoff and status fail with, including the access gate's refusal. */
27const WorktreeToolFailure = Schema.Union([WorktreeMcpFailure, OrchestratorMcpFailure]);
28
25const WorktreeHandoffTool = Tool.make("t3_worktree_handoff", {29const WorktreeHandoffTool = Tool.make("t3_worktree_handoff", {
26 description:30 description:
27 "Needs an agent running inside a T3 thread. Move this agent thread into a new git worktree. To launch a separate agent already bound to a new or existing worktree, use t3_thread_launch with workspaceStrategy instead. Creates the worktree branch (optionally from origin), re-points the thread at the worktree, and by default runs the project's setup script there. Changing the workspace detaches the live provider session, so the current turn ends shortly after the handoff is recorded; call this as the last action of the turn. To keep working after the handoff, pass continuationPrompt with the remaining work: it is queued as the thread's next message and starts a new turn inside the worktree with the conversation preserved. Without it the thread stays idle until the next message. The worktree is not removed automatically when the thread is deleted. Fails if the thread is already attached to a worktree.",31 "Needs an agent running inside a T3 thread. Move this agent thread into a new git worktree. To launch a separate agent already bound to a new or existing worktree, use t3_thread_launch with workspaceStrategy instead. Creates the worktree branch (optionally from origin), re-points the thread at the worktree, and by default runs the project's setup script there. Changing the workspace detaches the live provider session, so the current turn ends shortly after the handoff is recorded; call this as the last action of the turn. To keep working after the handoff, pass continuationPrompt with the remaining work: it is queued as the thread's next message and starts a new turn inside the worktree with the conversation preserved. Without it the thread stays idle until the next message. The worktree is not removed automatically when the thread is deleted. Fails if the thread is already attached to a worktree.",
28 parameters: WorktreeMcpHandoffInput,32 parameters: WorktreeMcpHandoffInput,
29 success: WorktreeMcpHandoffResult,33 success: WorktreeMcpHandoffResult,
30 failure: WorktreeMcpFailure,34 failure: WorktreeToolFailure,
31 failureMode: "return",35 failureMode: "return",
32 dependencies,36 dependencies,
33})37})
...@@ -45,7 +49,7 @@ const WorktreeStatusTool = Tool.make("t3_worktree_status", {...@@ -45,7 +49,7 @@ const WorktreeStatusTool = Tool.make("t3_worktree_status", {
45 // Schema.Struct({}) serializes to `anyOf: [object, array]`, which is not a49 // Schema.Struct({}) serializes to `anyOf: [object, array]`, which is not a
46 // valid MCP tool input schema and makes clients reject the whole server.50 // valid MCP tool input schema and makes clients reject the whole server.
47 success: WorktreeMcpStatusResult,51 success: WorktreeMcpStatusResult,
48 failure: WorktreeMcpFailure,52 failure: WorktreeToolFailure,
49 failureMode: "return",53 failureMode: "return",
50 dependencies,54 dependencies,
51})55})
apps/server/src/orchestration-v2/DispatchModeLimit.test.ts created+167
...@@ -0,0 +1,167 @@
1import { assert, it } from "@effect/vitest";
2import {
3 CommandId,
4 ProjectId,
5 ProviderDriverKind,
6 ProviderInstanceId,
7 ThreadId,
8} from "@t3tools/contracts";
9import * as Effect from "effect/Effect";
10import * as Layer from "effect/Layer";
11import * as SqlitePersistence from "../persistence/Sqlite.ts";
12import { CodexProviderCapabilitiesV2 } from "./Adapters/CodexAdapterV2.ts";
13import { DispatchModeLimit } from "./DispatchModeLimit.ts";
14import * as Orchestrator from "./Orchestrator.ts";
15import * as ProjectionStore from "./ProjectionStore.ts";
16import type { ProviderAdapterV2Shape } from "./ProviderAdapter.ts";
17import * as ProviderAdapterRegistry from "./ProviderAdapterRegistry.ts";
18import * as ProviderReplayHarness from "./testkit/ProviderReplayHarness.ts";
19
20const instanceId = ProviderInstanceId.make("codex");
21const adapter = {
22 instanceId,
23 driver: ProviderDriverKind.make("codex"),
24 getCapabilities: () => Effect.succeed(CodexProviderCapabilitiesV2),
25 planSelectionTransition: () => Effect.succeed({ type: "apply_on_next_turn" as const }),
26 openSession: () => Effect.die("No provider process needed for metadata commands"),
27} as ProviderAdapterV2Shape;
28const layerDatabase = SqlitePersistence.layerMemory;
29const layerTest = Layer.mergeAll(
30 layerDatabase,
31 ProjectionStore.layer.pipe(Layer.provide(layerDatabase)),
32 ProviderReplayHarness.layerWithRegistry(
33 { name: "dispatch-mode-limit" },
34 ProviderAdapterRegistry.layerFromAdapters([adapter]),
35 { databaseLayer: layerDatabase, runEffectWorker: false },
36 ),
37);
38
39const supervised = { runtimeMode: "approval-required", interactionMode: "default" } as const;
40
41const createThread = (threadId: ThreadId) =>
42 Effect.gen(function* () {
43 const orchestrator = yield* Orchestrator.OrchestratorV2;
44 yield* orchestrator.dispatch({
45 type: "thread.create",
46 commandId: CommandId.make(`create:${threadId}`),
47 threadId,
48 projectId: ProjectId.make("project:dispatch-mode-limit"),
49 title: "Before",
50 modelSelection: { instanceId, model: "gpt-5" },
51 ...supervised,
52 branch: null,
53 worktreePath: null,
54 createdBy: "user",
55 creationSource: "web",
56 });
57 });
58
59it.layer(layerTest)("DispatchModeLimit", (it) => {
60 it.effect("refuses a limited command on a thread its user raised, and records nothing", () =>
61 Effect.gen(function* () {
62 const orchestrator = yield* Orchestrator.OrchestratorV2;
63 const projections = yield* ProjectionStore.ProjectionStoreV2;
64 const threadId = ThreadId.make("thread:limit-raised");
65 yield* createThread(threadId);
66 // The user raises the thread; the user's own commands have no limit.
67 yield* orchestrator.dispatch({
68 type: "thread.runtime-mode.set",
69 commandId: CommandId.make("raise"),
70 threadId,
71 runtimeMode: "full-access",
72 });
73 const rename = {
74 type: "thread.metadata.update",
75 commandId: CommandId.make("rename"),
76 threadId,
77 title: "After",
78 } as const;
79 const refused = yield* orchestrator
80 .dispatch(rename)
81 .pipe(Effect.provideService(DispatchModeLimit, supervised), Effect.flip);
82 assert.equal(refused._tag, "OrchestratorThreadAboveModeLimitError");
83 assert.equal((yield* projections.getThreadShell(threadId))?.title, "Before");
84
85 // Refused before planning, so no receipt holds the command id: once the
86 // user lowers the thread again, the same command goes through.
87 yield* orchestrator.dispatch({
88 type: "thread.runtime-mode.set",
89 commandId: CommandId.make("lower"),
90 threadId,
91 runtimeMode: "approval-required",
92 });
93 yield* orchestrator
94 .dispatch(rename)
95 .pipe(Effect.provideService(DispatchModeLimit, supervised));
96 assert.equal((yield* projections.getThreadShell(threadId))?.title, "After");
97 }),
98 );
99
100 it.effect("names the interaction mode when that is what was raised", () =>
101 Effect.gen(function* () {
102 const orchestrator = yield* Orchestrator.OrchestratorV2;
103 const threadId = ThreadId.make("thread:limit-plan");
104 yield* createThread(threadId);
105 const refused = yield* orchestrator
106 .dispatch({
107 type: "thread.metadata.update",
108 commandId: CommandId.make("rename-plan"),
109 threadId,
110 title: "After",
111 })
112 .pipe(
113 Effect.provideService(DispatchModeLimit, {
114 runtimeMode: "full-access",
115 interactionMode: "plan",
116 }),
117 Effect.flip,
118 );
119 assert.ok(refused._tag === "OrchestratorThreadAboveModeLimitError");
120 assert.equal(refused.mode, "interaction");
121 }),
122 );
123
124 it.effect("refuses to fork a source thread its user raised", () =>
125 Effect.gen(function* () {
126 const orchestrator = yield* Orchestrator.OrchestratorV2;
127 const projections = yield* ProjectionStore.ProjectionStoreV2;
128 const sourceThreadId = ThreadId.make("thread:limit-fork-source");
129 const targetThreadId = ThreadId.make("thread:limit-fork-target");
130 yield* createThread(sourceThreadId);
131 yield* orchestrator.dispatch({
132 type: "thread.runtime-mode.set",
133 commandId: CommandId.make("raise-source"),
134 threadId: sourceThreadId,
135 runtimeMode: "full-access",
136 });
137 const fork = {
138 type: "thread.fork",
139 commandId: CommandId.make("fork"),
140 sourceThreadId,
141 targetThreadId,
142 sourcePoint: { type: "latest_stable" },
143 createdBy: "agent",
144 creationSource: "mcp",
145 } as const;
146 const refused = yield* orchestrator
147 .dispatch(fork)
148 .pipe(Effect.provideService(DispatchModeLimit, supervised), Effect.flip);
149 assert.ok(refused._tag === "OrchestratorThreadAboveModeLimitError");
150 assert.equal(refused.threadId, sourceThreadId);
151 assert.isNull(yield* projections.getThreadShell(targetThreadId));
152
153 // The refusal records no receipt: once the source is lowered, the same
154 // command is planned again (and fails only for want of a finished run).
155 yield* orchestrator.dispatch({
156 type: "thread.runtime-mode.set",
157 commandId: CommandId.make("lower-source"),
158 threadId: sourceThreadId,
159 runtimeMode: "approval-required",
160 });
161 const retried = yield* orchestrator
162 .dispatch(fork)
163 .pipe(Effect.provideService(DispatchModeLimit, supervised), Effect.flip);
164 assert.equal(retried._tag, "OrchestratorDispatchError");
165 }),
166 );
167});
apps/server/src/orchestration-v2/DispatchModeLimit.ts created+52
...@@ -0,0 +1,52 @@
1import type { ProviderInteractionMode, RuntimeMode, ThreadId } from "@t3tools/contracts";
2import * as Context from "effect/Context";
3import type * as Ref from "effect/Ref";
4
5export interface DispatchModes {
6 readonly runtimeMode: RuntimeMode;
7 readonly interactionMode: ProviderInteractionMode;
8}
9
10/** A thread the orchestrator refused to touch because it ran above the limit. */
11export interface DispatchModeRefusal extends DispatchModes {
12 readonly threadId: ThreadId;
13 readonly mode: "runtime" | "interaction";
14}
15
16/**
17 * The broadest modes a command may touch, for commands an MCP caller sends.
18 * A caller checks its target's modes before dispatching, but the target's
19 * user can raise them meanwhile; the orchestrator re-checks this limit inside
20 * the thread's command lock, where nothing else can change them. Unset for
21 * the user's own commands, which have no limit.
22 *
23 * The orchestrator also records its refusal in `refused`, so the sender can
24 * report it however the code in between wrapped the dispatch error.
25 */
26export interface DispatchModeLimitValue extends DispatchModes {
27 readonly refused?: Ref.Ref<DispatchModeRefusal | undefined>;
28}
29
30export const DispatchModeLimit = Context.Reference<DispatchModeLimitValue | undefined>(
31 "t3/orchestration-v2/DispatchModeLimit",
32 { defaultValue: () => undefined },
33);
34
35const runtimeModeRank: Record<RuntimeMode, number> = {
36 "approval-required": 0,
37 "auto-accept-edits": 1,
38 auto: 2,
39 "full-access": 3,
40};
41const interactionModeRank: Record<ProviderInteractionMode, number> = { plan: 0, default: 1 };
42
43/** Which of `modes` is broader than `limit`, if either. */
44export const exceededDispatchModeLimit = (
45 limit: DispatchModes,
46 modes: DispatchModes,
47): "runtime" | "interaction" | undefined =>
48 runtimeModeRank[modes.runtimeMode] > runtimeModeRank[limit.runtimeMode]
49 ? "runtime"
50 : interactionModeRank[modes.interactionMode] > interactionModeRank[limit.interactionMode]
51 ? "interaction"
52 : undefined;
apps/server/src/orchestration-v2/Orchestrator.ts+69
...@@ -46,8 +46,10 @@ import {...@@ -46,8 +46,10 @@ import {
46 latestProviderTurnForAttempt,46 latestProviderTurnForAttempt,
47 orchestrationV2RunWorkStartedAt,47 orchestrationV2RunWorkStartedAt,
48 ProviderInstanceId,48 ProviderInstanceId,
49 ProviderInteractionMode,
49 type ProviderSessionId,50 type ProviderSessionId,
50 RunId,51 RunId,
52 RuntimeMode,
51 ThreadLinkedPullRequest,53 ThreadLinkedPullRequest,
52 ThreadId,54 ThreadId,
53 type TurnItemId,55 type TurnItemId,
...@@ -86,6 +88,7 @@ import * as EffectOutbox from "./EffectOutbox.ts";...@@ -86,6 +88,7 @@ import * as EffectOutbox from "./EffectOutbox.ts";
86import type { OrchestrationEffectRequestV2, PendingOrchestrationEffectV2 } from "./EffectOutbox.ts";88import type { OrchestrationEffectRequestV2, PendingOrchestrationEffectV2 } from "./EffectOutbox.ts";
87import { IdAllocatorV2 } from "./IdAllocator.ts";89import { IdAllocatorV2 } from "./IdAllocator.ts";
88import * as ThreadCommandExecutor from "./ThreadCommandExecutor.ts";90import * as ThreadCommandExecutor from "./ThreadCommandExecutor.ts";
91import { DispatchModeLimit, exceededDispatchModeLimit } from "./DispatchModeLimit.ts";
89import {92import {
90 applyToProjection,93 applyToProjection,
91 emptyProjection,94 emptyProjection,
...@@ -187,6 +190,22 @@ export class OrchestratorSubagentThreadReadOnlyError extends Schema.TaggedError<...@@ -187,6 +190,22 @@ export class OrchestratorSubagentThreadReadOnlyError extends Schema.TaggedError<
187 }190 }
188}191}
189192
193/** The command's thread runs above the modes its sender may touch (see `DispatchModeLimit`). */
194export class OrchestratorThreadAboveModeLimitError extends Schema.TaggedError<OrchestratorThreadAboveModeLimitError>()(
195 "OrchestratorThreadAboveModeLimitError",
196 {
197 commandId: CommandId,
198 threadId: ThreadId,
199 mode: Schema.Literals(["runtime", "interaction"]),
200 runtimeMode: RuntimeMode,
201 interactionMode: ProviderInteractionMode,
202 },
203) {
204 override get message(): string {
205 return `Thread ${this.threadId} now runs in ${this.runtimeMode}/${this.interactionMode} mode, above what this caller may change.`;
206 }
207}
208
190export class OrchestratorCommandPreviouslyRejectedError extends Schema.TaggedError<OrchestratorCommandPreviouslyRejectedError>()(209export class OrchestratorCommandPreviouslyRejectedError extends Schema.TaggedError<OrchestratorCommandPreviouslyRejectedError>()(
191 "OrchestratorCommandPreviouslyRejectedError",210 "OrchestratorCommandPreviouslyRejectedError",
192 {211 {
...@@ -236,6 +255,7 @@ export const OrchestratorV2Error = Schema.Union([...@@ -236,6 +255,7 @@ export const OrchestratorV2Error = Schema.Union([
236 OrchestratorCommandPreviouslyRejectedError,255 OrchestratorCommandPreviouslyRejectedError,
237 OrchestratorCommandIdConflictError,256 OrchestratorCommandIdConflictError,
238 OrchestratorSubagentThreadReadOnlyError,257 OrchestratorSubagentThreadReadOnlyError,
258 OrchestratorThreadAboveModeLimitError,
239]);259]);
240export type OrchestratorV2Error = typeof OrchestratorV2Error.Type;260export type OrchestratorV2Error = typeof OrchestratorV2Error.Type;
241261
...@@ -3418,6 +3438,33 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio...@@ -3418,6 +3438,33 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
3418 },3438 },
3419 );3439 );
34203440
3441 /** Fails when a limited sender's command would touch a thread running above its limit. */
3442 const refuseAboveDispatchModeLimit = Effect.fn("orchestrationV2.dispatch.refuseAboveModeLimit")(
3443 function* (
3444 command: OrchestrationV2ServerCommand,
3445 threadId: ThreadId,
3446 modes: {
3447 readonly runtimeMode: RuntimeMode;
3448 readonly interactionMode: ProviderInteractionMode;
3449 },
3450 ) {
3451 const limit = yield* DispatchModeLimit;
3452 const exceeded = limit === undefined ? undefined : exceededDispatchModeLimit(limit, modes);
3453 if (limit === undefined || exceeded === undefined) return;
3454 const refusal = {
3455 threadId,
3456 mode: exceeded,
3457 runtimeMode: modes.runtimeMode,
3458 interactionMode: modes.interactionMode,
3459 };
3460 if (limit.refused !== undefined) yield* Ref.set(limit.refused, refusal);
3461 return yield* new OrchestratorThreadAboveModeLimitError({
3462 commandId: command.commandId,
3463 ...refusal,
3464 });
3465 },
3466 );
3467
3421 const dispatchThreadFork = Effect.fn("orchestrationV2.dispatch.threadFork")(function* (3468 const dispatchThreadFork = Effect.fn("orchestrationV2.dispatch.threadFork")(function* (
3422 command: Extract<OrchestrationV2Command, { readonly type: "thread.fork" }>,3469 command: Extract<OrchestrationV2Command, { readonly type: "thread.fork" }>,
3423 events: Ref.Ref<Array<OrchestrationV2DomainEvent>>,3470 events: Ref.Ref<Array<OrchestrationV2DomainEvent>>,
...@@ -3448,6 +3495,9 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio...@@ -3448,6 +3495,9 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
3448 }),3495 }),
3449 ),3496 ),
3450 );3497 );
3498 // The source is not under this command's lock, so check the modes this
3499 // command copies, not an earlier read the source's user could outrun.
3500 yield* refuseAboveDispatchModeLimit(command, command.sourceThreadId, sourceProjection.thread);
34513501
3452 const sourceRun = runForSourcePoint(sourceProjection, command.sourcePoint);3502 const sourceRun = runForSourcePoint(sourceProjection, command.sourcePoint);
34533503
...@@ -3536,6 +3586,9 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio...@@ -3536,6 +3586,9 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
3536 }),3586 }),
3537 ),3587 ),
3538 );3588 );
3589 // The source is not under this command's lock, so check the modes this
3590 // command copies, not an earlier read the source's user could outrun.
3591 yield* refuseAboveDispatchModeLimit(command, command.sourceThreadId, sourceProjection.thread);
3539 const targetProjection = yield* projectionStore3592 const targetProjection = yield* projectionStore
3540 .getThreadRecords(command.targetThreadId, ["contextTransfers"])3593 .getThreadRecords(command.targetThreadId, ["contextTransfers"])
3541 .pipe(3594 .pipe(
...@@ -10404,6 +10457,19 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio...@@ -10404,6 +10457,19 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
10404 } satisfies OrchestratorV2DispatchResult;10457 } satisfies OrchestratorV2DispatchResult;
10405 }10458 }
1040610459
10460 // A limited sender checked these modes before dispatching; the thread's
10461 // user may have raised them since, and only here can they not change.
10462 const limit = yield* DispatchModeLimit;
10463 if (limit !== undefined) {
10464 // A fork or merge-back source is not under this lock: its dispatch
10465 // checks the copy it reads instead.
10466 const threadId = commandThreadId(command);
10467 const shell = yield* projectionStore
10468 .getThreadShell(threadId)
10469 .pipe(Effect.mapError((cause) => new OrchestratorProjectionError({ threadId, cause })));
10470 if (shell !== null) yield* refuseAboveDispatchModeLimit(command, threadId, shell);
10471 }
10472
10407 const plan = yield* dispatchOnce(command).pipe(10473 const plan = yield* dispatchOnce(command).pipe(
10408 Effect.flatMap((planned) =>10474 Effect.flatMap((planned) =>
10409 // A settle that finds the provider already ended everything, or a10475 // A settle that finds the provider already ended everything, or a
...@@ -10423,6 +10489,9 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio...@@ -10423,6 +10489,9 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
10423 ),10489 ),
10424 Effect.catch((cause) =>10490 Effect.catch((cause) =>
10425 Effect.gen(function* () {10491 Effect.gen(function* () {
10492 // Refused like the check above: nothing recorded, so the same command
10493 // can go through once the thread's user lowers it again.
10494 if (cause._tag === "OrchestratorThreadAboveModeLimitError") return yield* cause;
10426 const rejectedAt = yield* DateTime.now;10495 const rejectedAt = yield* DateTime.now;
10427 const receipt = yield* eventSink10496 const receipt = yield* eventSink
10428 .commitRejectedCommand({10497 .commitRejectedCommand({
apps/server/src/orchestration-v2/ThreadMessageIntake.ts+1
...@@ -23,6 +23,7 @@ function dispatchWasNotAccepted(...@@ -23,6 +23,7 @@ function dispatchWasNotAccepted(
23 case "OrchestratorCommandPreviouslyRejectedError":23 case "OrchestratorCommandPreviouslyRejectedError":
24 case "OrchestratorCommandIdConflictError":24 case "OrchestratorCommandIdConflictError":
25 case "OrchestratorSubagentThreadReadOnlyError":25 case "OrchestratorSubagentThreadReadOnlyError":
26 case "OrchestratorThreadAboveModeLimitError":
26 return true;27 return true;
27 default:28 default:
28 return false;29 return false;
apps/server/src/provider/T3OrchestrationInstructions.ts+1-1
...@@ -20,7 +20,7 @@ For independent implementation or a PR stack in its own worktree, use \`t3_threa...@@ -20,7 +20,7 @@ For independent implementation or a PR stack in its own worktree, use \`t3_threa
20- Existing worktree: \`{"title":"Continue cleanup","workspaceStrategy":{"type":"existing_worktree","worktreePath":"/absolute/path/to/worktree","branch":"feature/ui-cleanup"},"message":"Continue the cleanup."}\`20- Existing worktree: \`{"title":"Continue cleanup","workspaceStrategy":{"type":"existing_worktree","worktreePath":"/absolute/path/to/worktree","branch":"feature/ui-cleanup"},"message":"Continue the cleanup."}\`
21- Project's main checkout: \`workspaceStrategy:{"type":"root"}\`. Omitting workspaceStrategy also selects root; it does not inherit the caller's worktree.21- Project's main checkout: \`workspaceStrategy:{"type":"root"}\`. Omitting workspaceStrategy also selects root; it does not inherit the caller's worktree.
2222
23For stacked work, set \`baseRef\` to the intended parent branch and \`startFromOrigin:false\` to use its local commits. Use \`startFromOrigin:true\` when you intend to fetch and start from origin. Uncommitted edits are not copied. Use \`t3_worktree_list\` to discover existing checkout paths. Project, model selection, and modes inherit unless supplied; launch requires a full-access/default caller.23For stacked work, set \`baseRef\` to the intended parent branch and \`startFromOrigin:false\` to use its local commits. Use \`startFromOrigin:true\` when you intend to fetch and start from origin. Uncommitted edits are not copied. Use \`t3_worktree_list\` to discover existing checkout paths. Project, model selection, and modes inherit unless supplied; a launched thread may not run with broader modes than yours.
2424
25\`t3_thread_launch\` is the single-thread launch tool. Use \`create_threads\` only for a batch of threads intentionally sharing the caller's checkout: it always inherits the caller's project, branch, and worktree and has no workspace override. Asking an agent to run \`git worktree add\` or \`cd\` in its prompt does not update T3's thread binding. Select the workspace in the launch call instead. \`t3_worktree_handoff\` moves the calling thread, not another thread, and cannot move a thread already attached to a worktree.25\`t3_thread_launch\` is the single-thread launch tool. Use \`create_threads\` only for a batch of threads intentionally sharing the caller's checkout: it always inherits the caller's project, branch, and worktree and has no workspace override. Asking an agent to run \`git worktree add\` or \`cd\` in its prompt does not update T3's thread binding. Select the workspace in the launch call instead. \`t3_worktree_handoff\` moves the calling thread, not another thread, and cannot move a thread already attached to a worktree.
2626
apps/server/src/vcs/GitVcsDriver.ts+5
...@@ -398,6 +398,11 @@ export class GitVcsDriver extends Context.Service<...@@ -398,6 +398,11 @@ export class GitVcsDriver extends Context.Service<
398 readonly pruneWorktrees: (input: {398 readonly pruneWorktrees: (input: {
399 readonly cwd: string;399 readonly cwd: string;
400 }) => Effect.Effect<void, GitCommandError>;400 }) => Effect.Effect<void, GitCommandError>;
401 /**
402 * Absolute paths of every live worktree of the repository at `cwd`, the
403 * main checkout included. Worktrees whose directory is gone are left out.
404 */
405 readonly listWorktreePaths: (cwd: string) => Effect.Effect<string[], GitCommandError>;
401 readonly deleteLocalBranch: (406 readonly deleteLocalBranch: (
402 input: GitDeleteLocalBranchInput,407 input: GitDeleteLocalBranchInput,
403 ) => Effect.Effect<void, GitCommandError>;408 ) => Effect.Effect<void, GitCommandError>;
apps/server/src/vcs/GitVcsDriverCore.ts+21
...@@ -3954,6 +3954,26 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*...@@ -3954,6 +3954,26 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
3954 }),3954 }),
3955 );3955 );
39563956
3957 const listWorktreePaths: GitVcsDriver.GitVcsDriver["Service"]["listWorktreePaths"] = (cwd) =>
3958 runGitStdout("GitVcsDriver.listWorktreePaths", cwd, [
3959 "worktree",
3960 "list",
3961 "--porcelain",
3962 "-z",
3963 ]).pipe(
3964 // One record per worktree, each ended by an empty field. A `prunable`
3965 // record's directory is gone, and another checkout may now sit there.
3966 Effect.map((stdout) =>
3967 stdout.split("\0\0").flatMap((record) => {
3968 const fields = record.split("\0");
3969 const worktree = fields.find((field) => field.startsWith("worktree "));
3970 return worktree === undefined || fields.some((field) => field.startsWith("prunable"))
3971 ? []
3972 : [path.resolve(cwd, worktree.slice("worktree ".length))];
3973 }),
3974 ),
3975 );
3976
3957 const withListRefsInvalidation = <A, E>(3977 const withListRefsInvalidation = <A, E>(
3958 cwd: string,3978 cwd: string,
3959 effect: Effect.Effect<A, E>,3979 effect: Effect.Effect<A, E>,
...@@ -4024,5 +4044,6 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*...@@ -4024,5 +4044,6 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
4024 switchRef: (input) => withListRefsInvalidation(input.cwd, switchRef(input)),4044 switchRef: (input) => withListRefsInvalidation(input.cwd, switchRef(input)),
4025 initRepo: initRepoWithListRefsInvalidation,4045 initRepo: initRepoWithListRefsInvalidation,
4026 listLocalBranchNames,4046 listLocalBranchNames,
4047 listWorktreePaths,
4027 });4048 });
4028});4049});
docs/orchestration-v2/orchestrator-mcp-server.md+2-2
...@@ -325,8 +325,8 @@ this binding....@@ -325,8 +325,8 @@ this binding.
325Pass the task in `message`. Project, model, and modes inherit when omitted;325Pass the task in `message`. Project, model, and modes inherit when omitted;
326workspace does not. `scratch: true` launches without a project, in a folder of326workspace does not. `scratch: true` launches without a project, in a folder of
327its own under the environment's Scratch project. For stacked PRs, use the parent branch as `baseRef` with327its own under the environment's Scratch project. For stacked PRs, use the parent branch as `baseRef` with
328`startFromOrigin: false`. Launch requires a full-access/default caller and has328`startFromOrigin: false`. The new thread may not run with broader runtime or
329no retry key, so inspect existing threads after a failed or lost response before329interaction modes than the caller. Launch has no retry key, so inspect existing threads after a failed or lost response before
330launching again. `create_threads` remains the batch option for a shared checkout.330launching again. `create_threads` remains the batch option for a shared checkout.
331331
332### `t3_thread_list`332### `t3_thread_list`
oxlint-plugin-t3code/index.ts+2
...@@ -7,6 +7,7 @@ import noInlineSchemaCompile from "./rules/no-inline-schema-compile.ts";...@@ -7,6 +7,7 @@ import noInlineSchemaCompile from "./rules/no-inline-schema-compile.ts";
7import noManualEffectRuntimeInTests from "./rules/no-manual-effect-runtime-in-tests.ts";7import noManualEffectRuntimeInTests from "./rules/no-manual-effect-runtime-in-tests.ts";
8import noMobileUniwindThemeEscapeHatches from "./rules/no-mobile-uniwind-theme-escape-hatches.ts";8import noMobileUniwindThemeEscapeHatches from "./rules/no-mobile-uniwind-theme-escape-hatches.ts";
9import noNativeTitleTooltip from "./rules/no-native-title-tooltip.ts";9import noNativeTitleTooltip from "./rules/no-native-title-tooltip.ts";
10import noRawMcpRegistration from "./rules/no-raw-mcp-registration.ts";
10import noTestInLoop from "./rules/no-test-in-loop.ts";11import noTestInLoop from "./rules/no-test-in-loop.ts";
11import noUnscopedHas from "./rules/no-unscoped-has.ts";12import noUnscopedHas from "./rules/no-unscoped-has.ts";
12import preferCatchTags from "./rules/prefer-catch-tags.ts";13import preferCatchTags from "./rules/prefer-catch-tags.ts";
...@@ -24,6 +25,7 @@ export default definePlugin({...@@ -24,6 +25,7 @@ export default definePlugin({
24 "no-manual-effect-runtime-in-tests": noManualEffectRuntimeInTests,25 "no-manual-effect-runtime-in-tests": noManualEffectRuntimeInTests,
25 "no-mobile-uniwind-theme-escape-hatches": noMobileUniwindThemeEscapeHatches,26 "no-mobile-uniwind-theme-escape-hatches": noMobileUniwindThemeEscapeHatches,
26 "no-native-title-tooltip": noNativeTitleTooltip,27 "no-native-title-tooltip": noNativeTitleTooltip,
28 "no-raw-mcp-registration": noRawMcpRegistration,
27 "no-test-in-loop": noTestInLoop,29 "no-test-in-loop": noTestInLoop,
28 "no-unscoped-has": noUnscopedHas,30 "no-unscoped-has": noUnscopedHas,
29 "prefer-catch-tags": preferCatchTags,31 "prefer-catch-tags": preferCatchTags,
oxlint-plugin-t3code/rules/no-raw-mcp-registration.test.ts created+185
...@@ -0,0 +1,185 @@
1import { assert, describe } from "@effect/vitest";
2
3import { createOxlintRuleHarness } from "../test/utils.ts";
4
5const rule = createOxlintRuleHarness("t3code/no-raw-mcp-registration");
6const testFile = createOxlintRuleHarness("t3code/no-raw-mcp-registration", {
7 filename: "server.test.ts",
8});
9
10describe("t3code/no-raw-mcp-registration", () => {
11 rule.valid(
12 "allows the rest of effect/ai and type-only McpServer imports",
13 `
14 import { McpSchema, Tool, Toolkit } from "effect/ai";
15 import type { McpServer } from "effect/ai";
16 import { type McpServer as Server } from "effect/ai";
17 export type Service = McpServer.McpServer | Server.McpServer;
18 export const used = [McpSchema, Tool, Toolkit];
19 `,
20 );
21
22 rule.invalid(
23 "reports importing McpServer from effect/ai",
24 `
25 import { McpServer } from "effect/ai";
26 export const server = McpServer.McpServer;
27 `,
28 (output) => {
29 assert.match(output, /Only McpHttpServer may use Effect's McpServer/);
30 },
31 );
32
33 rule.invalid(
34 "reports importing McpServer under another name",
35 `
36 import { McpServer as Server } from "effect/ai";
37 export const registration = Server.toolkit(SomeToolkit);
38 `,
39 );
40
41 rule.invalid(
42 "reports a namespace import of effect/ai",
43 `
44 import * as Ai from "effect/ai";
45 export const registration = Ai.McpServer.toolkit(SomeToolkit);
46 `,
47 );
48
49 rule.invalid(
50 "reports importing the McpServer module directly",
51 `
52 import { toolkit } from "effect/ai/McpServer";
53 export const registration = toolkit(SomeToolkit);
54 `,
55 );
56
57 rule.invalid(
58 "reports re-exporting McpServer",
59 `
60 export { McpServer as Server } from "effect/ai";
61 `,
62 );
63
64 rule.invalid(
65 "reports re-exporting all of effect/ai",
66 `
67 export * from "effect/ai";
68 `,
69 );
70
71 rule.invalid(
72 "reports a dynamic import of the McpServer module",
73 `
74 export const load = () => import("effect/ai/McpServer");
75 `,
76 );
77
78 rule.invalid(
79 "reports a dynamic import written as a template",
80 `
81 export const load = () => import(\`effect/ai/McpServer\`);
82 `,
83 );
84
85 rule.invalid(
86 "reports McpServer.toolkit",
87 `
88 export const registration = McpServer.toolkit(SomeToolkit);
89 `,
90 (output) => {
91 assert.match(output, /McpServer\.toolkit registers on \/mcp/);
92 },
93 );
94
95 rule.invalid(
96 "reports McpServer.resource and McpServer.prompt",
97 `
98 export const resource = McpServer.resource({ uri: "t3://x", name: "x", content: "x" });
99 export const prompt = McpServer.prompt({ name: "x", content: () => "x" });
100 `,
101 );
102
103 rule.invalid(
104 "reports addTool on the McpServer service",
105 `
106 import * as Effect from "effect/Effect";
107 export const register = Effect.gen(function* () {
108 const server = yield* Service;
109 yield* server.addTool({ tool, annotations, handle: () => Effect.die("unchecked") });
110 });
111 `,
112 (output) => {
113 assert.match(output, /\.addTool registers on \/mcp/);
114 },
115 );
116
117 rule.invalid(
118 "reports a registration method read without calling it",
119 `
120 export const add = server.addTool;
121 `,
122 );
123
124 rule.invalid(
125 "reports a registration function taken by destructuring",
126 `
127 const { toolkit } = McpServer;
128 export const registration = toolkit(SomeToolkit);
129 `,
130 );
131
132 rule.invalid(
133 "reports a registration method taken by destructuring",
134 `
135 import * as Effect from "effect/Effect";
136 export const register = Effect.gen(function* () {
137 const { addTool } = yield* Service;
138 yield* addTool({ tool, annotations, handle: () => Effect.die("unchecked") });
139 });
140 `,
141 );
142
143 testFile.valid(
144 "lets tests import McpServer to build a server",
145 `
146 import { McpServer } from "effect/ai";
147 export const layer = McpServer.McpServer.layer;
148 `,
149 );
150
151 testFile.invalid(
152 "reports a registration in a test",
153 `
154 import { McpServer } from "effect/ai";
155 export const registration = McpServer.toolkit(SomeToolkit);
156 `,
157 );
158
159 testFile.invalid(
160 "reports a test importing McpServer under another name",
161 `
162 import { McpServer as Server } from "effect/ai";
163 export const registration = Server.toolkit(SomeToolkit);
164 `,
165 (output) => {
166 assert.match(output, /Tests import McpServer only as/);
167 },
168 );
169
170 testFile.invalid(
171 "reports a test importing effect/ai as a namespace",
172 `
173 import * as Ai from "effect/ai";
174 export const registration = Ai.McpServer.toolkit(SomeToolkit);
175 `,
176 );
177
178 testFile.invalid(
179 "reports a test importing the McpServer module directly",
180 `
181 import { toolkit } from "effect/ai/McpServer";
182 export const registration = toolkit(SomeToolkit);
183 `,
184 );
185});
oxlint-plugin-t3code/rules/no-raw-mcp-registration.ts created+148
...@@ -0,0 +1,148 @@
1import { defineRule } from "@oxlint/plugins";
2import * as Option from "effect/Option";
3
4import { getPropertyName, isIdentifier, unwrapExpression } from "../utils.ts";
5
6// Effect's MCP server, and the modules that re-export it.
7const MCP_SERVER_MODULE = "effect/ai/McpServer";
8const AI_MODULES = new Set(["effect/ai", "effect/ai/index"]);
9// Registering anything straight on the MCP server skips McpToolAccess, where
10// every T3 MCP tool declares who may call it. These are the module functions...
11const MODULE_REGISTRATIONS = new Set([
12 "toolkit",
13 "registerToolkit",
14 "resource",
15 "registerResource",
16 "prompt",
17 "registerPrompt",
18]);
19// ...and the methods on the McpServer service itself.
20const SERVICE_REGISTRATIONS = new Set([
21 "addTool",
22 "addResource",
23 "addResourceTemplate",
24 "addPrompt",
25]);
26const TEST_FILE_PATTERN = /\.(?:test|spec)\.[cm]?[jt]sx?$/u;
27
28const HOW =
29 "Build the handlers with McpToolAccess.toLayer and register them through McpHttpServer's toolkitRegistration.";
30const registrationMessage = (name: string) =>
31 `${name} registers on /mcp without the access checks McpToolAccess declares. ${HOW}`;
32const importMessage = `Only McpHttpServer may use Effect's McpServer: a registration anywhere else skips the access checks McpToolAccess declares. ${HOW}`;
33const testImportMessage =
34 'Tests import McpServer only as `import { McpServer } from "effect/ai"`, so a registration on it is reported.';
35
36/** A module specifier: a string, or a template with nothing substituted. */
37const literalString = (node: unknown): Option.Option<string> =>
38 Option.flatMap(unwrapExpression(node), (expression) => {
39 if (expression.type === "Literal" && typeof expression.value === "string") {
40 return Option.some(expression.value);
41 }
42 if (expression.type === "TemplateLiteral" && expression.expressions.length === 0) {
43 return Option.fromNullishOr(expression.quasis[0]?.value.cooked);
44 }
45 return Option.none();
46 });
47
48const namesMcpServer = (node: unknown) =>
49 Option.getOrUndefined(getPropertyName(node)) === "McpServer";
50
51const exposesMcpServer = (source: Option.Option<string>) =>
52 Option.isSome(source) && (source.value === MCP_SERVER_MODULE || AI_MODULES.has(source.value));
53
54/**
55 * Keeps every registration on `/mcp` inside McpHttpServer, whose helpers
56 * accept only handlers McpToolAccess built; the lint config exempts that file.
57 * Elsewhere it reports runtime imports of Effect's McpServer, under any name
58 * or path, and reading a registration method or function in any form. Tests
59 * may import McpServer to build a server, but only under its own name, so a
60 * registration on it is still reported. It guards against a registration that
61 * skips the checks by accident; deliberately working around it needs a
62 * reviewed change here.
63 */
64export default defineRule({
65 meta: {
66 type: "problem",
67 docs: {
68 description:
69 "Disallow registering on the MCP server without McpToolAccess; tools must declare who may call them.",
70 },
71 },
72 create(context) {
73 const isTest = TEST_FILE_PATTERN.test(context.filename);
74 return {
75 ImportDeclaration(node) {
76 if (node.importKind === "type") return;
77 const source = literalString(node.source);
78 if (Option.isNone(source)) return;
79 const exposes = node.specifiers.some((specifier) => {
80 if (specifier.type !== "ImportSpecifier") return exposesMcpServer(source);
81 if (specifier.importKind === "type") return false;
82 if (source.value === MCP_SERVER_MODULE) return true;
83 return (
84 AI_MODULES.has(source.value) &&
85 namesMcpServer(specifier.imported) &&
86 // A test's `McpServer.toolkit(...)` is reported below by name.
87 !(isTest && namesMcpServer(specifier.local))
88 );
89 });
90 if (exposes) context.report({ node, message: isTest ? testImportMessage : importMessage });
91 },
92 ExportNamedDeclaration(node) {
93 if (node.source === null || node.exportKind === "type") return;
94 const source = literalString(node.source);
95 if (Option.isNone(source)) return;
96 const exposes = node.specifiers.some(
97 (specifier) =>
98 specifier.exportKind !== "type" &&
99 (source.value === MCP_SERVER_MODULE ||
100 (AI_MODULES.has(source.value) && namesMcpServer(specifier.local))),
101 );
102 if (exposes) context.report({ node, message: importMessage });
103 },
104 ExportAllDeclaration(node) {
105 if (node.exportKind === "type") return;
106 if (exposesMcpServer(literalString(node.source))) {
107 context.report({ node, message: importMessage });
108 }
109 },
110 ImportExpression(node) {
111 if (exposesMcpServer(literalString(node.source))) {
112 context.report({ node, message: importMessage });
113 }
114 },
115 MemberExpression(node) {
116 const name = getPropertyName(node.property);
117 if (Option.isNone(name)) return;
118 if (SERVICE_REGISTRATIONS.has(name.value)) {
119 context.report({ node, message: registrationMessage(`.${name.value}`) });
120 } else if (
121 MODULE_REGISTRATIONS.has(name.value) &&
122 isIdentifier(unwrapExpression(node.object), "McpServer")
123 ) {
124 context.report({ node, message: registrationMessage(`McpServer.${name.value}`) });
125 }
126 },
127 // `const { addTool } = server`, `const { toolkit } = McpServer`
128 ObjectPattern(node) {
129 const fromMcpServer =
130 node.parent.type === "VariableDeclarator" &&
131 isIdentifier(unwrapExpression(node.parent.init), "McpServer");
132 for (const property of node.properties) {
133 if (property.type !== "Property") continue;
134 const name = getPropertyName(property.key);
135 if (Option.isNone(name)) continue;
136 if (SERVICE_REGISTRATIONS.has(name.value)) {
137 context.report({ node: property, message: registrationMessage(`.${name.value}`) });
138 } else if (fromMcpServer && MODULE_REGISTRATIONS.has(name.value)) {
139 context.report({
140 node: property,
141 message: registrationMessage(`McpServer.${name.value}`),
142 });
143 }
144 }
145 },
146 };
147 },
148});
vite.config.ts+6
...@@ -167,6 +167,7 @@ export default defineConfig({...@@ -167,6 +167,7 @@ export default defineConfig({
167 "t3code/no-inline-schema-compile": "warn",167 "t3code/no-inline-schema-compile": "warn",
168 "t3code/no-manual-effect-runtime-in-tests": "error",168 "t3code/no-manual-effect-runtime-in-tests": "error",
169 "t3code/no-native-title-tooltip": "error",169 "t3code/no-native-title-tooltip": "error",
170 "t3code/no-raw-mcp-registration": "error",
170 "t3code/no-test-in-loop": "error",171 "t3code/no-test-in-loop": "error",
171 "t3code/no-unscoped-has": "error",172 "t3code/no-unscoped-has": "error",
172 "t3code/namespace-node-imports": "error",173 "t3code/namespace-node-imports": "error",
...@@ -179,6 +180,11 @@ export default defineConfig({...@@ -179,6 +180,11 @@ export default defineConfig({
179 files: ["packages/shared/src/hostProcess.ts"],180 files: ["packages/shared/src/hostProcess.ts"],
180 rules: { "t3code/no-global-process-runtime": "off" },181 rules: { "t3code/no-global-process-runtime": "off" },
181 },182 },
183 {
184 // The registration helpers that only accept handlers built by McpToolAccess.
185 files: ["apps/server/src/mcp/McpHttpServer.ts"],
186 rules: { "t3code/no-raw-mcp-registration": "off" },
187 },
182 {188 {
183 files: ["apps/web/src/**"],189 files: ["apps/web/src/**"],
184 excludeFiles: ["apps/web/src/components/ui/**"],190 excludeFiles: ["apps/web/src/components/ui/**"],