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/** A client approved for read-only access changes nothing. */
101const refuseReadOnlyClient = McpInvocationContext.McpInvocationContext.pipe(
102 Effect.flatMap((scope) =>
103 scope.client?.access === "read-only"
104 ? Effect.fail(
105 new OrchestratorMcpFailure({
106 code: "capability_denied",
107 message:
108 "This tool changes the environment, and this MCP client was approved for read-only access.",
109 }),
110 )
111 : Effect.void,
112 ),
113);
114
115/**
116 * The caller of a tool that changes something. Tools that act for a
117 * capability of their own (preview, device, worktree, pull requests) check it
118 * themselves.
119 */
120const writingCaller = refuseReadOnlyClient.pipe(
121 Effect.andThen(loadCaller()),
122 Effect.tap(assertLiveCaller),
123);
124
125/** The same, for tools whose only capability is controlling threads. */
126const orchestratingCaller = refuseReadOnlyClient.pipe(
127 Effect.andThen(readCaller()),
128 Effect.tap(assertLiveCaller),
129);
130
131const requireThreadCaller = McpInvocationContext.McpInvocationContext.pipe(
132 Effect.flatMap((scope) => McpInvocationContext.requireThreadScope(scope, "This tool")),
133);
134
135/** Changes nothing, so every caller may call it. */
136export const reads = <P, A, E, R>(handle: (params: P) => Effect.Effect<A, E, R>) =>
137 declare((params: P) => handle(params));
138
139/** Reads what belongs to the calling T3 thread, such as its preview tabs or devices. */
140export const readsAsCaller = <P, A, E, R>(handle: (params: P) => Effect.Effect<A, E, R>) =>
141 declare((params: P) => requireThreadCaller.pipe(Effect.flatMap(() => handle(params))));
142
143/**
144 * Acts as the calling T3 thread (its subagents, preview tabs, devices,
145 * worktree) while that thread's run is live. Only an agent running inside a
146 * T3 thread has one.
147 */
148export const actsAsCaller = <P, A, E, R>(handle: (params: P) => Effect.Effect<A, E, R>) =>
149 declare((params: P) =>
150 requireThreadCaller.pipe(
151 Effect.flatMap(() => writingCaller),
152 Effect.flatMap(() => handle(params)),
153 ),
154 );
155
156/** Changes something that belongs to no thread, such as a pending upload or a scheduled task. */
157export const writes = <P, A, E, R>(handle: (params: P) => Effect.Effect<A, E, R>) =>
158 declare((params: P) => orchestratingCaller.pipe(Effect.flatMap(() => handle(params))));
159
160/**
161 * Changes something that belongs to no thread under a capability of the tool's own, such as
162 * writing a pull request's review guide. Unlike `writes` it does not need thread control; the
163 * handler checks its capability, as the capability toolkits do.
164 */
165export const writesWithCapability = <P, A, E, R>(handle: (params: P) => Effect.Effect<A, E, R>) =>
166 declare((params: P) => writingCaller.pipe(Effect.flatMap(() => handle(params))));
167
168/**
169 * Changes the threads `threads` names. An omitted id is the caller's own
170 * thread; any other thread must run within the caller's modes, checked here
171 * and again by the orchestrator when it applies each command. A thread that
172 * does not exist is the handler's to report.
173 */
174export const writesThreads = <P, A, E, R>(
175 threads: (params: P) => ReadonlyArray<ThreadId | undefined>,
176 handle: (params: P) => Effect.Effect<A, E, R>,
177) =>
178 declare((params: P) =>
179 Effect.gen(function* () {
180 const caller = yield* writingCaller;
181 for (const threadId of threads(params)) {
182 if (threadId === undefined || threadId === caller.scope.thread?.threadId) continue;
183 const target = yield* caller.threads
184 .getThreadShell(threadId)
185 .pipe(Effect.mapError(unavailable));
186 if (target !== null && target.deletedAt === null) {
187 yield* assertTargetWithinLimits(caller.limits, target);
188 }
189 }
190 // The target's user can raise its modes after the check above; the
191 // orchestrator checks again under the thread's lock and records its
192 // refusal here, since handlers wrap dispatch errors their own way.
193 const refused = yield* Ref.make<DispatchModeRefusal | undefined>(undefined);
194 return yield* handle(params).pipe(
195 Effect.provideService(DispatchModeLimit, { ...caller.limits, refused }),
196 Effect.catch((error) =>
197 Effect.flatMap(Ref.get(refused), (refusal) =>
198 Effect.fail<E | OrchestratorMcpFailure>(
199 refusal === undefined ? error : escalationDenied(refusal),
200 ),
201 ),
202 ),
203 );
204 }),
205 );
206
207/** How an agent hears that a thread it targets was raised above its modes mid-call. */
208const escalationDenied = (refusal: DispatchModeRefusal) =>
209 new OrchestratorMcpFailure({
210 code:
211 refusal.mode === "runtime"
212 ? "runtime_mode_escalation_denied"
213 : "interaction_mode_escalation_denied",
214 message: `Thread ${refusal.threadId} now runs in ${refusal.runtimeMode}/${refusal.interactionMode} mode, above this caller's. Its user changed it while this call ran.`,
215 });
216
217/** The modes a started thread runs with: those requested, else the caller's own. */
218export interface StartedModes {
219 readonly runtimeMode: RuntimeMode;
220 readonly interactionMode: ProviderInteractionMode;
221}
222
223/** Starts threads with the modes `modes` requests, which may not be broader than the caller's. */
224export const startsThreads = <P, A, E, R>(
225 modes: (params: P) => {
226 readonly runtimeMode?: RuntimeMode | undefined;
227 readonly interactionMode?: ProviderInteractionMode | undefined;
228 },
229 handle: (params: P, modes: StartedModes) => Effect.Effect<A, E, R>,
230) =>
231 declare((params: P) =>
232 Effect.gen(function* () {
233 const { limits } = yield* writingCaller;
234 const requested = modes(params);
235 const started: StartedModes = {
236 runtimeMode: yield* resolveRuntimeMode(limits.runtimeMode, requested.runtimeMode),
237 interactionMode: yield* resolveInteractionMode(
238 limits.interactionMode,
239 requested.interactionMode,
240 ),
241 };
242 return yield* handle(params, started);
243 }),
244 );
245
246const fullAccessRequired =
247 "Changing projects or environment settings needs a live full-access/default calling thread or a full-access client.";
248
249/**
250 * Changes projects or environment settings, which needs a full-access/default
251 * caller. `check` re-checks the caller wherever the handler waits before
252 * writing, such as for a lock, since the caller's modes can change meanwhile.
253 */
254export const writesEnvironment = <P, A, E, R>(
255 handle: (
256 params: P,
257 check: Effect.Effect<Caller, OrchestratorMcpFailure, CheckServices>,
258 ) => Effect.Effect<A, E, R>,
259) => {
260 const check = orchestratingCaller.pipe(
261 Effect.tap((caller) => assertFullAccess(caller, fullAccessRequired)),
262 );
263 return declare((params: P) => check.pipe(Effect.flatMap(() => handle(params, check))));
264};
265
266/** What a declaration's own check needs. */
267type CheckServices = Effect.Services<typeof orchestratingCaller>;
268
269/**
270 * Each handler of `Handlers`, built by one of the declarations above. A
271 * declaration is never callable, which rules out a handler function wearing a
272 * declaration's fields.
273 */
274type Declarations<Handlers> = {
275 readonly [Name in keyof Handlers]: Declaration<Handlers[Name]> & NotCallable;
276};
277
278/** Anything but a function. */
279type NotCallable = { readonly call?: never } & { readonly apply?: never };
280
281/** A toolkit's handlers, each built by one of the declarations above. */
282export type Handlers<Tools extends Record<string, Tool.Any>> = Declarations<
283 Toolkit.HandlersFrom<Tools>
284>;
285
286/**
287 * A toolkit's handler layer built by `toLayer`, the only kind `/mcp`
288 * registers. Like a declaration it is nominal, so nothing else passes for one.
289 */
290export class HandlersLayer<Tools extends Record<string, Tool.Any>, EX = never, RX = never> {
291 readonly #layer: Layer.Layer<Tool.HandlersFor<Tools>, EX, RX>;
292 private constructor(
293 token: typeof builtHere,
294 layer: Layer.Layer<Tool.HandlersFor<Tools>, EX, RX>,
295 ) {
296 refuseOutsideConstruction(token);
297 this.#layer = layer;
298 }
299 static {
300 handlersLayer = (layer) => new HandlersLayer(builtHere, layer);
301 }
302 /** The handlers, for registering this toolkit on the MCP server. */
303 static layer<Tools extends Record<string, Tool.Any>, EX, RX>(
304 handlers: HandlersLayer<Tools, EX, RX>,
305 ) {
306 return handlers.#layer;
307 }
308}
309
310const checkedHandlers = <Handlers>(declarations: Declarations<Handlers>): Handlers =>
311 Struct.map(declarations, checkedHandler);
312
313/** `Toolkit.toLayer` for handlers that all declare their access. */
314export const toLayer = <Tools extends Record<string, Tool.Any>, EX = never, RX = never>(
315 toolkit: Toolkit.Toolkit<Tools>,
316 build: Handlers<Tools> | Effect.Effect<Handlers<Tools>, EX, RX>,
317): HandlersLayer<Tools, EX, Exclude<RX, Scope.Scope>> =>
318 handlersLayer(
319 toolkit.toLayer(
320 Effect.isEffect(build)
321 ? Effect.map(build, checkedHandlers<Toolkit.HandlersFrom<Tools>>)
322 : checkedHandlers(build),
323 ),
324 );