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";
1414import * as DeviceService from "../device/DeviceService.ts";
1515import * as McpHttpServer from "./McpHttpServer.ts";
1616import * as McpInvocationContext from "./McpInvocationContext.ts";
17import * as McpToolAccessTestkit from "./McpToolAccess.testkit.ts";
1718
1819const environmentId = EnvironmentId.make("environment-device-test");
1920const threadId = ThreadId.make("thread-device-test");
......@@ -102,6 +103,7 @@ const layerDeviceServiceMock = Layer.mock(DeviceService.DeviceService)({
102103
103104const layerTest = McpHttpServer.layerDeviceToolkit.pipe(
104105 Layer.provideMerge(McpServer.McpServer.layer),
106 Layer.provideMerge(McpToolAccessTestkit.liveThreadsLayer),
105107 Layer.provideMerge(layerDeviceServiceMock),
106108 Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-mcp-device-toolkit-test-" })),
107109 Layer.provide(NodeServices.layer),
......@@ -172,6 +174,7 @@ it.effect("rejects unavailable agent access before booting or opening a device",
172174 Effect.provide(
173175 McpHttpServer.layerDeviceToolkit.pipe(
174176 Layer.provideMerge(McpServer.McpServer.layer),
177 Layer.provideMerge(McpToolAccessTestkit.liveThreadsLayer),
175178 Layer.provide(layerUnavailable),
176179 Layer.provide(NodeServices.layer),
177180 ),
apps/server/src/mcp/McpHttpServer.test.ts+3
......@@ -17,6 +17,7 @@ import { HttpBody, HttpClient, HttpRouter, HttpServerResponse } from "effect/htt
1717import * as ProjectService from "../project/ProjectService.ts";
1818import * as ServerConfig from "../config.ts";
1919import * as McpHttpServer from "./McpHttpServer.ts";
20import * as McpToolAccessTestkit from "./McpToolAccess.testkit.ts";
2021import * as McpInvocationContext from "./McpInvocationContext.ts";
2122import * as PreviewAutomationBroker from "./PreviewAutomationBroker.ts";
2223
......@@ -51,12 +52,14 @@ const client = McpSchema.McpServerClient.of({
5152});
5253const layerTest = McpHttpServer.layerPreviewToolkit.pipe(
5354 Layer.provideMerge(McpServer.McpServer.layer),
55 Layer.provideMerge(McpToolAccessTestkit.liveThreadsLayer),
5456 Layer.provideMerge(PreviewAutomationBroker.layer),
5557 Layer.provideMerge(ServerConfig.layerTest(process.cwd(), { prefix: "t3-mcp-http-server-test-" })),
5658 Layer.provideMerge(NodeServices.layer),
5759);
5860const layerPullRequestsTest = McpHttpServer.layerPullRequestsToolkit.pipe(
5961 Layer.provideMerge(McpServer.McpServer.layer),
62 Layer.provideMerge(McpToolAccessTestkit.liveThreadsLayer),
6063 Layer.provide(
6164 Layer.mergeAll(
6265 Layer.mock(ProjectService.ProjectService)({}),
apps/server/src/mcp/McpHttpServer.ts+68-36
......@@ -11,7 +11,7 @@ import * as Schema from "effect/Schema";
1111import * as Sink from "effect/Sink";
1212import * as Stream from "effect/Stream";
1313import 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";
1515import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/http";
1616import { OrchestratorMcpFailure, PreviewAutomationError } from "@t3tools/contracts";
1717
......@@ -19,7 +19,9 @@ import packageJson from "../../package.json" with { type: "json" };
1919import * as ServerConfig from "../config.ts";
2020import * as DeviceService from "../device/DeviceService.ts";
2121import * as HtmlRender from "../htmlRender/HtmlRender.ts";
22import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts";
2223import * as McpInvocationContext from "./McpInvocationContext.ts";
24import * as McpToolAccess from "./McpToolAccess.ts";
2325import * as OrchestratorMcpService from "./OrchestratorMcpService.ts";
2426import { PreviewControlsToolkit } from "./toolkits/previewControls/tools.ts";
2527import * as PreviewControlsHandlers from "./toolkits/previewControls/handlers.ts";
......@@ -396,6 +398,7 @@ const previewSnapshotFailure = <E>(cause: Cause.Cause<E>) => {
396398const registerPreviewSnapshot = Effect.fn("McpHttpServer.registerPreviewSnapshot")(function* () {
397399 const server = yield* McpServer.McpServer;
398400 const broker = yield* PreviewAutomationBroker.PreviewAutomationBroker;
401 const threads = yield* ThreadManagementService.ThreadManagementService;
399402 // The MCP tool runner only supplies the client, so hand the save path its services here.
400403 const saveServices = yield* Effect.context<
401404 ServerConfig.ServerConfig | FileSystem.FileSystem | Path.Path | Crypto.Crypto
......@@ -430,6 +433,7 @@ const registerPreviewSnapshot = Effect.fn("McpHttpServer.registerPreviewSnapshot
430433 Stream.run(Sink.last()),
431434 Effect.flatMap(Effect.fromOption),
432435 Effect.provideService(PreviewAutomationBroker.PreviewAutomationBroker, broker),
436 Effect.provideService(ThreadManagementService.ThreadManagementService, threads),
433437 Effect.provideService(McpInvocationContext.McpInvocationContext, invocation),
434438 Effect.flatMap(({ encodedResult }) =>
435439 Effect.gen(function* () {
......@@ -655,6 +659,7 @@ const registerImageTool = <T extends Tool.Any, E, R>(
655659
656660const registerDeviceScreenshot = Effect.fn("McpHttpServer.registerDeviceScreenshot")(function* () {
657661 const devices = yield* DeviceService.DeviceService;
662 const threads = yield* ThreadManagementService.ThreadManagementService;
658663 const built = yield* DeviceScreenshotToolkit;
659664 yield* registerImageTool(
660665 DeviceScreenshotTool,
......@@ -662,7 +667,11 @@ const registerDeviceScreenshot = Effect.fn("McpHttpServer.registerDeviceScreensh
662667 built
663668 .handle("device_screenshot", payload)
664669 .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 ),
666675 "screenshot",
667676 "Device screenshot failed.",
668677 );
......@@ -672,6 +681,7 @@ const isOrchestratorMcpFailure = Schema.is(OrchestratorMcpFailure);
672681
673682const registerHtmlPreview = Effect.fn("McpHttpServer.registerHtmlPreview")(function* () {
674683 const htmlRender = yield* HtmlRender.HtmlRender;
684 const threads = yield* ThreadManagementService.ThreadManagementService;
675685 const built = yield* HtmlPreviewToolkit;
676686 yield* registerImageTool(
677687 HtmlPreviewTool,
......@@ -679,7 +689,11 @@ const registerHtmlPreview = Effect.fn("McpHttpServer.registerHtmlPreview")(funct
679689 built
680690 .handle("html_preview", payload)
681691 .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 ),
683697 "preview",
684698 // Parameter errors and HTML render errors are both written by the server for the agent.
685699 (error) =>
......@@ -689,17 +703,34 @@ const registerHtmlPreview = Effect.fn("McpHttpServer.registerHtmlPreview")(funct
689703 );
690704});
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
692721export const layerHtmlToolkit = Layer.mergeAll(
693 McpServer.toolkit(HtmlRenderToolkit).pipe(Layer.provide(HtmlHandlers.layerRender)),
694 Layer.effectDiscard(registerHtmlPreview()).pipe(Layer.provide(HtmlHandlers.layerPreview)),
722 toolkitRegistration(HtmlRenderToolkit, HtmlHandlers.layerRender),
723 imageToolRegistration(registerHtmlPreview(), HtmlHandlers.layerPreview),
695724).pipe(Layer.provide(HtmlRender.layer));
696725
697const layerPreviewStandardToolkitRegistration = McpServer.toolkit(PreviewStandardToolkit).pipe(
698 Layer.provide(PreviewHandlers.layerStandard),
726const layerPreviewStandardToolkitRegistration = toolkitRegistration(
727 PreviewStandardToolkit,
728 PreviewHandlers.layerStandard,
699729);
700730
701const layerPreviewSnapshotRegistration = Layer.effectDiscard(registerPreviewSnapshot()).pipe(
702 Layer.provide(PreviewHandlers.layerSnapshot),
731const layerPreviewSnapshotRegistration = imageToolRegistration(
732 registerPreviewSnapshot(),
733 PreviewHandlers.layerSnapshot,
703734);
704735
705736export const layerPreviewToolkit = Layer.mergeAll(
......@@ -707,47 +738,48 @@ export const layerPreviewToolkit = Layer.mergeAll(
707738 layerPreviewSnapshotRegistration,
708739);
709740
710export const layerOrchestratorToolkit = McpServer.toolkit(OrchestratorToolkit).pipe(
711 Layer.provide(OrchestratorHandlers.layer),
712 Layer.provide(OrchestratorMcpService.layer),
713 Layer.provide(ThreadMetadataMcpService.layer),
714);
741export const layerOrchestratorToolkit = toolkitRegistration(
742 OrchestratorToolkit,
743 OrchestratorHandlers.layer,
744).pipe(Layer.provide(OrchestratorMcpService.layer), Layer.provide(ThreadMetadataMcpService.layer));
715745
716export const layerThreadToolkit = McpServer.toolkit(ThreadToolkit).pipe(
717 Layer.provide(ThreadHandlers.layer),
718);
746export const layerThreadToolkit = toolkitRegistration(ThreadToolkit, ThreadHandlers.layer);
719747
720const layerWorktreeToolkitRegistration = McpServer.toolkit(WorktreeToolkit).pipe(
721 Layer.provide(WorktreeHandlers.layer),
722 Layer.provide(WorktreeMcpService.layer),
723);
748const layerWorktreeToolkitRegistration = toolkitRegistration(
749 WorktreeToolkit,
750 WorktreeHandlers.layer,
751).pipe(Layer.provide(WorktreeMcpService.layer));
724752
725const layerPreviewControlsRegistration = McpServer.toolkit(PreviewControlsToolkit).pipe(
726 Layer.provide(PreviewControlsHandlers.layer),
753const layerPreviewControlsRegistration = toolkitRegistration(
754 PreviewControlsToolkit,
755 PreviewControlsHandlers.layer,
727756);
728757
729const layerEnvironmentRegistration = McpServer.toolkit(EnvironmentToolkit).pipe(
730 Layer.provide(EnvironmentHandlers.layer),
758const layerEnvironmentRegistration = toolkitRegistration(
759 EnvironmentToolkit,
760 EnvironmentHandlers.layer,
731761);
732762
733const layerProjectRegistration = McpServer.toolkit(ProjectToolkit).pipe(
734 Layer.provide(ProjectHandlers.layer),
735);
763const layerProjectRegistration = toolkitRegistration(ProjectToolkit, ProjectHandlers.layer);
736764
737const layerAttachmentRegistration = McpServer.toolkit(AttachmentToolkit).pipe(
738 Layer.provide(AttachmentHandlers.layer),
765const layerAttachmentRegistration = toolkitRegistration(
766 AttachmentToolkit,
767 AttachmentHandlers.layer,
739768);
740769
741export const layerPullRequestsToolkit = McpServer.toolkit(PullRequestsToolkit).pipe(
742 Layer.provide(PullRequestsHandlers.layer),
770export const layerPullRequestsToolkit = toolkitRegistration(
771 PullRequestsToolkit,
772 PullRequestsHandlers.layer,
743773);
744774
745const layerDeviceStandardToolkitRegistration = McpServer.toolkit(DeviceStandardToolkit).pipe(
746 Layer.provide(DeviceHandlers.layerStandard),
775const layerDeviceStandardToolkitRegistration = toolkitRegistration(
776 DeviceStandardToolkit,
777 DeviceHandlers.layerStandard,
747778);
748779
749const layerDeviceScreenshotRegistration = Layer.effectDiscard(registerDeviceScreenshot()).pipe(
750 Layer.provide(DeviceHandlers.layerScreenshot),
780const layerDeviceScreenshotRegistration = imageToolRegistration(
781 registerDeviceScreenshot(),
782 DeviceHandlers.layerScreenshot,
751783);
752784
753785export 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 @@
11import * as NodeServices from "@effect/platform-node/NodeServices";
22import { assert, describe, it } from "@effect/vitest";
33import {
4 CommandId,
45 EnvironmentId,
56 NodeId,
7 type OrchestrationV2ThreadShell,
8 type ScheduledTask,
9 ScheduledTaskId,
610 ProjectId,
711 ProviderDriverKind,
812 ProviderInstanceId,
......@@ -16,7 +20,14 @@ import * as Effect from "effect/Effect";
1620import * as Layer from "effect/Layer";
1721import * 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";
2031import type { ProviderAdapterV2Shape } from "../orchestration-v2/ProviderAdapter.ts";
2132import * as ProviderAdapterRegistry from "../orchestration-v2/ProviderAdapterRegistry.ts";
2233import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts";
......@@ -26,6 +37,7 @@ import * as ProjectService from "../project/ProjectService.ts";
2637import * as ScheduledTaskService from "../scheduledTasks/ScheduledTaskService.ts";
2738import * as SecretRequests from "../secrets/SecretRequests.ts";
2839import type { McpInvocationScope } from "./McpInvocationContext.ts";
40import { idleThreadProjection, liveThreadShell } from "./McpToolAccess.testkit.ts";
2941import * as OrchestratorMcpService from "./OrchestratorMcpService.ts";
3042
3143describe("OrchestratorMcpService", () => {
......@@ -283,6 +295,8 @@ describe("OrchestratorMcpService", () => {
283295 Layer.mock(ThreadManagementService.ThreadManagementService)({
284296 getThreadRecords: (threadId) =>
285297 Effect.succeed(threadId === parentThreadId ? parentProjection : childProjection),
298 // The child still runs within the parent's modes.
299 getThreadShell: (threadId) => Effect.succeed(liveThreadShell(threadId)),
286300 dispatch: (command) =>
287301 Ref.update(dispatched, (commands) => [...commands, command]).pipe(
288302 Effect.as({} as never),
......@@ -357,6 +371,8 @@ describe("OrchestratorMcpService", () => {
357371 Layer.mock(ThreadManagementService.ThreadManagementService)({
358372 getThreadRecords: (threadId) =>
359373 Effect.succeed(threadId === parentThreadId ? parentProjection : childProjection),
374 // The child still runs within the parent's modes.
375 getThreadShell: (threadId) => Effect.succeed(liveThreadShell(threadId)),
360376 dispatch: (command) =>
361377 Ref.update(dispatched, (commands) => [...commands, command]).pipe(
362378 Effect.andThen(Effect.fail(new Error("simulated stop failure") as never)),
......@@ -434,6 +450,8 @@ describe("OrchestratorMcpService", () => {
434450 Layer.mock(ThreadManagementService.ThreadManagementService)({
435451 getThreadRecords: (threadId) =>
436452 Effect.succeed(threadId === parentThreadId ? parentProjection : childProjection),
453 // The child still runs within the parent's modes.
454 getThreadShell: (threadId) => Effect.succeed(liveThreadShell(threadId)),
437455 dispatch: (command) =>
438456 Ref.update(dispatched, (commands) => [...commands, command]).pipe(
439457 Effect.andThen(
......@@ -479,6 +497,258 @@ describe("OrchestratorMcpService", () => {
479497 }).pipe(Effect.provide(OrchestratorMcpService.layer.pipe(Layer.provide(layerDependencies))));
480498 }),
481499 );
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 );
482752});
483753
484754describe("OrchestratorMcpService provider resolution", () => {
......@@ -1294,4 +1564,227 @@ describe("OrchestratorMcpService provider resolution", () => {
12941564 }
12951565 }),
12961566 );
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 });
12971790});
apps/server/src/mcp/OrchestratorMcpService.ts+134-15
......@@ -66,6 +66,7 @@ import * as Effect from "effect/Effect";
6666import * as Exit from "effect/Exit";
6767import * as Layer from "effect/Layer";
6868import * as Option from "effect/Option";
69import * as Ref from "effect/Ref";
6970import * as Schema from "effect/Schema";
7071import * as Stream from "effect/Stream";
7172
......@@ -74,6 +75,10 @@ import {
7475 subagentResultForRun,
7576 delegatedTaskProgress,
7677} from "../orchestration-v2/SubagentProjection.ts";
78import {
79 DispatchModeLimit,
80 type DispatchModeRefusal,
81} from "../orchestration-v2/DispatchModeLimit.ts";
7782import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts";
7883import * as ProjectService from "../project/ProjectService.ts";
7984import * as ProviderRegistry from "../provider/ProviderRegistry.ts";
......@@ -225,7 +230,12 @@ function scheduledTaskWorkspaceStrategy(
225230 : { type: "worktree", baseRef: "main", startFromOrigin: true };
226231}
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 {
229239 return {
230240 scheduledTaskId: task.id,
231241 title: task.title,
......@@ -237,7 +247,7 @@ function scheduledTaskSummary(task: ScheduledTask): OrchestratorMcpScheduledTask
237247 nextRunAt: task.nextRunAt,
238248 lastRunStatus: task.lastRunStatus,
239249 // 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 }),
241251 ...(task.webhook === undefined
242252 ? {}
243253 : { webhookSignature: task.webhook.hasSecret ? "set" : "none" }),
......@@ -1364,9 +1374,65 @@ const make = Effect.gen(function* () {
13641374 }).pipe(Effect.timeoutOption(Duration.millis(timeoutMs)), Effect.map(Option.flatten));
13651375
13661376 /**
1367 * A scheduled task the caller may change: one whose modes are no broader
1368 * than the caller's own, so editing its prompt cannot run work above the
1369 * caller's limits.
1377 * The modes a task's runs execute at: its own, or for a task bound to a
1378 * thread, also that thread's modes as they are now, since its runs are
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.
13701436 */
13711437 const loadScheduledTask = (
13721438 scheduledTaskId: ScheduledTask["id"],
......@@ -1387,8 +1453,10 @@ const make = Effect.gen(function* () {
13871453 if (task === undefined) {
13881454 return yield* failure("task_not_found", `Scheduled task ${scheduledTaskId} was not found.`);
13891455 }
1390 yield* resolveRuntimeMode(limits.runtimeMode, task.runtimeMode);
1391 yield* resolveInteractionMode(limits.interactionMode, task.interactionMode);
1456 for (const modes of yield* scheduledTaskRunModes(task)) {
1457 yield* resolveRuntimeMode(limits.runtimeMode, modes.runtimeMode);
1458 yield* resolveInteractionMode(limits.interactionMode, modes.interactionMode);
1459 }
13921460 return task;
13931461 });
13941462
......@@ -1451,11 +1519,11 @@ const make = Effect.gen(function* () {
14511519 failure("orchestration_error", `Could not schedule task: ${error.message}`),
14521520 ),
14531521 );
1454 return scheduledTaskSummary(task);
1522 return yield* summarizeScheduledTask(scope, { parent, limits }, task);
14551523 }),
14561524 listScheduledTasks: (scope, input) =>
14571525 Effect.gen(function* () {
1458 const { parent } = yield* loadCaller(scope);
1526 const { parent, limits } = yield* loadCaller(scope);
14591527 const projectId = input.projectId ?? parent?.thread.projectId;
14601528 const { tasks } = yield* scheduledTasks
14611529 .list()
......@@ -1465,9 +1533,10 @@ const make = Effect.gen(function* () {
14651533 ),
14661534 );
14671535 return {
1468 tasks: tasks
1469 .filter((task) => projectId === undefined || task.projectId === projectId)
1470 .map(scheduledTaskSummary),
1536 tasks: yield* Effect.forEach(
1537 tasks.filter((task) => projectId === undefined || task.projectId === projectId),
1538 (task) => summarizeScheduledTask(scope, { parent, limits }, task),
1539 ),
14711540 };
14721541 }),
14731542 updateScheduledTask: (scope, input) =>
......@@ -1521,7 +1590,7 @@ const make = Effect.gen(function* () {
15211590 failure("orchestration_error", `Could not update scheduled task: ${error.message}`),
15221591 ),
15231592 );
1524 return scheduledTaskSummary(task);
1593 return yield* summarizeScheduledTask(scope, { parent, limits }, task);
15251594 }),
15261595 deleteScheduledTask: (scope, input) =>
15271596 Effect.gen(function* () {
......@@ -1851,6 +1920,32 @@ const make = Effect.gen(function* () {
18511920 const current = yield* readTask(scope, input.taskId);
18521921 const key = yield* requestKey(input.clientRequestId);
18531922 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);
18541949 const parentTask = parentProjection.subagents.find(
18551950 (task) => task.id === input.taskId && task.origin === "app_owned",
18561951 );
......@@ -1910,8 +2005,32 @@ const make = Effect.gen(function* () {
19102005 ),
19112006 );
19122007 });
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 );
19132032 if (isTerminalTaskStatus(current.status)) {
1914 yield* stopChild;
2033 yield* stopWithinLimit;
19152034 yield* disposeCompletionDelivery;
19162035 return {
19172036 taskId: input.taskId,
......@@ -1930,7 +2049,7 @@ const make = Effect.gen(function* () {
19302049 `Delegated task ${input.taskId} has no interruptible child run.`,
19312050 );
19322051 }
1933 yield* stopChild;
2052 yield* stopWithinLimit;
19342053 yield* disposeCompletionDelivery.pipe(
19352054 Effect.catchCause((cause) =>
19362055 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", () =>
7878 expect(error.code).toBe("orchestration_error");
7979 }),
8080);
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";
1616
1717import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts";
1818import type { McpInvocationScope } from "./McpInvocationContext.ts";
19import { assertTargetWithinLimits } from "./threadAccess.ts";
2019
2120export class ThreadMetadataMcpService extends Context.Service<
2221 ThreadMetadataMcpService,
......@@ -167,42 +166,6 @@ const make = Effect.gen(function* () {
167166 if (shell === null || shell.deletedAt !== null) {
168167 return yield* failure("thread_not_found", `Thread ${threadId} was not found.`);
169168 }
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 }
206169 const target = yield* threadManagement
207170 .getProjectThreadRecords({ projectId: shell.projectId, threadId }, [])
208171 .pipe(Effect.mapError(threadLookupFailure));
apps/server/src/mcp/threadAccess.ts+22-37
......@@ -60,6 +60,15 @@ export const readCaller = Effect.fn("mcp.readCaller")(function* () {
6060 message: "This credential cannot control threads.",
6161 });
6262 }
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;
6372 const threads = yield* ThreadManagement.ThreadManagementService;
6473 if (scope.thread === undefined) {
6574 return {
......@@ -104,7 +113,12 @@ export const assertTargetWithinLimits = (
104113 Effect.asVoid,
105114 );
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) {
108122 if (caller === undefined) return Effect.void;
109123 return caller.archivedAt !== null ||
110124 caller.activeRunId === null ||
......@@ -119,34 +133,14 @@ function assertLiveCaller({ caller, scope }: Caller) {
119133}
120134
121135/**
122 * Mutations from a thread need that thread's live run, so an agent whose turn
123 * ended cannot keep acting. An OAuth client has no run; its session and
124 * ceiling are its authority.
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 * Actions that change the environment itself (projects, preferences) need full
137 * access: a thread caller in full-access/default mode, or a client approved
138 * with a full-access ceiling.
136139 */
137export const readFullAccessCaller = Effect.fn("mcp.readFullAccessCaller")(function* (
138 message: string,
139) {
140 const context = yield* readMutationCaller();
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});
140export const assertFullAccess = (context: Caller, message: string) =>
141 context.limits.runtimeMode === "full-access" && context.limits.interactionMode === "default"
142 ? Effect.void
143 : Effect.fail(new OrchestratorMcpFailure({ code: "capability_denied", message }));
150144
151145/** A target project: the one passed, else the calling thread's. */
152146export const resolveProjectId = (context: Caller, projectId: ProjectId | undefined) =>
......@@ -204,15 +198,6 @@ export const readThread = Effect.fn("mcp.readThread")(function* <
204198 return { ...context, projection };
205199});
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
216201export const newCommandId = Effect.fn("mcp.newCommandId")(function* () {
217202 const crypto = yield* Crypto.Crypto;
218203 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";
33import * as Upload from "../../../assets/AttachmentUpload.ts";
44import * as Claims from "../../../orchestration-v2/AttachmentClaims.ts";
55import * as ThreadMessageIntake from "../../../orchestration-v2/ThreadMessageIntake.ts";
6import {
7 newCommandId,
8 readMutationCaller,
9 readWritableThread,
10 unavailable,
11} from "../../threadAccess.ts";
6import * as McpToolAccess from "../../McpToolAccess.ts";
7import { newCommandId, readThread, unavailable } from "../../threadAccess.ts";
128import { AttachmentToolkit } from "./tools.ts";
139
1410export function resolveAttachmentReferences(
......@@ -31,57 +27,53 @@ export function resolveAttachmentReferences(
3127 });
3228}
3329
34export const layer = AttachmentToolkit.toLayer({
35 t3_attachment_prepare_upload: (input) =>
36 Effect.gen(function* () {
37 yield* readMutationCaller();
38 return yield* Upload.issueAttachmentUploadUrl(input.upload).pipe(
39 Effect.mapError(unavailable),
40 );
41 }),
42 t3_attachment_discard: (input) =>
43 Effect.gen(function* () {
44 yield* readMutationCaller();
45 yield* Upload.deletePendingAttachment(input.attachmentId);
46 return {};
47 }),
48 t3_thread_send_attachments: (input) =>
49 Effect.gen(function* () {
50 const { caller, projection } = yield* readWritableThread(input.threadId, ["messages"]);
51 if (projection.thread.archivedAt !== null)
52 return yield* new OrchestratorMcpFailure({
53 code: "invalid_request",
54 message: "Unarchive the target thread before sending attachments.",
55 });
56 const attachments = yield* resolveAttachmentReferences(
57 input.attachments,
58 projection.messages.flatMap((message) => message.attachments),
59 );
60 const commandId = yield* newCommandId();
61 const messageId = MessageId.make(commandId);
62 const result = yield* ThreadMessageIntake.sendToThread({
63 projectId: projection.thread.projectId,
64 threadId: projection.thread.id,
65 commandId,
66 messageId,
67 ...(caller === undefined ? {} : { senderThreadId: caller.id }),
68 text: input.message ?? "",
69 attachments,
70 mode: "auto",
71 createdBy: "agent",
72 creationSource: "mcp",
73 }).pipe(
74 Effect.mapError((error) =>
75 error._tag === "AttachmentClaimError"
76 ? new OrchestratorMcpFailure({ code: "orchestration_error", message: error.message })
77 : unavailable(),
78 ),
79 );
80 return {
81 threadId: projection.thread.id,
82 messageId,
83 runId: result.run.id,
84 status: result.run.status,
85 };
86 }),
30export const layer = McpToolAccess.toLayer(AttachmentToolkit, {
31 t3_attachment_prepare_upload: McpToolAccess.writes((input) =>
32 Upload.issueAttachmentUploadUrl(input.upload).pipe(Effect.mapError(unavailable)),
33 ),
34 t3_attachment_discard: McpToolAccess.writes((input) =>
35 Upload.deletePendingAttachment(input.attachmentId).pipe(Effect.as({})),
36 ),
37 t3_thread_send_attachments: McpToolAccess.writesThreads(
38 (input) => [input.threadId],
39 (input) =>
40 Effect.gen(function* () {
41 const { caller, projection } = yield* readThread(input.threadId, ["messages"]);
42 if (projection.thread.archivedAt !== null)
43 return yield* new OrchestratorMcpFailure({
44 code: "invalid_request",
45 message: "Unarchive the target thread before sending attachments.",
46 });
47 const attachments = yield* resolveAttachmentReferences(
48 input.attachments,
49 projection.messages.flatMap((message) => message.attachments),
50 );
51 const commandId = yield* newCommandId();
52 const messageId = MessageId.make(commandId);
53 const result = yield* ThreadMessageIntake.sendToThread({
54 projectId: projection.thread.projectId,
55 threadId: projection.thread.id,
56 commandId,
57 messageId,
58 ...(caller === undefined ? {} : { senderThreadId: caller.id }),
59 text: input.message ?? "",
60 attachments,
61 mode: "auto",
62 createdBy: "agent",
63 creationSource: "mcp",
64 }).pipe(
65 Effect.mapError((error) =>
66 error._tag === "AttachmentClaimError"
67 ? new OrchestratorMcpFailure({ code: "orchestration_error", message: error.message })
68 : unavailable(),
69 ),
70 );
71 return {
72 threadId: projection.thread.id,
73 messageId,
74 runId: result.run.id,
75 status: result.run.status,
76 };
77 }),
78 ),
8779});
apps/server/src/mcp/toolkits/core.test.ts+6-9
......@@ -36,6 +36,7 @@ import * as SecretRequests from "../../secrets/SecretRequests.ts";
3636import * as ScheduledTaskService from "../../scheduledTasks/ScheduledTaskService.ts";
3737import * as McpHttpServer from "../McpHttpServer.ts";
3838import * as McpInvocationContext from "../McpInvocationContext.ts";
39import * as McpToolAccessTestkit from "../McpToolAccess.testkit.ts";
3940import { dispatchFailure } from "../threadAccess.ts";
4041import { OrchestratorToolkit } from "./orchestrator/tools.ts";
4142import { PreviewToolkit } from "./preview/tools.ts";
......@@ -131,7 +132,7 @@ const client = McpSchema.McpServerClient.of({
131132 getClient: Effect.die("unused"),
132133});
133134
134it.effect("checks capability before accessing services through the production registration", () =>
135it.effect("checks capability through the production registration", () =>
135136 Effect.gen(function* () {
136137 const server = yield* McpServer.McpServer;
137138 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
152153 McpHttpServer.layerThreadToolkit.pipe(
153154 Layer.provideMerge(McpServer.McpServer.layer),
154155 Layer.provide(NodeCrypto.layer),
155 Layer.provide(Layer.mock(ThreadManagement.ThreadManagementService)({})),
156 Layer.provide(McpToolAccessTestkit.liveThreadsLayer),
156157 ),
157158 ),
158159 ),
......@@ -453,12 +454,8 @@ it.effect("a client caller targets any thread within its ceiling and cannot act
453454 Layer.provide(NodeCrypto.layer),
454455 Layer.provide(
455456 Layer.mock(ThreadManagement.ThreadManagementService)({
456 getThreadShell: () =>
457 Effect.succeed({
458 id: ThreadId.make("other-project-thread"),
459 projectId: "other-project",
460 deletedAt: null,
461 } as never),
457 getThreadShell: (id) =>
458 Effect.succeed(McpToolAccessTestkit.liveThreadShell(id, { runtimeMode: "auto" })),
462459 getProjectThreadRecords: () =>
463460 Effect.succeed({
464461 thread: {
......@@ -469,7 +466,7 @@ it.effect("a client caller targets any thread within its ceiling and cannot act
469466 deletedAt: null,
470467 },
471468 } as never),
472 dispatch: () => Effect.succeed({ sequence: 7 } as never),
469 dispatch: () => Effect.succeed({ sequence: 7, storedEvents: [] }),
473470 }),
474471 ),
475472 ),
apps/server/src/mcp/toolkits/device/handlers.ts+12-7
......@@ -16,6 +16,7 @@ import { nodeRuntimeUnavailableMessage } from "@t3tools/shared/nodeRuntime";
1616
1717import * as DeviceService from "../../../device/DeviceService.ts";
1818import * as McpInvocationContext from "../../McpInvocationContext.ts";
19import * as McpToolAccess from "../../McpToolAccess.ts";
1920import { DeviceScreenshotToolkit, DeviceStandardToolkit, DeviceToolkit } from "./tools.ts";
2021
2122/** The flags that pin every agent-device command to one device. */
......@@ -119,7 +120,7 @@ const pickDevice = (
119120const toolError = (error: DeviceError | DeviceToolUnavailableError) => error;
120121
121122const handlers = {
122 device_list: (input) =>
123 device_list: McpToolAccess.readsAsCaller((input) =>
123124 Effect.gen(function* () {
124125 const scope = yield* requireDeviceAccess;
125126 const devices = yield* DeviceService.DeviceService;
......@@ -145,7 +146,8 @@ const handlers = {
145146 open,
146147 };
147148 }).pipe(Effect.mapError(toolError)),
148 device_open: (input) =>
149 ),
150 device_open: McpToolAccess.actsAsCaller((input) =>
149151 Effect.gen(function* () {
150152 const scope = yield* requireDeviceAccess;
151153 const devices = yield* DeviceService.DeviceService;
......@@ -203,7 +205,8 @@ const handlers = {
203205 quickStart: agentDeviceQuickStart(device, targetArgs, command),
204206 };
205207 }).pipe(Effect.mapError(toolError)),
206 device_screenshot: (input) =>
208 ),
209 device_screenshot: McpToolAccess.readsAsCaller((input) =>
207210 Effect.gen(function* () {
208211 const scope = yield* requireDeviceAccess;
209212 const devices = yield* DeviceService.DeviceService;
......@@ -229,7 +232,8 @@ const handlers = {
229232 },
230233 };
231234 }).pipe(Effect.mapError(toolError)),
232 device_close: (input) =>
235 ),
236 device_close: McpToolAccess.actsAsCaller((input) =>
233237 Effect.gen(function* () {
234238 const scope = yield* requireDeviceAccess;
235239 const devices = yield* DeviceService.DeviceService;
......@@ -241,7 +245,8 @@ const handlers = {
241245 });
242246 return {};
243247 }).pipe(Effect.mapError(toolError)),
244} satisfies Parameters<typeof DeviceToolkit.toLayer>[0];
248 ),
249} satisfies McpToolAccess.Handlers<typeof DeviceToolkit.tools>;
245250
246251/** Width and height from the IHDR chunk; a PNG that lacks one reports 0×0. */
247252export function pngDimensions(png: Uint8Array): { width: number; height: number } {
......@@ -258,8 +263,8 @@ export function pngDimensions(png: Uint8Array): { width: number; height: number
258263
259264const { 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, {
264269 device_screenshot,
265270});
apps/server/src/mcp/toolkits/device/tools.ts+14-5
......@@ -1,4 +1,5 @@
11import {
2 OrchestratorMcpFailure,
23 DeviceToolCloseInput,
34 DeviceToolError,
45 DeviceToolListResult,
......@@ -15,8 +16,16 @@ import { Tool, Toolkit } from "effect/ai";
1516
1617import * as McpInvocationContext from "../../McpInvocationContext.ts";
1718import * 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
2130/**
2231 * Deliberately a small surface: lifecycle, visibility for the user, and one
......@@ -36,7 +45,7 @@ const DeviceListTool = Tool.make("device_list", {
3645 ),
3746 }),
3847 success: DeviceToolListResult,
39 failure: DeviceToolError,
48 failure: DeviceToolFailure,
4049 dependencies,
4150})
4251 .annotate(Tool.Title, "List devices")
......@@ -50,7 +59,7 @@ const DeviceOpenTool = Tool.make("device_open", {
5059 "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.",
5160 parameters: DeviceToolOpenInput,
5261 success: DeviceToolOpenResult,
53 failure: DeviceToolError,
62 failure: DeviceToolFailure,
5463 dependencies: [...dependencies, FileSystem.FileSystem, Path.Path, ServerConfig.ServerConfig],
5564})
5665 .annotate(Tool.Title, "Open device")
......@@ -64,7 +73,7 @@ export const DeviceScreenshotTool = Tool.make("device_screenshot", {
6473 "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.",
6574 parameters: DeviceToolTargetInput,
6675 success: DeviceToolScreenshotResult,
67 failure: DeviceToolError,
76 failure: DeviceToolFailure,
6877 dependencies,
6978})
7079 .annotate(Tool.Title, "Screenshot device")
......@@ -80,7 +89,7 @@ const DeviceCloseTool = Tool.make("device_close", {
8089 success: Schema.Record(Schema.String, Schema.Never).annotate({
8190 description: "The device was closed.",
8291 }),
83 failure: DeviceToolError,
92 failure: DeviceToolFailure,
8493 dependencies,
8594})
8695 .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";
44import * as ThreadCommandExecutor from "../../../orchestration-v2/ThreadCommandExecutor.ts";
55import * as Settings from "../../../serverSettings.ts";
66import * as McpInvocationContext from "../../McpInvocationContext.ts";
7import { readCaller, readFullAccessCaller, unavailable } from "../../threadAccess.ts";
7import * as McpToolAccess from "../../McpToolAccess.ts";
8import { readCaller, unavailable } from "../../threadAccess.ts";
89import { EnvironmentToolkit } from "./tools.ts";
910
1011export function preferences(settings: ServerSettings) {
......@@ -28,26 +29,21 @@ export function preferences(settings: ServerSettings) {
2829 },
2930 };
3031}
31const access = (writable = false) =>
32 Effect.gen(function* () {
33 const context = yield* writable
34 ? readFullAccessCaller(
35 "Preference updates require a live full-access/default thread or a full-access client.",
36 )
37 : readCaller();
38 const environment = yield* Environment.ServerEnvironment;
39 const descriptor = yield* environment.getDescriptor;
40 if (descriptor.environmentId !== context.scope.environmentId)
41 return yield* new OrchestratorMcpFailure({
42 code: "capability_denied",
43 message: "This credential belongs to another environment.",
44 });
45 return { ...context, descriptor, settings: yield* Settings.ServerSettingsService };
46 });
47export const layer = EnvironmentToolkit.toLayer({
48 t3_environment_read: () =>
32const access = Effect.gen(function* () {
33 const context = yield* readCaller();
34 const environment = yield* Environment.ServerEnvironment;
35 const descriptor = yield* environment.getDescriptor;
36 if (descriptor.environmentId !== context.scope.environmentId)
37 return yield* new OrchestratorMcpFailure({
38 code: "capability_denied",
39 message: "This credential belongs to another environment.",
40 });
41 return { ...context, descriptor, settings: yield* Settings.ServerSettingsService };
42});
43export const layer = McpToolAccess.toLayer(EnvironmentToolkit, {
44 t3_environment_read: McpToolAccess.reads(() =>
4945 Effect.gen(function* () {
50 const { descriptor, settings } = yield* access();
46 const { descriptor, settings } = yield* access;
5147 const current = yield* settings.getSettings.pipe(Effect.mapError(unavailable));
5248 return {
5349 environmentId: descriptor.environmentId,
......@@ -57,12 +53,15 @@ export const layer = EnvironmentToolkit.toLayer({
5753 preferences: preferences(current),
5854 };
5955 }),
60 t3_environment_preferences_update: (patch) =>
56 ),
57 t3_environment_preferences_update: McpToolAccess.writesEnvironment((patch, check) =>
6158 Effect.gen(function* () {
6259 const scope = yield* McpInvocationContext.McpInvocationContext;
6360 const executor = yield* ThreadCommandExecutor.ThreadCommandExecutor;
6461 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;
6665 return preferences(
6766 yield* settings.updateSettings(patch).pipe(Effect.mapError(unavailable)),
6867 );
......@@ -72,4 +71,5 @@ export const layer = EnvironmentToolkit.toLayer({
7271 ? update
7372 : executor.withLock(scope.thread.threadId, update);
7473 }),
74 ),
7575});
apps/server/src/mcp/toolkits/html/handlers.ts+12-15
......@@ -3,7 +3,7 @@ import * as Effect from "effect/Effect";
33
44import * as HtmlRender from "../../../htmlRender/HtmlRender.ts";
55import * as McpInvocationContext from "../../McpInvocationContext.ts";
6import { readMutationCaller } from "../../threadAccess.ts";
6import * as McpToolAccess from "../../McpToolAccess.ts";
77import { HtmlPreviewToolkit, HtmlRenderToolkit, type HtmlToolkit } from "./tools.ts";
88
99const INVALID_PAGE_ERRORS = new Set([
......@@ -20,14 +20,10 @@ const toFailure = (error: { readonly _tag: string; readonly message: string }) =
2020 });
2121
2222const 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) =>
2426 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 );
3127 const htmlRender = yield* HtmlRender.HtmlRender;
3228 const { png, ...preview } = yield* htmlRender.preview(input).pipe(Effect.mapError(toFailure));
3329 return {
......@@ -40,11 +36,11 @@ const handlers = {
4036 },
4137 };
4238 }),
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) =>
4442 Effect.gen(function* () {
45 // The page is stored in the calling thread, so it needs that thread's
46 // live run, like any other write.
47 const { scope } = yield* readMutationCaller();
43 const scope = yield* McpInvocationContext.McpInvocationContext;
4844 const { thread } = yield* McpInvocationContext.requireThreadScope(scope, "html_render");
4945 const htmlRender = yield* HtmlRender.HtmlRender;
5046 const reference = yield* htmlRender
......@@ -56,12 +52,13 @@ const handlers = {
5652 "Shown to the reader above your reply. Don't mention or describe the page; reply with only what it doesn't already say.",
5753 };
5854 }),
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, {
6259 html_preview: handlers.html_preview,
6360});
6461
65export const layerRender = HtmlRenderToolkit.toLayer({
62export const layerRender = McpToolAccess.toLayer(HtmlRenderToolkit, {
6663 html_render: handlers.html_render,
6764});
apps/server/src/mcp/toolkits/html/tools.ts+5-1
......@@ -56,7 +56,11 @@ export const HtmlPreviewTool = Tool.make("html_preview", {
5656 }),
5757 }),
5858 failure: OrchestratorMcpFailure,
59 dependencies: [McpInvocationContext.McpInvocationContext, HtmlRender.HtmlRender],
59 dependencies: [
60 McpInvocationContext.McpInvocationContext,
61 ThreadManagementService.ThreadManagementService,
62 HtmlRender.HtmlRender,
63 ],
6064})
6165 .annotate(Tool.Title, "Preview HTML")
6266 .annotate(Tool.Readonly, true)
apps/server/src/mcp/toolkits/orchestrator/handlers.ts+67-38
......@@ -2,106 +2,135 @@ import { OrchestratorToolkit } from "./tools.ts";
22import * as Effect from "effect/Effect";
33
44import * as McpInvocationContext from "../../McpInvocationContext.ts";
5import * as McpToolAccess from "../../McpToolAccess.ts";
56import * as OrchestratorMcpService from "../../OrchestratorMcpService.ts";
67import * as ThreadMetadataMcpService from "../../ThreadMetadataMcpService.ts";
78
89const handlers = {
9 orchestrator_capabilities: () =>
10 orchestrator_capabilities: McpToolAccess.reads(() =>
1011 Effect.gen(function* () {
1112 const scope = yield* McpInvocationContext.McpInvocationContext;
1213 const service = yield* OrchestratorMcpService.OrchestratorMcpService;
1314 return yield* service.capabilities(scope);
1415 }),
15 delegate_task: (input) =>
16 ),
17 delegate_task: McpToolAccess.actsAsCaller((input) =>
1618 Effect.gen(function* () {
1719 const scope = yield* McpInvocationContext.McpInvocationContext;
1820 const service = yield* OrchestratorMcpService.OrchestratorMcpService;
1921 return yield* service.delegateTask(scope, input);
2022 }),
21 task_status: ({ taskId }) =>
23 ),
24 task_status: McpToolAccess.actsAsCaller(({ taskId }) =>
2225 Effect.gen(function* () {
2326 const scope = yield* McpInvocationContext.McpInvocationContext;
2427 const service = yield* OrchestratorMcpService.OrchestratorMcpService;
2528 return yield* service.taskStatus(scope, taskId);
2629 }),
27 task_cancel: (input) =>
30 ),
31 task_cancel: McpToolAccess.actsAsCaller((input) =>
2832 Effect.gen(function* () {
2933 const scope = yield* McpInvocationContext.McpInvocationContext;
3034 const service = yield* OrchestratorMcpService.OrchestratorMcpService;
3135 return yield* service.cancelTask(scope, input);
3236 }),
33 schedule_task: (input) =>
34 Effect.gen(function* () {
35 const scope = yield* McpInvocationContext.McpInvocationContext;
36 const service = yield* OrchestratorMcpService.OrchestratorMcpService;
37 return yield* service.scheduleTask(scope, input);
38 }),
39 list_scheduled_tasks: (input) =>
37 ),
38 schedule_task: McpToolAccess.startsThreads(
39 // A scheduled task runs with the caller's own modes.
40 () => ({}),
41 (input) =>
42 Effect.gen(function* () {
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) =>
4049 Effect.gen(function* () {
4150 const scope = yield* McpInvocationContext.McpInvocationContext;
4251 const service = yield* OrchestratorMcpService.OrchestratorMcpService;
4352 return yield* service.listScheduledTasks(scope, input);
4453 }),
45 update_scheduled_task: (input) =>
54 ),
55 update_scheduled_task: McpToolAccess.writes((input) =>
4656 Effect.gen(function* () {
4757 const scope = yield* McpInvocationContext.McpInvocationContext;
4858 const service = yield* OrchestratorMcpService.OrchestratorMcpService;
4959 return yield* service.updateScheduledTask(scope, input);
5060 }),
51 delete_scheduled_task: (input) =>
61 ),
62 delete_scheduled_task: McpToolAccess.writes((input) =>
5263 Effect.gen(function* () {
5364 const scope = yield* McpInvocationContext.McpInvocationContext;
5465 const service = yield* OrchestratorMcpService.OrchestratorMcpService;
5566 return yield* service.deleteScheduledTask(scope, input);
5667 }),
57 request_secret: (input) =>
68 ),
69 request_secret: McpToolAccess.actsAsCaller((input) =>
5870 Effect.gen(function* () {
5971 const scope = yield* McpInvocationContext.McpInvocationContext;
6072 const service = yield* OrchestratorMcpService.OrchestratorMcpService;
6173 return yield* service.requestSecret(scope, input);
6274 }),
63 create_threads: (input) =>
75 ),
76 create_threads: McpToolAccess.actsAsCaller((input) =>
6477 Effect.gen(function* () {
6578 const scope = yield* McpInvocationContext.McpInvocationContext;
6679 const service = yield* OrchestratorMcpService.OrchestratorMcpService;
6780 return yield* service.createThreads(scope, input);
6881 }),
69 t3_thread_list: (input) =>
82 ),
83 t3_thread_list: McpToolAccess.reads((input) =>
7084 Effect.gen(function* () {
7185 const scope = yield* McpInvocationContext.McpInvocationContext;
7286 const service = yield* OrchestratorMcpService.OrchestratorMcpService;
7387 return yield* service.listThreads(scope, input);
7488 }),
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) =>
7694 Effect.gen(function* () {
7795 const scope = yield* McpInvocationContext.McpInvocationContext;
7896 const service = yield* OrchestratorMcpService.OrchestratorMcpService;
7997 return yield* service.readThread(scope, input);
8098 }),
81 t3_thread_update: (input) =>
82 Effect.gen(function* () {
83 const scope = yield* McpInvocationContext.McpInvocationContext;
84 const service = yield* ThreadMetadataMcpService.ThreadMetadataMcpService;
85 return yield* service.update(scope, input);
86 }),
87 t3_thread_send: (input) =>
88 Effect.gen(function* () {
89 const scope = yield* McpInvocationContext.McpInvocationContext;
90 const service = yield* OrchestratorMcpService.OrchestratorMcpService;
91 return yield* service.sendToThread(scope, input);
92 }),
93 t3_thread_wait: (input) =>
99 ),
100 t3_thread_update: McpToolAccess.writesThreads(
101 (input) => [input.threadId],
102 (input) =>
103 Effect.gen(function* () {
104 const scope = yield* McpInvocationContext.McpInvocationContext;
105 const service = yield* ThreadMetadataMcpService.ThreadMetadataMcpService;
106 return yield* service.update(scope, input);
107 }),
108 ),
109 t3_thread_send: McpToolAccess.writesThreads(
110 (input) => [input.threadId],
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) =>
94119 Effect.gen(function* () {
95120 const scope = yield* McpInvocationContext.McpInvocationContext;
96121 const service = yield* OrchestratorMcpService.OrchestratorMcpService;
97122 return yield* service.waitForThread(scope, input);
98123 }),
99 t3_thread_interrupt: (input) =>
100 Effect.gen(function* () {
101 const scope = yield* McpInvocationContext.McpInvocationContext;
102 const service = yield* OrchestratorMcpService.OrchestratorMcpService;
103 return yield* service.interruptThread(scope, input);
104 }),
105} satisfies Parameters<typeof OrchestratorToolkit.toLayer>[0];
124 ),
125 t3_thread_interrupt: McpToolAccess.writesThreads(
126 (input) => [input.threadId],
127 (input) =>
128 Effect.gen(function* () {
129 const scope = yield* McpInvocationContext.McpInvocationContext;
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 {
3232} from "@t3tools/contracts";
3333import { Tool, Toolkit } from "effect/ai";
3434
35import * as ThreadManagementService from "../../../orchestration-v2/ThreadManagementService.ts";
3536import * as McpInvocationContext from "../../McpInvocationContext.ts";
3637import * as OrchestratorMcpService from "../../OrchestratorMcpService.ts";
3738import * as ThreadMetadataMcpService from "../../ThreadMetadataMcpService.ts";
3839
3940const dependencies = [
4041 McpInvocationContext.McpInvocationContext,
42 ThreadManagementService.ThreadManagementService,
4143 OrchestratorMcpService.OrchestratorMcpService,
4244];
4345const threadMetadataDependencies = [
4446 McpInvocationContext.McpInvocationContext,
47 ThreadManagementService.ThreadManagementService,
4548 ThreadMetadataMcpService.ThreadMetadataMcpService,
4649];
4750
apps/server/src/mcp/toolkits/preview/handlers.ts+48-23
......@@ -29,6 +29,7 @@ import {
2929import { resolveAttachmentRelativePath } from "../../../attachmentPaths.ts";
3030import * as ServerConfig from "../../../config.ts";
3131import * as McpInvocationContext from "../../McpInvocationContext.ts";
32import * as McpToolAccess from "../../McpToolAccess.ts";
3233import * as PreviewAutomationBroker from "../../PreviewAutomationBroker.ts";
3334import { PreviewSnapshotToolkit, PreviewStandardToolkit, PreviewToolkit } from "./tools.ts";
3435
......@@ -189,41 +190,64 @@ export const claimPreviewRecording = Effect.fn("PreviewToolkit.claimRecording")(
189190});
190191
191192const handlers = {
192 preview_dialog: (input) => invokeTargeted<PreviewAutomationStatus>("dialog", input),
193 preview_status: (input) => invokeTargeted<PreviewAutomationStatus>("status", input ?? {}),
194 preview_open: (input) =>
193 preview_dialog: McpToolAccess.actsAsCaller((input) =>
194 invokeTargeted<PreviewAutomationStatus>("dialog", input),
195 ),
196 preview_status: McpToolAccess.readsAsCaller((input) =>
197 invokeTargeted<PreviewAutomationStatus>("status", input ?? {}),
198 ),
199 preview_open: McpToolAccess.actsAsCaller((input) =>
195200 invokeTargeted<PreviewAutomationStatus>("open", normalizePreviewOpenInput(input)),
196 preview_navigate: (input) =>
201 ),
202 preview_navigate: McpToolAccess.actsAsCaller((input) =>
197203 invokeTargeted<PreviewAutomationStatus>("navigate", input, input.timeoutMs),
198 preview_resize: (input) =>
204 ),
205 preview_resize: McpToolAccess.actsAsCaller((input) =>
199206 invokeTargeted<PreviewAutomationResizeResult>("resize", input, input.timeoutMs),
200 preview_set_appearance: (input) =>
207 ),
208 preview_set_appearance: McpToolAccess.actsAsCaller((input) =>
201209 invokeTargeted<PreviewAutomationSetColorSchemeResult>("setColorScheme", input),
202 preview_snapshot: (input) => {
210 ),
211 preview_snapshot: McpToolAccess.readsAsCaller((input) => {
203212 // Output selection and saving are MCP-only; the browser still produces a complete snapshot.
204213 const { includeImage: _includeImage, save: _save, ...operationInput } = input ?? {};
205214 return invokeTargeted<PreviewAutomationSnapshot>("snapshot", operationInput);
206 },
207 preview_click: (input) => invokeTargeted<object>("click", input, input.timeoutMs),
208 preview_type: (input) => invokeTargeted<object>("type", input, input.timeoutMs),
209 preview_hover: (input) => invokeTargeted<object>("hover", input, input.timeoutMs),
210 preview_select: (input) =>
215 }),
216 preview_click: McpToolAccess.actsAsCaller((input) =>
217 invokeTargeted<object>("click", input, input.timeoutMs),
218 ),
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) =>
211226 invokeTargeted<PreviewAutomationSelectResult>("select", input, input.timeoutMs),
212 preview_drag: (input) => invokeTargeted<object>("drag", input, input.timeoutMs),
213 preview_upload: (input) => invokeTargeted<object>("upload", input, input.timeoutMs),
214 preview_press: (input) => invokeTargeted<object>("press", input),
215 preview_scroll: (input) => invokeTargeted<object>("scroll", input),
216 preview_evaluate: ({ tabId, ...input }) =>
227 ),
228 preview_drag: McpToolAccess.actsAsCaller((input) =>
229 invokeTargeted<object>("drag", input, input.timeoutMs),
230 ),
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 }) =>
217237 invoke<unknown>("evaluate", input, undefined, tabId).pipe(
218238 Effect.map(({ result, toolIcon }) => ({
219239 value: result ?? null,
220240 ...(toolIcon ? { toolIcon } : {}),
221241 })),
222242 ),
223 preview_wait_for: (input) => invokeTargeted<object>("waitFor", input, input.timeoutMs),
224 preview_recording_start: (input) =>
243 ),
244 preview_wait_for: McpToolAccess.readsAsCaller((input) =>
245 invokeTargeted<object>("waitFor", input, input.timeoutMs),
246 ),
247 preview_recording_start: McpToolAccess.actsAsCaller((input) =>
225248 invokeTargeted<PreviewAutomationRecordingStatus>("recordingStart", input ?? {}),
226 preview_recording_stop: (input) =>
249 ),
250 preview_recording_stop: McpToolAccess.actsAsCaller((input) =>
227251 Effect.gen(function* () {
228252 const scope = yield* McpInvocationContext.requireThreadMcpCapability("preview");
229253 const { tabId, ...operationInput } = input;
......@@ -236,12 +260,13 @@ const handlers = {
236260 const artifact = yield* claimPreviewRecording(scope.thread.threadId, response.result);
237261 return { ...artifact, ...(response.toolIcon ? { toolIcon: response.toolIcon } : {}) };
238262 }),
239} satisfies Parameters<typeof PreviewToolkit.toLayer>[0];
263 ),
264} satisfies McpToolAccess.Handlers<typeof PreviewToolkit.tools>;
240265
241266const { 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, {
246271 preview_snapshot,
247272});
apps/server/src/mcp/toolkits/preview/tools.ts+25-19
......@@ -1,4 +1,5 @@
11import {
2 OrchestratorMcpFailure,
23 ToolActivityIcon,
34 PreviewAutomationClickInput,
45 PreviewAutomationDialogInput,
......@@ -32,12 +33,17 @@ import { Tool, Toolkit } from "effect/ai";
3233import * as McpInvocationContext from "../../McpInvocationContext.ts";
3334import * as PreviewAutomationBroker from "../../PreviewAutomationBroker.ts";
3435import * as ServerConfig from "../../../config.ts";
36import * as ThreadManagementService from "../../../orchestration-v2/ThreadManagementService.ts";
3537
3638const dependencies = [
3739 McpInvocationContext.McpInvocationContext,
40 ThreadManagementService.ThreadManagementService,
3841 PreviewAutomationBroker.PreviewAutomationBroker,
3942];
4043
44/** What a browser action fails with, including the access gate's refusal. */
45const PreviewToolFailure = Schema.Union([PreviewAutomationError, OrchestratorMcpFailure]);
46
4147const presentationFields = { toolIcon: Schema.optional(ToolActivityIcon) };
4248
4349const PreviewActionResult = Schema.Struct(presentationFields).annotate({
......@@ -61,7 +67,7 @@ const PreviewStatusTool = Tool.make("preview_status", {
6167 "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.",
6268 parameters: PreviewAutomationTabTargetInput,
6369 success: PreviewAutomationStatus,
64 failure: PreviewAutomationError,
70 failure: PreviewToolFailure,
6571 dependencies,
6672})
6773 .annotate(Tool.Title, "Get preview status")
......@@ -75,7 +81,7 @@ const PreviewOpenTool = browserTool(
7581 "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.",
7682 parameters: PreviewAutomationOpenInput,
7783 success: PreviewAutomationStatus,
78 failure: PreviewAutomationError,
84 failure: PreviewToolFailure,
7985 dependencies,
8086 })
8187 .annotate(Tool.Title, "Open browser preview")
......@@ -88,7 +94,7 @@ const PreviewDialogTool = browserTool(
8894 "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.",
8995 parameters: PreviewAutomationDialogInput,
9096 success: PreviewAutomationStatus,
91 failure: PreviewAutomationError,
97 failure: PreviewToolFailure,
9298 dependencies,
9399 }).annotate(Tool.Title, "Resolve browser dialog"),
94100);
......@@ -99,7 +105,7 @@ const PreviewNavigateTool = safeBrowserTool(
99105 "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.",
100106 parameters: PreviewAutomationNavigateInput,
101107 success: PreviewAutomationStatus,
102 failure: PreviewAutomationError,
108 failure: PreviewToolFailure,
103109 dependencies,
104110 }).annotate(Tool.Title, "Navigate browser preview"),
105111);
......@@ -110,7 +116,7 @@ const PreviewResizeTool = safeBrowserTool(
110116 "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.",
111117 parameters: PreviewAutomationResizeInput,
112118 success: Schema.Struct({ ...PreviewAutomationResizeResult.fields, ...presentationFields }),
113 failure: PreviewAutomationError,
119 failure: PreviewToolFailure,
114120 dependencies,
115121 })
116122 .annotate(Tool.Title, "Resize browser viewport")
......@@ -126,7 +132,7 @@ const PreviewSetAppearanceTool = safeBrowserTool(
126132 ...PreviewAutomationSetColorSchemeResult.fields,
127133 ...presentationFields,
128134 }),
129 failure: PreviewAutomationError,
135 failure: PreviewToolFailure,
130136 dependencies,
131137 })
132138 .annotate(Tool.Title, "Set preview appearance")
......@@ -153,7 +159,7 @@ export const PreviewSnapshotTool = readonlyBrowserTool(
153159 ),
154160 }),
155161 success: PreviewAutomationSnapshot,
156 failure: PreviewAutomationError,
162 failure: PreviewToolFailure,
157163 dependencies,
158164 }).annotate(Tool.Title, "Inspect browser page"),
159165);
......@@ -164,7 +170,7 @@ const PreviewClickTool = browserTool(
164170 "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.",
165171 parameters: PreviewAutomationClickInput,
166172 success: PreviewActionResult,
167 failure: PreviewAutomationError,
173 failure: PreviewToolFailure,
168174 dependencies,
169175 }).annotate(Tool.Title, "Click preview page"),
170176);
......@@ -175,7 +181,7 @@ const PreviewTypeTool = browserTool(
175181 "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.",
176182 parameters: PreviewAutomationTypeInput,
177183 success: PreviewActionResult,
178 failure: PreviewAutomationError,
184 failure: PreviewToolFailure,
179185 dependencies,
180186 }).annotate(Tool.Title, "Type into preview page"),
181187);
......@@ -186,7 +192,7 @@ const PreviewHoverTool = safeBrowserTool(
186192 "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.",
187193 parameters: PreviewAutomationHoverInput,
188194 success: PreviewActionResult,
189 failure: PreviewAutomationError,
195 failure: PreviewToolFailure,
190196 dependencies,
191197 }).annotate(Tool.Title, "Hover preview page"),
192198);
......@@ -200,7 +206,7 @@ const PreviewSelectTool = browserTool(
200206 ...PreviewAutomationSelectResult.fields,
201207 ...presentationFields,
202208 }),
203 failure: PreviewAutomationError,
209 failure: PreviewToolFailure,
204210 dependencies,
205211 }).annotate(Tool.Title, "Select preview option"),
206212);
......@@ -211,7 +217,7 @@ const PreviewDragTool = browserTool(
211217 "Drag one element onto another in the tab selected by tabId, or this agent session's current tab when omitted. Server browser tabs only.",
212218 parameters: PreviewAutomationDragInput,
213219 success: PreviewActionResult,
214 failure: PreviewAutomationError,
220 failure: PreviewToolFailure,
215221 dependencies,
216222 }).annotate(Tool.Title, "Drag in preview page"),
217223);
......@@ -222,7 +228,7 @@ const PreviewUploadTool = browserTool(
222228 "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.",
223229 parameters: PreviewAutomationUploadInput,
224230 success: PreviewActionResult,
225 failure: PreviewAutomationError,
231 failure: PreviewToolFailure,
226232 dependencies,
227233 }).annotate(Tool.Title, "Upload files to preview page"),
228234);
......@@ -233,7 +239,7 @@ const PreviewPressTool = browserTool(
233239 "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']}.",
234240 parameters: PreviewAutomationPressInput,
235241 success: PreviewActionResult,
236 failure: PreviewAutomationError,
242 failure: PreviewToolFailure,
237243 dependencies,
238244 }).annotate(Tool.Title, "Press key in preview page"),
239245);
......@@ -244,7 +250,7 @@ const PreviewScrollTool = safeBrowserTool(
244250 "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.",
245251 parameters: PreviewAutomationScrollInput,
246252 success: PreviewActionResult,
247 failure: PreviewAutomationError,
253 failure: PreviewToolFailure,
248254 dependencies,
249255 }).annotate(Tool.Title, "Scroll preview page"),
250256);
......@@ -267,7 +273,7 @@ const PreviewEvaluateTool = browserTool(
267273 "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.",
268274 parameters: PreviewAutomationEvaluateInput,
269275 success: PreviewEvaluateResult,
270 failure: PreviewAutomationError,
276 failure: PreviewToolFailure,
271277 dependencies,
272278 }).annotate(Tool.Title, "Evaluate JavaScript in preview"),
273279);
......@@ -278,7 +284,7 @@ const PreviewWaitForTool = readonlyBrowserTool(
278284 "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.",
279285 parameters: PreviewAutomationWaitForInput,
280286 success: PreviewActionResult,
281 failure: PreviewAutomationError,
287 failure: PreviewToolFailure,
282288 dependencies,
283289 }).annotate(Tool.Title, "Wait for preview page condition"),
284290);
......@@ -289,7 +295,7 @@ const PreviewRecordingStartTool = safeBrowserTool(
289295 "Start recording the collaborative browser tab selected by tabId, or this agent session's current tab when omitted.",
290296 parameters: PreviewAutomationTabTargetInput,
291297 success: Schema.Struct({ ...PreviewAutomationRecordingStatus.fields, ...presentationFields }),
292 failure: PreviewAutomationError,
298 failure: PreviewToolFailure,
293299 dependencies,
294300 }).annotate(Tool.Title, "Start browser recording"),
295301);
......@@ -300,7 +306,7 @@ const PreviewRecordingStopTool = safeBrowserTool(
300306 "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.",
301307 parameters: PreviewAutomationTabTargetInput,
302308 success: Schema.Struct({ ...PreviewAutomationRecordingArtifact.fields, ...presentationFields }),
303 failure: PreviewAutomationError,
309 failure: PreviewToolFailure,
304310 dependencies: [...dependencies, FileSystem.FileSystem, ServerConfig.ServerConfig],
305311 }).annotate(Tool.Title, "Stop browser recording"),
306312);
apps/server/src/mcp/toolkits/previewControls/handlers.test.ts+8-1
......@@ -17,6 +17,8 @@ import * as ServerConfig from "../../../config.ts";
1717import * as Preview from "../../../preview/Manager.ts";
1818import * as ServerSettings from "../../../serverSettings.ts";
1919import * as McpInvocationContext from "../../McpInvocationContext.ts";
20import * as McpToolAccess from "../../McpToolAccess.ts";
21import * as McpToolAccessTestkit from "../../McpToolAccess.testkit.ts";
2022import * as PreviewAutomationBroker from "../../PreviewAutomationBroker.ts";
2123import * as PreviewControlsHandlers from "./handlers.ts";
2224import { PreviewControlsToolkit } from "./tools.ts";
......@@ -64,12 +66,17 @@ it.effect.each([
6466 PreviewAutomationBroker.layer.pipe(Layer.provide(NodeServices.layer)),
6567 Layer.succeed(Preview.PreviewManager, manager),
6668 Layer.succeed(McpInvocationContext.McpInvocationContext, scope),
69 McpToolAccessTestkit.liveThreadsLayer,
6770 Layer.mock(ServerSettings.ServerSettingsService)({
6871 getSettings: Effect.succeed(settings),
6972 }),
7073 );
7174 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 ),
7380 );
7481 const listed = yield* toolkit
7582 .handle("t3_preview_list", {})
apps/server/src/mcp/toolkits/previewControls/handlers.ts+6-3
......@@ -1,6 +1,7 @@
11import * as Effect from "effect/Effect";
22import * as Preview from "../../../preview/Manager.ts";
33import { requireThreadMcpCapability } from "../../McpInvocationContext.ts";
4import * as McpToolAccess from "../../McpToolAccess.ts";
45import { unavailable } from "../../threadAccess.ts";
56import { PreviewControlsToolkit } from "./tools.ts";
67import * as PreviewAutomationBroker from "../../PreviewAutomationBroker.ts";
......@@ -10,8 +11,8 @@ const access = Effect.gen(function* () {
1011 const scope = yield* requireThreadMcpCapability("preview");
1112 return { scope, manager: yield* Preview.PreviewManager };
1213});
13export const layer = PreviewControlsToolkit.toLayer({
14 t3_preview_list: (input) =>
14export const layer = McpToolAccess.toLayer(PreviewControlsToolkit, {
15 t3_preview_list: McpToolAccess.readsAsCaller((input) =>
1516 Effect.gen(function* () {
1617 const { scope, manager } = yield* access;
1718 const result = yield* manager.list({ threadId: scope.thread.threadId });
......@@ -23,7 +24,8 @@ export const layer = PreviewControlsToolkit.toLayer({
2324 nextCursor: end < result.sessions.length ? end : null,
2425 };
2526 }),
26 t3_preview_close: (input) =>
27 ),
28 t3_preview_close: McpToolAccess.actsAsCaller((input) =>
2729 Effect.gen(function* () {
2830 const { scope, manager } = yield* access;
2931 const { sessions } = yield* manager.list({ threadId: scope.thread.threadId });
......@@ -41,4 +43,5 @@ export const layer = PreviewControlsToolkit.toLayer({
4143 .pipe(Effect.mapError(unavailable));
4244 return {};
4345 }),
46 ),
4447});
apps/server/src/mcp/toolkits/previewControls/tools.ts+2
......@@ -7,6 +7,7 @@ import {
77} from "@t3tools/contracts";
88import * as Schema from "effect/Schema";
99import { Tool, Toolkit } from "effect/ai";
10import * as ThreadManagementService from "../../../orchestration-v2/ThreadManagementService.ts";
1011import * as PreviewManager from "../../../preview/Manager.ts";
1112import * as PreviewAutomationBroker from "../../PreviewAutomationBroker.ts";
1213import * as McpInvocationContext from "../../McpInvocationContext.ts";
......@@ -16,6 +17,7 @@ const shared = {
1617 failureMode: "return" as const,
1718 dependencies: [
1819 McpInvocationContext.McpInvocationContext,
20 ThreadManagementService.ThreadManagementService,
1921 PreviewManager.PreviewManager,
2022 PreviewAutomationBroker.PreviewAutomationBroker,
2123 ],
apps/server/src/mcp/toolkits/project/handlers.test.ts+101-7
......@@ -10,6 +10,8 @@ import {
1010import * as NodeCrypto from "@effect/platform-node/NodeCrypto";
1111import * as NodeServices from "@effect/platform-node/NodeServices";
1212import * as Effect from "effect/Effect";
13import * as FileSystem from "effect/FileSystem";
14import * as Path from "effect/Path";
1315import * as Layer from "effect/Layer";
1416import * as Option from "effect/Option";
1517import * as Stream from "effect/Stream";
......@@ -19,7 +21,10 @@ import * as ThreadManagement from "../../../orchestration-v2/ThreadManagementSer
1921import * as ServerConfig from "../../../config.ts";
2022import * as Project from "../../../project/ProjectService.ts";
2123import * as ManagedProjectFolders from "../../../project/ManagedProjectFolders.ts";
24import * as GitVcsDriver from "../../../vcs/GitVcsDriver.ts";
25import * as VcsProcess from "../../../vcs/VcsProcess.ts";
2226import * as McpInvocationContext from "../../McpInvocationContext.ts";
27import * as McpToolAccess from "../../McpToolAccess.ts";
2328import * as ProjectHandlers from "./handlers.ts";
2429import { ProjectToolkit } from "./tools.ts";
2530
......@@ -73,13 +78,18 @@ it.effect("attributes a launched thread's first message to the calling thread",
7378 }),
7479 Layer.mock(Project.ProjectService)({}),
7580 Layer.mock(ManagedProjectFolders.ManagedProjectFolders)({ namedProjectsRoot: "/projects" }),
81 Layer.mock(GitVcsDriver.GitVcsDriver)({}),
7682 NodeServices.layer,
7783 ServerConfig.layerTest(process.cwd(), { prefix: "t3-source-link-" }).pipe(
7884 Layer.provide(NodeServices.layer),
7985 ),
8086 );
8187 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 ),
8393 );
8494 const result = yield* toolkit
8595 .handle("t3_thread_launch", { title: "Audit", message: "Review the change" })
......@@ -142,13 +152,18 @@ it.effect("launches a scratch thread into the Scratch project", () =>
142152 namedProjectsRoot: "/projects",
143153 ensureScratchProject: Effect.succeed({ projectId: scratchProjectId }),
144154 }),
155 Layer.mock(GitVcsDriver.GitVcsDriver)({}),
145156 NodeServices.layer,
146157 ServerConfig.layerTest(process.cwd(), { prefix: "t3-scratch-launch-" }).pipe(
147158 Layer.provide(NodeServices.layer),
148159 ),
149160 );
150161 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 ),
152167 );
153168 const handle = (params: Parameters<typeof toolkit.handle<"t3_thread_launch">>[1]) =>
154169 toolkit
......@@ -240,13 +255,18 @@ it.effect("starts a project from just a title when workspaceRoot is omitted", ()
240255 };
241256 }),
242257 }),
258 Layer.mock(GitVcsDriver.GitVcsDriver)({}),
243259 NodeServices.layer,
244260 ServerConfig.layerTest(process.cwd(), { prefix: "t3-named-project-" }).pipe(
245261 Layer.provide(NodeServices.layer),
246262 ),
247263 );
248264 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 ),
250270 );
251271 const handle = (params: Parameters<typeof toolkit.handle<"t3_project_create">>[1]) =>
252272 toolkit
......@@ -280,6 +300,7 @@ it.effect("starts a project from just a title when workspaceRoot is omitted", ()
280300const clientLaunchHarness = (input: {
281301 readonly runtimeModeCeiling: "approval-required" | "auto-accept-edits" | "auto" | "full-access";
282302 readonly launched: Array<ThreadLaunch.ThreadLaunchInput>;
303 readonly workspaceRoot?: string;
283304}) => {
284305 const projectId = ProjectId.make("project:client-target");
285306 const modelSelection = { instanceId: ProviderInstanceId.make("claude"), model: "claude-opus" };
......@@ -319,15 +340,25 @@ const clientLaunchHarness = (input: {
319340 getById: (id) =>
320341 Effect.succeed(
321342 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)
323348 : Option.none(),
324349 ),
325350 }),
326351 Layer.mock(ManagedProjectFolders.ManagedProjectFolders)({ namedProjectsRoot: "/projects" }),
327352 NodeServices.layer,
328 ServerConfig.layerTest(process.cwd(), { prefix: "t3-client-launch-" }).pipe(
329 Layer.provide(NodeServices.layer),
353 ).pipe(
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 ),
330360 ),
361 Layer.provideMerge(NodeServices.layer),
331362 );
332363 return { projectId, modelSelection, dependencies: layerDependencies };
333364};
......@@ -340,7 +371,9 @@ it.effect("a client launches at its ceiling with the project's default model", (
340371 launched,
341372 });
342373 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 ),
344377 );
345378 const handle = (params: Parameters<typeof toolkit.handle<"t3_thread_launch">>[1]) =>
346379 toolkit
......@@ -360,3 +393,64 @@ it.effect("a client launches at its ceiling with the project's default model", (
360393 expect(launched).toHaveLength(1);
361394 }),
362395);
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 @@
11import { MessageId, ThreadId, OrchestratorMcpFailure, ProjectId } from "@t3tools/contracts";
22import * as Effect from "effect/Effect";
3import * as FileSystem from "effect/FileSystem";
34import * as Option from "effect/Option";
45import * as ThreadMessageIntake from "../../../orchestration-v2/ThreadMessageIntake.ts";
56import * as Claims from "../../../orchestration-v2/AttachmentClaims.ts";
67import * as Project from "../../../project/ProjectService.ts";
78import * as ManagedProjectFolders from "../../../project/ManagedProjectFolders.ts";
89import * as Repositories from "../../../sourceControl/SourceControlRepositoryService.ts";
9import { resolveRuntimeMode } from "../../OrchestratorMcpService.ts";
10import {
11 newCommandId,
12 readCaller,
13 readFullAccessCaller,
14 readMutationCaller,
15 resolveProjectId,
16 unavailable,
17} from "../../threadAccess.ts";
10import * as GitVcsDriver from "../../../vcs/GitVcsDriver.ts";
11import * as McpToolAccess from "../../McpToolAccess.ts";
12import { newCommandId, readCaller, resolveProjectId, unavailable } from "../../threadAccess.ts";
1813import { ProjectToolkit } from "./tools.ts";
1914
2015function projectFailure(error: Project.ProjectServiceError) {
......@@ -28,121 +23,137 @@ function projectFailure(error: Project.ProjectServiceError) {
2823 return new OrchestratorMcpFailure({ code: "invalid_request", message });
2924}
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
3150const access = Effect.gen(function* () {
3251 yield* readCaller();
3352 return yield* Project.ProjectService;
3453});
35const mutation = Effect.gen(function* () {
36 yield* readFullAccessCaller(
37 "Project changes require a live full-access/default calling thread or a full-access client.",
38 );
39 return yield* Project.ProjectService;
40});
41export const layer = ProjectToolkit.toLayer({
42 t3_thread_launch: (input) =>
43 Effect.gen(function* () {
44 const context = yield* readMutationCaller();
45 const { caller, limits } = context;
46 // A thread caller launches only as itself (full-access/default), as before. A client
47 // launches anything up to its ceiling.
48 if (
49 caller !== undefined &&
50 (caller.runtimeMode !== "full-access" || caller.interactionMode !== "default")
51 )
52 return yield* new OrchestratorMcpFailure({
53 code: "capability_denied",
54 message: "Project launches require a full-access/default calling thread.",
55 });
56 const runtimeMode = yield* resolveRuntimeMode(
57 limits.runtimeMode,
58 input.runtimeMode ?? caller?.runtimeMode,
59 );
60 const commandId = yield* newCommandId();
61 const threadId = ThreadId.make(commandId);
62 const messageId = MessageId.make(commandId);
63 const attachments = input.attachments ?? [];
64 if (attachments.some((attachment) => !Claims.attachmentIsPendingUpload(attachment)))
65 return yield* new OrchestratorMcpFailure({
66 code: "invalid_request",
67 message: "A new thread accepts only pending attachment uploads.",
68 });
69 if (
70 input.scratch === true &&
71 (input.projectId !== undefined || input.workspaceStrategy !== undefined)
72 )
73 return yield* new OrchestratorMcpFailure({
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(
54export const layer = McpToolAccess.toLayer(ProjectToolkit, {
55 t3_thread_launch: McpToolAccess.startsThreads(
56 (input) => input,
57 (input, { runtimeMode, interactionMode }) =>
58 Effect.gen(function* () {
59 const context = yield* readCaller();
60 const { caller } = context;
61 const commandId = yield* newCommandId();
62 const threadId = ThreadId.make(commandId);
63 const messageId = MessageId.make(commandId);
64 const attachments = input.attachments ?? [];
65 if (attachments.some((attachment) => !Claims.attachmentIsPendingUpload(attachment)))
66 return yield* new OrchestratorMcpFailure({
67 code: "invalid_request",
68 message: "A new thread accepts only pending attachment uploads.",
69 });
70 if (
71 input.scratch === true &&
72 (input.projectId !== undefined || input.workspaceStrategy !== undefined)
73 )
74 return yield* new OrchestratorMcpFailure({
75 code: "invalid_request",
76 message:
77 "scratch:true picks its own project and folder; omit projectId and workspaceStrategy.",
78 });
79 const projectId =
80 input.scratch === true
81 ? (yield* ManagedProjectFolders.ManagedProjectFolders.pipe(
82 Effect.flatMap((folders) => folders.ensureScratchProject),
83 Effect.mapError(
84 (error) =>
85 new OrchestratorMcpFailure({
86 code: "orchestration_error",
87 message: error.message,
88 }),
89 ),
90 )).projectId
91 : yield* resolveProjectId(context, input.projectId);
92 const readProject = Project.ProjectService.pipe(
9593 Effect.flatMap((projects) => projects.getById(projectId)),
9694 Effect.mapError(unavailable),
97 Effect.map((project) =>
98 Option.getOrUndefined(Option.flatMapNullishOr(project, (p) => p.defaultModelSelection)),
95 Effect.map(Option.getOrUndefined),
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(),
99143 ),
100 ));
101 if (modelSelection === undefined)
102 return yield* new OrchestratorMcpFailure({
103 code: "invalid_request",
104 message:
105 "Pass modelSelection: the project has no default model. orchestrator_capabilities lists providers and models.",
106 });
107 const result = yield* ThreadMessageIntake.launchThread({
108 commandId,
109 threadId,
110 projectId,
111 title: input.title,
112 modelSelection,
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) =>
144 );
145 const thread = result.projection.thread;
146 const run = result.projection.runs.find((run) => run.userMessageId === messageId);
147 return {
148 threadId: thread.id,
149 projectId: thread.projectId,
150 modelSelection: thread.modelSelection,
151 runId: run?.id ?? null,
152 status: run?.status ?? null,
153 };
154 }),
155 ),
156 t3_project_list: McpToolAccess.reads((input) =>
146157 Effect.gen(function* () {
147158 const projects = yield* access;
148159 const snapshot = yield* projects.snapshot.pipe(Effect.mapError(unavailable));
......@@ -151,7 +162,8 @@ export const layer = ProjectToolkit.toLayer({
151162 end = start + (input.limit ?? 20);
152163 return { projects: rows.slice(start, end), nextCursor: end < rows.length ? end : null };
153164 }),
154 t3_project_read: (input) =>
165 ),
166 t3_project_read: McpToolAccess.reads((input) =>
155167 Effect.gen(function* () {
156168 const projects = yield* access;
157169 const result = yield* projects.getById(input.projectId).pipe(Effect.mapError(unavailable));
......@@ -162,9 +174,10 @@ export const layer = ProjectToolkit.toLayer({
162174 });
163175 return result.value;
164176 }),
165 t3_project_create: ({ workspaceRoot, ...input }) =>
177 ),
178 t3_project_create: McpToolAccess.writesEnvironment(({ workspaceRoot, ...input }) =>
166179 Effect.gen(function* () {
167 const projects = yield* mutation;
180 const projects = yield* Project.ProjectService;
168181 if (workspaceRoot === undefined) {
169182 // Project creation records no model default (only an update does), so
170183 // reject what this mode would otherwise drop silently.
......@@ -205,23 +218,25 @@ export const layer = ProjectToolkit.toLayer({
205218 .create({ ...input, workspaceRoot, commandId, projectId: ProjectId.make(commandId) })
206219 .pipe(Effect.mapError(projectFailure));
207220 }),
208 t3_project_update: (input) =>
221 ),
222 t3_project_update: McpToolAccess.writesEnvironment((input) =>
209223 Effect.gen(function* () {
210 const projects = yield* mutation;
224 const projects = yield* Project.ProjectService;
211225 return yield* projects
212226 .update({ ...input, commandId: yield* newCommandId() })
213227 .pipe(Effect.mapError(projectFailure));
214228 }),
215 t3_project_delete: (input) =>
229 ),
230 t3_project_delete: McpToolAccess.writesEnvironment((input) =>
216231 Effect.gen(function* () {
217 const projects = yield* mutation;
232 const projects = yield* Project.ProjectService;
218233 return yield* projects
219234 .delete({ ...input, commandId: yield* newCommandId() })
220235 .pipe(Effect.mapError(projectFailure));
221236 }),
222 t3_project_clone: (input) =>
237 ),
238 t3_project_clone: McpToolAccess.writesEnvironment((input) =>
223239 Effect.gen(function* () {
224 yield* mutation;
225240 const repositories = yield* Repositories.SourceControlRepositoryService;
226241 return yield* repositories.cloneRepository(input).pipe(
227242 Effect.mapError(
......@@ -233,4 +248,5 @@ export const layer = ProjectToolkit.toLayer({
233248 ),
234249 );
235250 }),
251 ),
236252});
apps/server/src/mcp/toolkits/project/tools.ts+4-2
......@@ -27,6 +27,7 @@ import * as ProjectService from "../../../project/ProjectService.ts";
2727import * as ManagedProjectFolders from "../../../project/ManagedProjectFolders.ts";
2828import * as ThreadManagementService from "../../../orchestration-v2/ThreadManagementService.ts";
2929import * as SourceControlRepositoryService from "../../../sourceControl/SourceControlRepositoryService.ts";
30import * as GitVcsDriver from "../../../vcs/GitVcsDriver.ts";
3031import * as McpInvocationContext from "../../McpInvocationContext.ts";
3132
3233const shared = {
......@@ -102,7 +103,7 @@ const ProjectCloneTool = Tool.make("t3_project_clone", {
102103const ThreadLaunchTool = Tool.make("t3_thread_launch", {
103104 ...shared,
104105 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.',
106107 parameters: Schema.Struct({
107108 projectId: Schema.optional(ProjectId),
108109 scratch: Schema.optional(
......@@ -118,7 +119,7 @@ const ThreadLaunchTool = Tool.make("t3_thread_launch", {
118119 workspaceStrategy: Schema.optional(
119120 OrchestrationV2ThreadLaunchWorkspaceStrategy.annotate({
120121 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.",
122123 }),
123124 ),
124125 message: Schema.optional(
......@@ -140,6 +141,7 @@ const ThreadLaunchTool = Tool.make("t3_thread_launch", {
140141 ...shared.dependencies,
141142 ThreadLaunchService.ThreadLaunchService,
142143 ManagedProjectFolders.ManagedProjectFolders,
144 GitVcsDriver.GitVcsDriver,
143145 FileSystem.FileSystem,
144146 ServerConfig.ServerConfig,
145147 ],
apps/server/src/mcp/toolkits/pullRequests/handlers.test.ts+8-1
......@@ -23,6 +23,8 @@ import {
2323} from "../../../orchestration-v2/testkit/pullRequestFixtures.ts";
2424import * as ProjectService from "../../../project/ProjectService.ts";
2525import * as McpInvocationContext from "../../McpInvocationContext.ts";
26import * as McpToolAccess from "../../McpToolAccess.ts";
27import * as McpToolAccessTestkit from "../../McpToolAccess.testkit.ts";
2628import { listThreadPullRequests } from "./handlers.ts";
2729import * as PullRequestsHandlers from "./handlers.ts";
2830import { PullRequestLinkFailedError, PullRequestsToolkit } from "./tools.ts";
......@@ -163,9 +165,14 @@ const makeHarness = Effect.fn("makePullRequestsToolkitHarness")(function* (
163165 dispatch,
164166 }),
165167 Layer.succeed(Crypto.Crypto, testCrypto),
168 McpToolAccessTestkit.liveThreadsLayer,
166169 );
167170 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 ),
169176 );
170177 const call = <Name extends keyof typeof PullRequestsToolkit.tools>(
171178 name: Name,
apps/server/src/mcp/toolkits/pullRequests/handlers.ts+20-54
......@@ -26,7 +26,7 @@ import * as Option from "effect/Option";
2626import * as Orchestrator from "../../../orchestration-v2/Orchestrator.ts";
2727import * as ProjectService from "../../../project/ProjectService.ts";
2828import * as McpInvocationContext from "../../McpInvocationContext.ts";
29import { assertTargetWithinLimits } from "../../threadAccess.ts";
29import * as McpToolAccess from "../../McpToolAccess.ts";
3030import {
3131 type ListThreadPullRequestsResult,
3232 PullRequestLinkFailedError,
......@@ -40,7 +40,6 @@ import {
4040 PullRequestWatchFailedError,
4141 PullRequestWatchFromSubagentError,
4242 PullRequestThreadNotFoundError,
43 PullRequestThreadAboveLimitsError,
4443 PullRequestThreadRequiredError,
4544 PullRequestsToolkit,
4645 type ThreadPullRequestEntry,
......@@ -189,47 +188,6 @@ const make = Effect.gen(function* () {
189188 return thread.value;
190189 });
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
233191 const projectOf = (
234192 thread: OrchestrationV2ThreadShell,
235193 Failure:
......@@ -267,7 +225,7 @@ const make = Effect.gen(function* () {
267225 input: PullRequestTargetInput,
268226 watching: boolean,
269227 ) {
270 const thread = yield* requireWritableThread(PullRequestWatchFailedError, input.threadId);
228 const thread = yield* requireThread(PullRequestWatchFailedError, input.threadId);
271229 const project = yield* projectOf(thread, PullRequestWatchFailedError);
272230 const target = yield* resolveTarget(input, project);
273231 const watchedLink = (shell: OrchestrationV2ThreadShell) =>
......@@ -306,10 +264,15 @@ const make = Effect.gen(function* () {
306264 };
307265 });
308266
309 return PullRequestsToolkit.of({
310 link_pull_request: (input) =>
267 /** A tool that changes `threadId`, or the caller's own thread when it is omitted. */
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) =>
311274 Effect.gen(function* () {
312 const thread = yield* requireWritableThread(PullRequestLinkFailedError, input.threadId);
275 const thread = yield* requireThread(PullRequestLinkFailedError, input.threadId);
313276 const project = yield* projectOf(thread, PullRequestLinkFailedError);
314277 const target = yield* resolveTarget(input, project);
315278 const existing = threadPullRequestsOf(thread).find((link) =>
......@@ -337,9 +300,10 @@ const make = Effect.gen(function* () {
337300 );
338301 return { ...target, alreadyLinked };
339302 }),
340 unlink_pull_request: (input) =>
303 ),
304 unlink_pull_request: writesThread((input) =>
341305 Effect.gen(function* () {
342 const thread = yield* requireWritableThread(PullRequestUnlinkFailedError, input.threadId);
306 const thread = yield* requireThread(PullRequestUnlinkFailedError, input.threadId);
343307 const project = yield* projectOf(thread, PullRequestUnlinkFailedError);
344308 const target = yield* resolveTarget(input, project);
345309 if (!threadPullRequestsOf(thread).some((link) => threadPullRequestKeysEqual(link, target)))
......@@ -370,13 +334,15 @@ const make = Effect.gen(function* () {
370334 wasLinked,
371335 };
372336 }),
373 list_thread_pull_requests: (input) =>
337 ),
338 list_thread_pull_requests: McpToolAccess.reads((input) =>
374339 requireThread(PullRequestListFailedError, input.threadId).pipe(
375340 Effect.map(listThreadPullRequests),
376341 ),
377 watch_pull_request: (input) => setWatching(input, true),
378 unwatch_pull_request: (input) => setWatching(input, false),
379 });
342 ),
343 watch_pull_request: writesThread((input) => setWatching(input, true)),
344 unwatch_pull_request: writesThread((input) => setWatching(input, false)),
345 } satisfies McpToolAccess.Handlers<typeof PullRequestsToolkit.tools>;
380346});
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 @@
11import {
22 McpCapabilityUnavailableError,
3 OrchestratorMcpFailure,
34 PositiveInt,
45 PullRequestState,
56 ThreadPullRequestLinkSource,
......@@ -12,10 +13,12 @@ import * as Toolkit from "effect/ai/Toolkit";
1213
1314import * as McpInvocationContext from "../../McpInvocationContext.ts";
1415import * as Orchestrator from "../../../orchestration-v2/Orchestrator.ts";
16import * as ThreadManagementService from "../../../orchestration-v2/ThreadManagementService.ts";
1517import * as ProjectService from "../../../project/ProjectService.ts";
1618
1719const dependencies = [
1820 McpInvocationContext.McpInvocationContext,
21 ThreadManagementService.ThreadManagementService,
1922 Orchestrator.OrchestratorV2,
2023 ProjectService.ProjectService,
2124];
......@@ -96,15 +99,6 @@ export class PullRequestThreadRequiredError extends Schema.TaggedError<PullReque
9699 }
97100}
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
108102export class PullRequestThreadNotFoundError extends Schema.TaggedError<PullRequestThreadNotFoundError>()(
109103 "PullRequestThreadNotFoundError",
110104 { threadId: Schema.String },
......@@ -169,12 +163,12 @@ export class PullRequestListFailedError extends Schema.TaggedError<PullRequestLi
169163}
170164
171165export const PullRequestToolError = Schema.Union([
166 OrchestratorMcpFailure,
172167 McpCapabilityUnavailableError,
173168 PullRequestUrlInvalidError,
174169 PullRequestTargetIncompleteError,
175170 PullRequestHostRequiredError,
176171 PullRequestThreadRequiredError,
177 PullRequestThreadAboveLimitsError,
178172 PullRequestThreadNotFoundError,
179173 PullRequestLinkFailedError,
180174 PullRequestUnlinkFailedError,
apps/server/src/mcp/toolkits/thread/handlers.ts+69-54
......@@ -10,13 +10,12 @@ import {
1010import * as Effect from "effect/Effect";
1111import { modelSelectionCommandType } from "@t3tools/shared/model";
1212
13import * as McpToolAccess from "../../McpToolAccess.ts";
1314import {
1415 dispatchFailure,
1516 newCommandId,
1617 readCaller,
17 readFullAccessCaller,
1818 readThread,
19 readWritableThread,
2019 unavailable,
2120} from "../../threadAccess.ts";
2221import * as ThreadSearch from "../../../orchestration-v2/ThreadSearch.ts";
......@@ -43,23 +42,18 @@ const dispatch = Effect.fn("mcp.dispatchThreadCommand")(function* (
4342 threadId: ThreadId | undefined,
4443 command: (common: { commandId: CommandId; threadId: ThreadId }) => OrchestrationV2Command,
4544) {
46 const { threads, projection } = yield* readWritableThread(threadId);
45 const { threads, projection } = yield* readThread(threadId);
4746 const result = yield* threads
4847 .dispatch(command({ commandId: yield* newCommandId(), threadId: projection.thread.id }))
4948 .pipe(Effect.mapError(dispatchFailure));
5049 return { sequence: result.sequence };
5150});
5251
53const readQuestion = Effect.fn("mcp.readQuestion")(function* (
54 input: {
55 threadId?: ThreadId | undefined;
56 requestId: RuntimeRequestId;
57 },
58 writable = false,
59) {
60 const context = yield* writable
61 ? readWritableThread(input.threadId, ["runtimeRequests", "turnItems"])
62 : readThread(input.threadId, ["runtimeRequests", "turnItems"]);
52const readQuestion = Effect.fn("mcp.readQuestion")(function* (input: {
53 threadId?: ThreadId | undefined;
54 requestId: RuntimeRequestId;
55}) {
56 const context = yield* readThread(input.threadId, ["runtimeRequests", "turnItems"]);
6357 const request = context.projection.runtimeRequests.find(
6458 (request) =>
6559 request.id === input.requestId &&
......@@ -76,12 +70,14 @@ const readQuestion = Effect.fn("mcp.readQuestion")(function* (
7670 });
7771 return { ...context, request, item };
7872});
79export const layer = ThreadToolkit.toLayer({
80 run_scheduled_task_now: (input) =>
73/** A tool that changes `threadId`, or the caller's own thread when it is omitted. */
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) =>
8180 Effect.gen(function* () {
82 yield* readFullAccessCaller(
83 "Running a scheduled task requires a live full-access/default thread or a full-access client.",
84 );
8581 const scheduler = yield* ScheduledTasks.ScheduledTaskService;
8682 const { tasks } = yield* scheduler.list().pipe(Effect.mapError(unavailable));
8783 if (!tasks.some((task) => task.id === input.taskId))
......@@ -100,7 +96,8 @@ export const layer = ThreadToolkit.toLayer({
10096 nextRunAt: task.nextRunAt,
10197 };
10298 }),
103 t3_thread_search: (input) =>
99 ),
100 t3_thread_search: McpToolAccess.reads((input) =>
104101 Effect.gen(function* () {
105102 const { caller } = yield* readCaller();
106103 const { projectId: requested, ...query } = input;
......@@ -116,9 +113,10 @@ export const layer = ThreadToolkit.toLayer({
116113 : result.matches.filter((match) => match.projectId === projectId),
117114 };
118115 }),
119 t3_thread_fork: (input) =>
116 ),
117 t3_thread_fork: writesThread((input) =>
120118 Effect.gen(function* () {
121 const { threads, projection } = yield* readWritableThread(input.threadId);
119 const { threads, projection } = yield* readThread(input.threadId);
122120 const commandId = yield* newCommandId();
123121 const targetThreadId = ThreadId.make(`${commandId}:fork`);
124122 const result = yield* threads
......@@ -135,24 +133,28 @@ export const layer = ThreadToolkit.toLayer({
135133 .pipe(Effect.mapError(dispatchFailure));
136134 return { sequence: result.sequence, targetThreadId };
137135 }),
138 t3_thread_merge_back: (input) =>
139 Effect.gen(function* () {
140 const context = yield* readWritableThread(input.targetThreadId);
141 const source = yield* readWritableThread(input.sourceThreadId);
142 const result = yield* context.threads
143 .dispatch({
144 type: "thread.merge_back",
145 commandId: yield* newCommandId(),
146 sourceThreadId: source.projection.thread.id,
147 targetThreadId: input.targetThreadId,
148 sourcePoint: input.sourcePoint,
149 createdBy: "agent",
150 creationSource: "mcp",
151 })
152 .pipe(Effect.mapError(dispatchFailure));
153 return { sequence: result.sequence, targetThreadId: input.targetThreadId };
154 }),
155 t3_thread_transfers: (input) =>
136 ),
137 t3_thread_merge_back: McpToolAccess.writesThreads(
138 (input) => [input.targetThreadId, input.sourceThreadId],
139 (input) =>
140 Effect.gen(function* () {
141 const context = yield* readThread(input.targetThreadId);
142 const source = yield* readThread(input.sourceThreadId);
143 const result = yield* context.threads
144 .dispatch({
145 type: "thread.merge_back",
146 commandId: yield* newCommandId(),
147 sourceThreadId: source.projection.thread.id,
148 targetThreadId: input.targetThreadId,
149 sourcePoint: input.sourcePoint,
150 createdBy: "agent",
151 creationSource: "mcp",
152 })
153 .pipe(Effect.mapError(dispatchFailure));
154 return { sequence: result.sequence, targetThreadId: input.targetThreadId };
155 }),
156 ),
157 t3_thread_transfers: McpToolAccess.reads((input) =>
156158 Effect.gen(function* () {
157159 const { projection } = yield* readThread(input.threadId, ["contextTransfers"]);
158160 return {
......@@ -166,7 +168,8 @@ export const layer = ThreadToolkit.toLayer({
166168 ),
167169 };
168170 }),
169 t3_thread_configuration: (input) =>
171 ),
172 t3_thread_configuration: McpToolAccess.reads((input) =>
170173 Effect.gen(function* () {
171174 const {
172175 projection: { thread },
......@@ -178,12 +181,13 @@ export const layer = ThreadToolkit.toLayer({
178181 interactionMode: thread.interactionMode,
179182 };
180183 }),
181 t3_thread_configure: (input) =>
184 ),
185 t3_thread_configure: writesThread((input) =>
182186 Effect.gen(function* () {
183187 const {
184188 threads,
185189 projection: { thread },
186 } = yield* readWritableThread(input.threadId);
190 } = yield* readThread(input.threadId);
187191 const type = modelSelectionCommandType(thread.providerInstanceId, input.modelSelection);
188192 const result = yield* threads
189193 .dispatch({
......@@ -195,7 +199,8 @@ export const layer = ThreadToolkit.toLayer({
195199 .pipe(Effect.mapError(dispatchFailure));
196200 return { sequence: result.sequence };
197201 }),
198 t3_pending_request_list: (input) =>
202 ),
203 t3_pending_request_list: McpToolAccess.reads((input) =>
199204 Effect.gen(function* () {
200205 const { projection } = yield* readThread(input.threadId, ["runtimeRequests"]);
201206 return {
......@@ -204,14 +209,16 @@ export const layer = ThreadToolkit.toLayer({
204209 .map((request) => request.id),
205210 };
206211 }),
207 t3_pending_request_read: (input) =>
212 ),
213 t3_pending_request_read: McpToolAccess.reads((input) =>
208214 Effect.gen(function* () {
209215 const { item } = yield* readQuestion(input);
210216 return { requestId: input.requestId, questions: item.questions };
211217 }),
212 t3_pending_request_respond: (input) =>
218 ),
219 t3_pending_request_respond: writesThread((input) =>
213220 Effect.gen(function* () {
214 const { threads, projection } = yield* readQuestion(input, true);
221 const { threads, projection } = yield* readQuestion(input);
215222 const result = yield* threads
216223 .dispatch({
217224 type: "runtime-request.respond",
......@@ -223,7 +230,8 @@ export const layer = ThreadToolkit.toLayer({
223230 .pipe(Effect.mapError(dispatchFailure));
224231 return { sequence: result.sequence };
225232 }),
226 t3_queue_list: (input) =>
233 ),
234 t3_queue_list: McpToolAccess.reads((input) =>
227235 Effect.gen(function* () {
228236 const { projection } = yield* readThread(input.threadId, ["runs", "messages"]);
229237 const runs = queuedRunsInDeliveryOrder(projection);
......@@ -237,7 +245,8 @@ export const layer = ThreadToolkit.toLayer({
237245 nextCursor: end < runs.length ? end : null,
238246 };
239247 }),
240 t3_queue_read: (input) =>
248 ),
249 t3_queue_read: McpToolAccess.reads((input) =>
241250 Effect.gen(function* () {
242251 const { projection } = yield* readThread(input.threadId, ["runs", "messages"]);
243252 const entry = queueEntry(projection, input.queuedRunId, 16000);
......@@ -249,36 +258,41 @@ export const layer = ThreadToolkit.toLayer({
249258 }))
250259 );
251260 }),
252 t3_queue_edit: (input) =>
261 ),
262 t3_queue_edit: writesThread((input) =>
253263 dispatch(input.threadId, (common) => ({
254264 ...common,
255265 type: "queued-run.edit",
256266 runId: input.queuedRunId,
257267 text: input.text,
258268 })),
259 t3_queue_cancel: (input) =>
269 ),
270 t3_queue_cancel: writesThread((input) =>
260271 dispatch(input.threadId, (common) => ({
261272 ...common,
262273 type: "queued-run.cancel",
263274 runId: input.queuedRunId,
264275 })),
265 t3_queue_reorder: (input) =>
276 ),
277 t3_queue_reorder: writesThread((input) =>
266278 dispatch(input.threadId, (common) => ({
267279 ...common,
268280 type: "queued-run.reorder",
269281 runId: input.queuedRunId,
270282 beforeRunId: input.beforeRunId,
271283 })),
272 t3_queue_promote_to_steer: (input) =>
284 ),
285 t3_queue_promote_to_steer: writesThread((input) =>
273286 dispatch(input.threadId, (common) => ({
274287 ...common,
275288 type: "queued-message.promote-to-steer",
276289 queuedRunId: input.queuedRunId,
277290 targetRunId: input.targetRunId,
278291 })),
279 t3_thread_organize: (input) =>
292 ),
293 t3_thread_organize: writesThread((input) =>
280294 Effect.gen(function* () {
281 const { threads, projection } = yield* readWritableThread(input.threadId);
295 const { threads, projection } = yield* readThread(input.threadId);
282296 const common = { commandId: yield* newCommandId(), threadId: projection.thread.id };
283297 let command: OrchestrationV2Command;
284298 switch (input.action) {
......@@ -304,4 +318,5 @@ export const layer = ThreadToolkit.toLayer({
304318 const result = yield* threads.dispatch(command).pipe(Effect.mapError(dispatchFailure));
305319 return { sequence: result.sequence };
306320 }),
321 ),
307322});
apps/server/src/mcp/toolkits/worktree/handlers.ts+9-5
......@@ -6,11 +6,12 @@ import { readThread, unavailable } from "../../threadAccess.ts";
66import * as Effect from "effect/Effect";
77
88import * as McpInvocationContext from "../../McpInvocationContext.ts";
9import * as McpToolAccess from "../../McpToolAccess.ts";
910import * as WorktreeMcpService from "../../WorktreeMcpService.ts";
1011import { WorktreeToolkit } from "./tools.ts";
1112
1213const handlers = {
13 t3_worktree_list: (input) =>
14 t3_worktree_list: McpToolAccess.reads((input) =>
1415 Effect.gen(function* () {
1516 const context = yield* McpInvocationContext.McpInvocationContext;
1617 if (!context.capabilities.has("worktree"))
......@@ -34,18 +35,21 @@ const handlers = {
3435 .listRefs({ ...refs, cwd: thread.worktreePath ?? project.value.workspaceRoot })
3536 .pipe(Effect.mapError(unavailable));
3637 }),
37 t3_worktree_handoff: (input) =>
38 ),
39 t3_worktree_handoff: McpToolAccess.actsAsCaller((input) =>
3840 Effect.gen(function* () {
3941 const scope = yield* McpInvocationContext.McpInvocationContext;
4042 const service = yield* WorktreeMcpService.WorktreeMcpService;
4143 return yield* service.handoff(scope, input);
4244 }),
43 t3_worktree_status: () =>
45 ),
46 t3_worktree_status: McpToolAccess.readsAsCaller(() =>
4447 Effect.gen(function* () {
4548 const scope = yield* McpInvocationContext.McpInvocationContext;
4649 const service = yield* WorktreeMcpService.WorktreeMcpService;
4750 return yield* service.status(scope);
4851 }),
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";
1919
2020const dependencies = [
2121 McpInvocationContext.McpInvocationContext,
22 ThreadManagementService.ThreadManagementService,
2223 WorktreeMcpService.WorktreeMcpService,
2324];
2425
26/** What handoff and status fail with, including the access gate's refusal. */
27const WorktreeToolFailure = Schema.Union([WorktreeMcpFailure, OrchestratorMcpFailure]);
28
2529const WorktreeHandoffTool = Tool.make("t3_worktree_handoff", {
2630 description:
2731 "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.",
2832 parameters: WorktreeMcpHandoffInput,
2933 success: WorktreeMcpHandoffResult,
30 failure: WorktreeMcpFailure,
34 failure: WorktreeToolFailure,
3135 failureMode: "return",
3236 dependencies,
3337})
......@@ -45,7 +49,7 @@ const WorktreeStatusTool = Tool.make("t3_worktree_status", {
4549 // Schema.Struct({}) serializes to `anyOf: [object, array]`, which is not a
4650 // valid MCP tool input schema and makes clients reject the whole server.
4751 success: WorktreeMcpStatusResult,
48 failure: WorktreeMcpFailure,
52 failure: WorktreeToolFailure,
4953 failureMode: "return",
5054 dependencies,
5155})
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 {
4646 latestProviderTurnForAttempt,
4747 orchestrationV2RunWorkStartedAt,
4848 ProviderInstanceId,
49 ProviderInteractionMode,
4950 type ProviderSessionId,
5051 RunId,
52 RuntimeMode,
5153 ThreadLinkedPullRequest,
5254 ThreadId,
5355 type TurnItemId,
......@@ -86,6 +88,7 @@ import * as EffectOutbox from "./EffectOutbox.ts";
8688import type { OrchestrationEffectRequestV2, PendingOrchestrationEffectV2 } from "./EffectOutbox.ts";
8789import { IdAllocatorV2 } from "./IdAllocator.ts";
8890import * as ThreadCommandExecutor from "./ThreadCommandExecutor.ts";
91import { DispatchModeLimit, exceededDispatchModeLimit } from "./DispatchModeLimit.ts";
8992import {
9093 applyToProjection,
9194 emptyProjection,
......@@ -187,6 +190,22 @@ export class OrchestratorSubagentThreadReadOnlyError extends Schema.TaggedError<
187190 }
188191}
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
190209export class OrchestratorCommandPreviouslyRejectedError extends Schema.TaggedError<OrchestratorCommandPreviouslyRejectedError>()(
191210 "OrchestratorCommandPreviouslyRejectedError",
192211 {
......@@ -236,6 +255,7 @@ export const OrchestratorV2Error = Schema.Union([
236255 OrchestratorCommandPreviouslyRejectedError,
237256 OrchestratorCommandIdConflictError,
238257 OrchestratorSubagentThreadReadOnlyError,
258 OrchestratorThreadAboveModeLimitError,
239259]);
240260export type OrchestratorV2Error = typeof OrchestratorV2Error.Type;
241261
......@@ -3418,6 +3438,33 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
34183438 },
34193439 );
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
34213468 const dispatchThreadFork = Effect.fn("orchestrationV2.dispatch.threadFork")(function* (
34223469 command: Extract<OrchestrationV2Command, { readonly type: "thread.fork" }>,
34233470 events: Ref.Ref<Array<OrchestrationV2DomainEvent>>,
......@@ -3448,6 +3495,9 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
34483495 }),
34493496 ),
34503497 );
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
34523502 const sourceRun = runForSourcePoint(sourceProjection, command.sourcePoint);
34533503
......@@ -3536,6 +3586,9 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
35363586 }),
35373587 ),
35383588 );
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);
35393592 const targetProjection = yield* projectionStore
35403593 .getThreadRecords(command.targetThreadId, ["contextTransfers"])
35413594 .pipe(
......@@ -10404,6 +10457,19 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
1040410457 } satisfies OrchestratorV2DispatchResult;
1040510458 }
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
1040710473 const plan = yield* dispatchOnce(command).pipe(
1040810474 Effect.flatMap((planned) =>
1040910475 // A settle that finds the provider already ended everything, or a
......@@ -10423,6 +10489,9 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
1042310489 ),
1042410490 Effect.catch((cause) =>
1042510491 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;
1042610495 const rejectedAt = yield* DateTime.now;
1042710496 const receipt = yield* eventSink
1042810497 .commitRejectedCommand({
apps/server/src/orchestration-v2/ThreadMessageIntake.ts+1
......@@ -23,6 +23,7 @@ function dispatchWasNotAccepted(
2323 case "OrchestratorCommandPreviouslyRejectedError":
2424 case "OrchestratorCommandIdConflictError":
2525 case "OrchestratorSubagentThreadReadOnlyError":
26 case "OrchestratorThreadAboveModeLimitError":
2627 return true;
2728 default:
2829 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
2020- Existing worktree: \`{"title":"Continue cleanup","workspaceStrategy":{"type":"existing_worktree","worktreePath":"/absolute/path/to/worktree","branch":"feature/ui-cleanup"},"message":"Continue the cleanup."}\`
2121- 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
2525\`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<
398398 readonly pruneWorktrees: (input: {
399399 readonly cwd: string;
400400 }) => 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>;
401406 readonly deleteLocalBranch: (
402407 input: GitDeleteLocalBranchInput,
403408 ) => Effect.Effect<void, GitCommandError>;
apps/server/src/vcs/GitVcsDriverCore.ts+21
......@@ -3954,6 +3954,26 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
39543954 }),
39553955 );
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
39573977 const withListRefsInvalidation = <A, E>(
39583978 cwd: string,
39593979 effect: Effect.Effect<A, E>,
......@@ -4024,5 +4044,6 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
40244044 switchRef: (input) => withListRefsInvalidation(input.cwd, switchRef(input)),
40254045 initRepo: initRepoWithListRefsInvalidation,
40264046 listLocalBranchNames,
4047 listWorktreePaths,
40274048 });
40284049});
docs/orchestration-v2/orchestrator-mcp-server.md+2-2
......@@ -325,8 +325,8 @@ this binding.
325325Pass the task in `message`. Project, model, and modes inherit when omitted;
326326workspace does not. `scratch: true` launches without a project, in a folder of
327327its 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 has
329no retry key, so inspect existing threads after a failed or lost response before
328`startFromOrigin: false`. The new thread may not run with broader runtime or
329interaction modes than the caller. Launch has no retry key, so inspect existing threads after a failed or lost response before
330330launching again. `create_threads` remains the batch option for a shared checkout.
331331
332332### `t3_thread_list`
oxlint-plugin-t3code/index.ts+2
......@@ -7,6 +7,7 @@ import noInlineSchemaCompile from "./rules/no-inline-schema-compile.ts";
77import noManualEffectRuntimeInTests from "./rules/no-manual-effect-runtime-in-tests.ts";
88import noMobileUniwindThemeEscapeHatches from "./rules/no-mobile-uniwind-theme-escape-hatches.ts";
99import noNativeTitleTooltip from "./rules/no-native-title-tooltip.ts";
10import noRawMcpRegistration from "./rules/no-raw-mcp-registration.ts";
1011import noTestInLoop from "./rules/no-test-in-loop.ts";
1112import noUnscopedHas from "./rules/no-unscoped-has.ts";
1213import preferCatchTags from "./rules/prefer-catch-tags.ts";
......@@ -24,6 +25,7 @@ export default definePlugin({
2425 "no-manual-effect-runtime-in-tests": noManualEffectRuntimeInTests,
2526 "no-mobile-uniwind-theme-escape-hatches": noMobileUniwindThemeEscapeHatches,
2627 "no-native-title-tooltip": noNativeTitleTooltip,
28 "no-raw-mcp-registration": noRawMcpRegistration,
2729 "no-test-in-loop": noTestInLoop,
2830 "no-unscoped-has": noUnscopedHas,
2931 "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({
167167 "t3code/no-inline-schema-compile": "warn",
168168 "t3code/no-manual-effect-runtime-in-tests": "error",
169169 "t3code/no-native-title-tooltip": "error",
170 "t3code/no-raw-mcp-registration": "error",
170171 "t3code/no-test-in-loop": "error",
171172 "t3code/no-unscoped-has": "error",
172173 "t3code/namespace-node-imports": "error",
......@@ -179,6 +180,11 @@ export default defineConfig({
179180 files: ["packages/shared/src/hostProcess.ts"],
180181 rules: { "t3code/no-global-process-runtime": "off" },
181182 },
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 },
182188 {
183189 files: ["apps/web/src/**"],
184190 excludeFiles: ["apps/web/src/components/ui/**"],