| 1 | import { expect, it } from "@effect/vitest"; |
| 2 | import { |
| 3 | EnvironmentId, |
| 4 | OrchestratorMcpFailure, |
| 5 | ProviderInstanceId, |
| 6 | ProviderInteractionMode, |
| 7 | RuntimeMode, |
| 8 | ThreadId, |
| 9 | } from "@t3tools/contracts"; |
| 10 | import * as Effect from "effect/Effect"; |
| 11 | import * as Layer from "effect/Layer"; |
| 12 | import * as Schema from "effect/Schema"; |
| 13 | import { McpSchema, McpServer, Tool, Toolkit } from "effect/ai"; |
| 14 | |
| 15 | import { OrchestratorProjectionError } from "../orchestration-v2/Orchestrator.ts"; |
| 16 | import * as ThreadManagement from "../orchestration-v2/ThreadManagementService.ts"; |
| 17 | import * as McpHttpServer from "./McpHttpServer.ts"; |
| 18 | import * as McpInvocationContext from "./McpInvocationContext.ts"; |
| 19 | import * as McpToolAccess from "./McpToolAccess.ts"; |
| 20 | import { liveThreadShell } from "./McpToolAccess.testkit.ts"; |
| 21 | |
| 22 | const supervisedThreadId = ThreadId.make("thread:supervised"); |
| 23 | const planThreadId = ThreadId.make("thread:plan"); |
| 24 | const fullAccessThreadId = ThreadId.make("thread:full-access"); |
| 25 | const endedThreadId = ThreadId.make("thread:ended"); |
| 26 | |
| 27 | const 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 | |
| 37 | const 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 | |
| 50 | const clientCaller = ( |
| 51 | access: McpInvocationContext.McpClientCaller["access"], |
| 52 | ): McpInvocationContext.McpInvocationScope => ({ |
| 53 | environmentId: EnvironmentId.make("environment"), |
| 54 | requestNamespace: "client:session", |
| 55 | thread: undefined, |
| 56 | client: { sessionId: "session", label: "Claude Code", access }, |
| 57 | capabilities: new Set(["orchestration"]), |
| 58 | issuedAt: 0, |
| 59 | }); |
| 60 | |
| 61 | const 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. |
| 76 | const 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 | }; |
| 85 | const 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_with_capability", probe), |
| 91 | Tool.make("writes_threads", { |
| 92 | ...probe, |
| 93 | parameters: Schema.Struct({ threadId: Schema.optional(ThreadId) }), |
| 94 | }), |
| 95 | Tool.make("starts_threads", { |
| 96 | ...probe, |
| 97 | parameters: Schema.Struct({ |
| 98 | runtimeMode: Schema.optional(RuntimeMode), |
| 99 | interactionMode: Schema.optional(ProviderInteractionMode), |
| 100 | }), |
| 101 | }), |
| 102 | Tool.make("writes_environment", probe), |
| 103 | ); |
| 104 | const ran = Effect.succeed({ ran: "ran" }); |
| 105 | const probeHandlers: McpToolAccess.Handlers<typeof ProbeToolkit.tools> = { |
| 106 | reads: McpToolAccess.reads(() => ran), |
| 107 | reads_as_caller: McpToolAccess.readsAsCaller(() => ran), |
| 108 | acts_as_caller: McpToolAccess.actsAsCaller(() => ran), |
| 109 | writes: McpToolAccess.writes(() => ran), |
| 110 | writes_with_capability: McpToolAccess.writesWithCapability(() => ran), |
| 111 | writes_threads: McpToolAccess.writesThreads( |
| 112 | (input) => [input.threadId], |
| 113 | () => ran, |
| 114 | ), |
| 115 | starts_threads: McpToolAccess.startsThreads( |
| 116 | (input) => input, |
| 117 | (_, modes) => Effect.succeed({ ran: `${modes.runtimeMode}/${modes.interactionMode}` }), |
| 118 | ), |
| 119 | writes_environment: McpToolAccess.writesEnvironment(() => ran), |
| 120 | }; |
| 121 | const layerProbeHandlers = McpToolAccess.toLayer(ProbeToolkit, probeHandlers); |
| 122 | |
| 123 | const unchecked = () => ran; |
| 124 | |
| 125 | // What the compiler refuses: each line below must fail to typecheck, and its |
| 126 | // `@ts-expect-error` fails the build if one ever compiles. Never called. |
| 127 | export const refusedAtCompileTime = () => { |
| 128 | McpToolAccess.toLayer(ProbeToolkit, { |
| 129 | ...probeHandlers, |
| 130 | // @ts-expect-error a handler that skips its declaration |
| 131 | reads: unchecked, |
| 132 | }); |
| 133 | McpToolAccess.toLayer(ProbeToolkit, { |
| 134 | ...probeHandlers, |
| 135 | // @ts-expect-error a declaration's fields copied onto an unchecked handler |
| 136 | reads: Object.assign( |
| 137 | unchecked, |
| 138 | McpToolAccess.reads(() => ran), |
| 139 | ), |
| 140 | }); |
| 141 | // @ts-expect-error a declaration built outside McpToolAccess |
| 142 | McpToolAccess.Declaration.make(unchecked); |
| 143 | // @ts-expect-error a declaration constructed directly |
| 144 | new McpToolAccess.Declaration(unchecked); |
| 145 | // @ts-expect-error a handlers layer built outside McpToolAccess |
| 146 | McpToolAccess.HandlersLayer.make(Layer.empty); |
| 147 | // The refused layer below types its error and services as unknown, which is fine here. |
| 148 | // @effect-diagnostics-next-line anyUnknownInErrorContext:off |
| 149 | McpHttpServer.toolkitRegistration( |
| 150 | ProbeToolkit, |
| 151 | // @ts-expect-error a handlers layer that skipped `McpToolAccess.toLayer` |
| 152 | ProbeToolkit.toLayer({ |
| 153 | reads: unchecked, |
| 154 | reads_as_caller: unchecked, |
| 155 | acts_as_caller: unchecked, |
| 156 | writes: unchecked, |
| 157 | writes_with_capability: unchecked, |
| 158 | writes_threads: unchecked, |
| 159 | starts_threads: unchecked, |
| 160 | writes_environment: unchecked, |
| 161 | }), |
| 162 | ); |
| 163 | }; |
| 164 | |
| 165 | const probeServer = (threads: Layer.Layer<ThreadManagement.ThreadManagementService>) => |
| 166 | McpHttpServer.toolkitRegistration(ProbeToolkit, layerProbeHandlers).pipe( |
| 167 | Layer.provideMerge(McpServer.McpServer.layer), |
| 168 | Layer.provideMerge(threads), |
| 169 | ); |
| 170 | |
| 171 | /** A probe's answer, or the gate's refusal, as the result's JSON text. */ |
| 172 | const decodeOutcome = Schema.decodeUnknownEffect( |
| 173 | Schema.fromJsonString( |
| 174 | Schema.Union([Schema.Struct({ ran: Schema.String }), Schema.Struct({ code: Schema.String })]), |
| 175 | ), |
| 176 | ); |
| 177 | |
| 178 | const call = ( |
| 179 | tool: string, |
| 180 | scope: McpInvocationContext.McpInvocationScope, |
| 181 | args: Record<string, unknown> = {}, |
| 182 | ) => |
| 183 | McpServer.McpServer.pipe( |
| 184 | Effect.flatMap((server) => server.callTool({ name: tool, arguments: args })), |
| 185 | Effect.flatMap(({ content }) => |
| 186 | decodeOutcome(content.map((part) => (part.type === "text" ? part.text : "")).join("")), |
| 187 | ), |
| 188 | Effect.map((outcome) => ("code" in outcome ? outcome.code : outcome.ran)), |
| 189 | Effect.provideService(McpInvocationContext.McpInvocationContext, scope), |
| 190 | Effect.provideService(McpSchema.McpServerClient, mcpClient), |
| 191 | ); |
| 192 | |
| 193 | const supervised = threadCaller(supervisedThreadId); |
| 194 | const plan = threadCaller(planThreadId); |
| 195 | const fullAccess = threadCaller(fullAccessThreadId); |
| 196 | const ended = threadCaller(endedThreadId); |
| 197 | const supervisedClient = clientCaller("approval-required"); |
| 198 | const fullAccessClient = clientCaller("full-access"); |
| 199 | const readOnlyClient = clientCaller("read-only"); |
| 200 | // A live full-access thread whose credential may use its browser but not control threads. |
| 201 | const previewOnly = { ...fullAccess, capabilities: new Set(["preview"] as const) }; |
| 202 | |
| 203 | it.effect.each([ |
| 204 | // Reads are open to every caller, even one whose turn ended. |
| 205 | ["reads", ended, {}, "ran"], |
| 206 | ["reads", supervisedClient, {}, "ran"], |
| 207 | ["reads", readOnlyClient, {}, "ran"], |
| 208 | |
| 209 | // A client approved for read-only access changes nothing. |
| 210 | ["writes", readOnlyClient, {}, "capability_denied"], |
| 211 | ["writes_threads", readOnlyClient, { threadId: supervisedThreadId }, "capability_denied"], |
| 212 | ["starts_threads", readOnlyClient, {}, "capability_denied"], |
| 213 | ["writes_environment", readOnlyClient, {}, "capability_denied"], |
| 214 | |
| 215 | // What belongs to the calling thread needs one; changing it needs its live turn. |
| 216 | ["reads_as_caller", ended, {}, "ran"], |
| 217 | ["reads_as_caller", fullAccessClient, {}, "thread_credential_required"], |
| 218 | ["acts_as_caller", supervised, {}, "ran"], |
| 219 | ["acts_as_caller", ended, {}, "parent_not_active"], |
| 220 | ["acts_as_caller", fullAccessClient, {}, "thread_credential_required"], |
| 221 | |
| 222 | // Any change needs a thread caller's live turn; a client has none to lose. |
| 223 | ["writes", supervised, {}, "ran"], |
| 224 | ["writes", ended, {}, "parent_not_active"], |
| 225 | ["writes", supervisedClient, {}, "ran"], |
| 226 | // Uploads, scheduled tasks, projects and settings need a credential that controls threads. |
| 227 | ["writes", previewOnly, {}, "capability_denied"], |
| 228 | ["writes_environment", previewOnly, {}, "capability_denied"], |
| 229 | |
| 230 | // A tool's own capability stands in for thread control; the rest of a change still applies. |
| 231 | ["writes_with_capability", previewOnly, {}, "ran"], |
| 232 | ["writes_with_capability", readOnlyClient, {}, "capability_denied"], |
| 233 | ["writes_with_capability", ended, {}, "parent_not_active"], |
| 234 | |
| 235 | // Changing a thread: only one that runs within the caller's modes. |
| 236 | ["writes_threads", supervised, {}, "ran"], |
| 237 | ["writes_threads", supervised, { threadId: planThreadId }, "ran"], |
| 238 | [ |
| 239 | "writes_threads", |
| 240 | supervised, |
| 241 | { threadId: fullAccessThreadId }, |
| 242 | "runtime_mode_escalation_denied", |
| 243 | ], |
| 244 | ["writes_threads", plan, { threadId: supervisedThreadId }, "interaction_mode_escalation_denied"], |
| 245 | ["writes_threads", fullAccess, { threadId: supervisedThreadId }, "ran"], |
| 246 | ["writes_threads", ended, { threadId: supervisedThreadId }, "parent_not_active"], |
| 247 | ["writes_threads", supervisedClient, { threadId: supervisedThreadId }, "ran"], |
| 248 | [ |
| 249 | "writes_threads", |
| 250 | supervisedClient, |
| 251 | { threadId: fullAccessThreadId }, |
| 252 | "runtime_mode_escalation_denied", |
| 253 | ], |
| 254 | // A thread that does not exist is the tool's to report. |
| 255 | ["writes_threads", supervised, { threadId: "thread:missing" }, "ran"], |
| 256 | |
| 257 | // Starting a thread: the caller's own modes or narrower. |
| 258 | ["starts_threads", supervised, {}, "approval-required/default"], |
| 259 | ["starts_threads", supervised, { runtimeMode: "full-access" }, "runtime_mode_escalation_denied"], |
| 260 | ["starts_threads", plan, {}, "approval-required/plan"], |
| 261 | ["starts_threads", plan, { interactionMode: "default" }, "interaction_mode_escalation_denied"], |
| 262 | ["starts_threads", fullAccess, { runtimeMode: "auto", interactionMode: "plan" }, "auto/plan"], |
| 263 | ["starts_threads", ended, {}, "parent_not_active"], |
| 264 | ["starts_threads", supervisedClient, {}, "approval-required/default"], |
| 265 | ["starts_threads", supervisedClient, { runtimeMode: "auto" }, "runtime_mode_escalation_denied"], |
| 266 | ["starts_threads", fullAccessClient, { runtimeMode: "full-access" }, "full-access/default"], |
| 267 | |
| 268 | // Projects and environment settings need full access. |
| 269 | ["writes_environment", fullAccess, {}, "ran"], |
| 270 | ["writes_environment", supervised, {}, "capability_denied"], |
| 271 | ["writes_environment", plan, {}, "capability_denied"], |
| 272 | ["writes_environment", fullAccessClient, {}, "ran"], |
| 273 | ["writes_environment", supervisedClient, {}, "capability_denied"], |
| 274 | ] as const)("%s from %o with %o: %s", ([tool, scope, args, expected]) => |
| 275 | call(tool, scope, args).pipe( |
| 276 | Effect.map((outcome) => expect(outcome).toBe(expected)), |
| 277 | Effect.provide( |
| 278 | probeServer( |
| 279 | Layer.mock(ThreadManagement.ThreadManagementService)({ |
| 280 | getThreadShell: (threadId) => Effect.succeed(shells.get(threadId) ?? null), |
| 281 | }), |
| 282 | ), |
| 283 | ), |
| 284 | ), |
| 285 | ); |
| 286 | |
| 287 | it("refuses a declaration or handlers layer built outside McpToolAccess", () => { |
| 288 | // Private constructors only bind the type checker; Reflect.construct reaches them anyway. |
| 289 | expect(() => Reflect.construct(McpToolAccess.Declaration, [unchecked])).toThrow(TypeError); |
| 290 | expect(() => Reflect.construct(McpToolAccess.HandlersLayer, [Layer.empty])).toThrow(TypeError); |
| 291 | }); |
| 292 | |
| 293 | it("refuses a declaration or handlers layer that only wears another's fields", () => { |
| 294 | // The types already refuse these; at runtime the private fields are missing too. |
| 295 | const copiedDeclaration = Object.assign( |
| 296 | unchecked, |
| 297 | McpToolAccess.reads(() => ran), |
| 298 | ); |
| 299 | expect(() => |
| 300 | McpToolAccess.toLayer(ProbeToolkit, { |
| 301 | ...probeHandlers, |
| 302 | // @ts-expect-error a declaration's fields copied onto an unchecked handler |
| 303 | reads: copiedDeclaration, |
| 304 | }), |
| 305 | ).toThrow(TypeError); |
| 306 | const copiedLayer = Object.assign(Layer.empty, layerProbeHandlers); |
| 307 | expect(() => McpToolAccess.HandlersLayer.layer(copiedLayer)).toThrow(TypeError); |
| 308 | }); |
| 309 | |
| 310 | it.effect("refuses a change when the calling thread cannot be read", () => |
| 311 | call("writes", supervised).pipe( |
| 312 | Effect.map((outcome) => expect(outcome).toBe("orchestration_error")), |
| 313 | Effect.provide( |
| 314 | probeServer( |
| 315 | Layer.mock(ThreadManagement.ThreadManagementService)({ |
| 316 | getThreadShell: (threadId) => |
| 317 | Effect.fail(new OrchestratorProjectionError({ threadId, cause: "unreadable" })), |
| 318 | }), |
| 319 | ), |
| 320 | ), |
| 321 | ), |
| 322 | ); |