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 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
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_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);
104const ran = Effect.succeed({ ran: "ran" });
105const 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};
121const layerProbeHandlers = McpToolAccess.toLayer(ProbeToolkit, probeHandlers);
122
123const 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.
127export 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
165const 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. */
172const decodeOutcome = Schema.decodeUnknownEffect(
173 Schema.fromJsonString(
174 Schema.Union([Schema.Struct({ ran: Schema.String }), Schema.Struct({ code: Schema.String })]),
175 ),
176);
177
178const 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
193const supervised = threadCaller(supervisedThreadId);
194const plan = threadCaller(planThreadId);
195const fullAccess = threadCaller(fullAccessThreadId);
196const ended = threadCaller(endedThreadId);
197const supervisedClient = clientCaller("approval-required");
198const fullAccessClient = clientCaller("full-access");
199const readOnlyClient = clientCaller("read-only");
200// A live full-access thread whose credential may use its browser but not control threads.
201const previewOnly = { ...fullAccess, capabilities: new Set(["preview"] as const) };
202
203it.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
287it("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
293it("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
310it.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);