| 1 | import { |
| 2 | OrchestratorMcpFailure, |
| 3 | type ProviderInteractionMode, |
| 4 | type RuntimeMode, |
| 5 | type ThreadId, |
| 6 | } from "@t3tools/contracts"; |
| 7 | import * as Effect from "effect/Effect"; |
| 8 | import * as Ref from "effect/Ref"; |
| 9 | import * as Struct from "effect/Struct"; |
| 10 | import type * as Layer from "effect/Layer"; |
| 11 | import type * as Scope from "effect/Scope"; |
| 12 | import type { Tool, Toolkit } from "effect/ai"; |
| 13 | |
| 14 | import { |
| 15 | DispatchModeLimit, |
| 16 | type DispatchModeRefusal, |
| 17 | } from "../orchestration-v2/DispatchModeLimit.ts"; |
| 18 | import * as McpInvocationContext from "./McpInvocationContext.ts"; |
| 19 | import { resolveInteractionMode, resolveRuntimeMode } from "./OrchestratorMcpService.ts"; |
| 20 | import { |
| 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 | */ |
| 37 | const builtHere: unique symbol = Symbol("t3/mcp/McpToolAccess/builtHere"); |
| 38 | |
| 39 | const 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. */ |
| 46 | let declare: <P, A, E, R>( |
| 47 | handle: (params: P) => Effect.Effect<A, E, R>, |
| 48 | ) => Declaration<(params: P) => Effect.Effect<A, E, R>>; |
| 49 | |
| 50 | type CheckedHandlerOf<D> = D extends Declaration<infer Handler> ? Handler : never; |
| 51 | |
| 52 | /** Turns each declaration back into the handler it checks, keeping its type. */ |
| 53 | interface CheckedHandler extends Struct.Lambda { |
| 54 | <Handler>(declaration: Declaration<Handler>): Handler; |
| 55 | readonly "~lambda.out": CheckedHandlerOf<this["~lambda.in"]>; |
| 56 | } |
| 57 | let checkedHandler: CheckedHandler; |
| 58 | |
| 59 | /** Only `toLayer` below builds one. */ |
| 60 | let 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 | */ |
| 84 | export 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. */ |
| 101 | const 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 | */ |
| 120 | const writingCaller = refuseReadOnlyClient.pipe( |
| 121 | Effect.andThen(loadCaller()), |
| 122 | Effect.tap(assertLiveCaller), |
| 123 | ); |
| 124 | |
| 125 | /** The same, for tools whose only capability is controlling threads. */ |
| 126 | const orchestratingCaller = refuseReadOnlyClient.pipe( |
| 127 | Effect.andThen(readCaller()), |
| 128 | Effect.tap(assertLiveCaller), |
| 129 | ); |
| 130 | |
| 131 | const requireThreadCaller = McpInvocationContext.McpInvocationContext.pipe( |
| 132 | Effect.flatMap((scope) => McpInvocationContext.requireThreadScope(scope, "This tool")), |
| 133 | ); |
| 134 | |
| 135 | /** Changes nothing, so every caller may call it. */ |
| 136 | export 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. */ |
| 140 | export 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 | */ |
| 148 | export 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. */ |
| 157 | export 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 | */ |
| 165 | export 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 | */ |
| 174 | export 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. */ |
| 208 | const 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. */ |
| 218 | export 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. */ |
| 224 | export 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 | |
| 246 | const 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 | */ |
| 254 | export 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. */ |
| 267 | type 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 | */ |
| 274 | type Declarations<Handlers> = { |
| 275 | readonly [Name in keyof Handlers]: Declaration<Handlers[Name]> & NotCallable; |
| 276 | }; |
| 277 | |
| 278 | /** Anything but a function. */ |
| 279 | type NotCallable = { readonly call?: never } & { readonly apply?: never }; |
| 280 | |
| 281 | /** A toolkit's handlers, each built by one of the declarations above. */ |
| 282 | export 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 | */ |
| 290 | export 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 | |
| 310 | const checkedHandlers = <Handlers>(declarations: Declarations<Handlers>): Handlers => |
| 311 | Struct.map(declarations, checkedHandler); |
| 312 | |
| 313 | /** `Toolkit.toLayer` for handlers that all declare their access. */ |
| 314 | export 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 | ); |