| 1 | import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; |
| 2 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 3 | import { expect, it } from "@effect/vitest"; |
| 4 | import { AjvJsonSchemaValidator } from "@modelcontextprotocol/sdk/validation/ajv"; |
| 5 | import type { JsonSchemaType } from "@modelcontextprotocol/sdk/validation"; |
| 6 | import { |
| 7 | DEFAULT_SERVER_SETTINGS, |
| 8 | ChatImageAttachment, |
| 9 | CommandId, |
| 10 | EnvironmentId, |
| 11 | NodeId, |
| 12 | OrchestratorMcpThreadReadResult, |
| 13 | ProviderDriverKind, |
| 14 | ProviderInstanceId, |
| 15 | RunId, |
| 16 | ThreadId, |
| 17 | TurnItemId, |
| 18 | type OrchestrationV2ProjectedTurnItem, |
| 19 | type OrchestrationV2ThreadShell, |
| 20 | } from "@t3tools/contracts"; |
| 21 | import * as Effect from "effect/Effect"; |
| 22 | import * as TestClock from "effect/testing/TestClock"; |
| 23 | import * as Layer from "effect/Layer"; |
| 24 | import * as Schema from "effect/Schema"; |
| 25 | import { McpAttachmentInput } from "./attachment/input.ts"; |
| 26 | import { McpSchema, McpServer, Tool } from "effect/ai"; |
| 27 | import { FetchHttpClient } from "effect/http"; |
| 28 | |
| 29 | import { |
| 30 | OrchestratorCommandRejectedError, |
| 31 | OrchestratorDispatchError, |
| 32 | OrchestratorProjectionError, |
| 33 | } from "../../orchestration-v2/Orchestrator.ts"; |
| 34 | |
| 35 | import * as ServerSecretStore from "../../auth/ServerSecretStore.ts"; |
| 36 | import * as ServerConfig from "../../config.ts"; |
| 37 | import * as ProviderAdapterRegistry from "../../orchestration-v2/ProviderAdapterRegistry.ts"; |
| 38 | import * as ThreadLaunchService from "../../orchestration-v2/ThreadLaunchService.ts"; |
| 39 | import * as GitVcsDriver from "../../vcs/GitVcsDriver.ts"; |
| 40 | import * as ThreadManagement from "../../orchestration-v2/ThreadManagementService.ts"; |
| 41 | import * as ThreadSearch from "../../orchestration-v2/ThreadSearch.ts"; |
| 42 | import * as PreviewBrowser from "../../preview/PreviewBrowser.ts"; |
| 43 | import * as ProjectService from "../../project/ProjectService.ts"; |
| 44 | import * as ProviderRegistry from "../../provider/ProviderRegistry.ts"; |
| 45 | import * as SecretRequests from "../../secrets/SecretRequests.ts"; |
| 46 | import * as ScheduledTaskService from "../../scheduledTasks/ScheduledTaskService.ts"; |
| 47 | import * as ServerSettings from "../../serverSettings.ts"; |
| 48 | import * as McpHttpServer from "../McpHttpServer.ts"; |
| 49 | import * as McpInvocationContext from "../McpInvocationContext.ts"; |
| 50 | import * as McpToolAccessTestkit from "../McpToolAccess.testkit.ts"; |
| 51 | import { dispatchFailure } from "../threadAccess.ts"; |
| 52 | import { OrchestratorToolkit } from "./orchestrator/tools.ts"; |
| 53 | import { PreviewToolkit } from "./preview/tools.ts"; |
| 54 | import { PreviewControlsToolkit } from "./previewControls/tools.ts"; |
| 55 | import { EnvironmentToolkit } from "./environment/tools.ts"; |
| 56 | import * as EnvironmentHandlers from "./environment/handlers.ts"; |
| 57 | import { ProjectToolkit } from "./project/tools.ts"; |
| 58 | import { AttachmentToolkit } from "./attachment/tools.ts"; |
| 59 | import * as AttachmentHandlers from "./attachment/handlers.ts"; |
| 60 | import { ThreadToolkit } from "./thread/tools.ts"; |
| 61 | import { WorktreeToolkit } from "./worktree/tools.ts"; |
| 62 | import { DeviceToolkit } from "./device/tools.ts"; |
| 63 | |
| 64 | const layerOrchestratorToolkit = McpHttpServer.layerOrchestratorToolkit.pipe( |
| 65 | Layer.provide(Layer.mock(ThreadLaunchService.ThreadLaunchService)({})), |
| 66 | Layer.provide(Layer.mock(GitVcsDriver.GitVcsDriver)({})), |
| 67 | ); |
| 68 | |
| 69 | // Effect returns a declared tool failure as `isError` with its encoded payload |
| 70 | // as JSON text, never as `structuredContent`. |
| 71 | const declaredFailure = (result: McpSchema.CallToolResult) => { |
| 72 | const text = result.content[0]; |
| 73 | return result.isError === true && text?.type === "text" ? JSON.parse(text.text) : undefined; |
| 74 | }; |
| 75 | import { PullRequestsToolkit } from "./pullRequests/tools.ts"; |
| 76 | import { HtmlToolkit } from "./html/tools.ts"; |
| 77 | import { |
| 78 | resolveT3McpToolDefinition, |
| 79 | resolveT3McpToolPresentation, |
| 80 | resolveT3McpToolSummaryAction, |
| 81 | } from "@t3tools/shared/t3McpToolPresentation"; |
| 82 | import { htmlRenderFromToolItem } from "@t3tools/shared/toolOutput"; |
| 83 | |
| 84 | const decodeMcpAttachmentInput = Schema.decodeUnknownEffect(McpAttachmentInput); |
| 85 | const decodeThreadReadResult = Schema.decodeUnknownEffect(OrchestratorMcpThreadReadResult); |
| 86 | |
| 87 | // Registration asks for every service the thread tools declare; these cases call none that use them. |
| 88 | const layerThreadToolkit = McpHttpServer.layerThreadToolkit.pipe( |
| 89 | Layer.provide(Layer.mock(ThreadSearch.ThreadSearch)({})), |
| 90 | Layer.provide(Layer.mock(ScheduledTaskService.ScheduledTaskService)({})), |
| 91 | ); |
| 92 | |
| 93 | it("publishes unique tool names with reference-free object-root inputs", () => { |
| 94 | const names = new Set<string>(); |
| 95 | for (const toolkit of [ |
| 96 | OrchestratorToolkit, |
| 97 | PreviewToolkit, |
| 98 | WorktreeToolkit, |
| 99 | ThreadToolkit, |
| 100 | AttachmentToolkit, |
| 101 | ProjectToolkit, |
| 102 | EnvironmentToolkit, |
| 103 | PreviewControlsToolkit, |
| 104 | DeviceToolkit, |
| 105 | PullRequestsToolkit, |
| 106 | HtmlToolkit, |
| 107 | ]) { |
| 108 | for (const tool of Object.values(toolkit.tools)) { |
| 109 | expect(names.has(tool.name)).toBe(false); |
| 110 | names.add(tool.name); |
| 111 | const schema = Tool.getJsonSchema(tool); |
| 112 | expect(schema).toMatchObject({ type: "object" }); |
| 113 | // The published tool catalog must also work with providers without $ref support. |
| 114 | expect(JSON.stringify(schema), tool.name).not.toContain('"$ref"'); |
| 115 | // Every published tool must have labels for its lifecycle, branding, and a summary. |
| 116 | const definition = resolveT3McpToolDefinition(tool.name); |
| 117 | expect(definition, tool.name).not.toBeNull(); |
| 118 | expect( |
| 119 | definition?.labels.every((label) => label.trim().length > 0), |
| 120 | tool.name, |
| 121 | ).toBe(true); |
| 122 | for (const name of [tool.name, `mcp__t3-code__${tool.name}`, `T3-code.${tool.name}`]) { |
| 123 | expect(resolveT3McpToolPresentation(name)?.logo, name).toBe("t3-code"); |
| 124 | expect(resolveT3McpToolSummaryAction(name), name).not.toBeNull(); |
| 125 | } |
| 126 | } |
| 127 | } |
| 128 | expect(names.has("t3_thread_launch")).toBe(true); |
| 129 | expect(names.has("t3_thread_start")).toBe(false); |
| 130 | }); |
| 131 | |
| 132 | const threadId = ThreadId.make("mcp-core-thread"); |
| 133 | const scope: McpInvocationContext.McpInvocationScope = { |
| 134 | environmentId: EnvironmentId.make("mcp-core-environment"), |
| 135 | requestNamespace: "mcp-core-session", |
| 136 | thread: { |
| 137 | threadId, |
| 138 | providerSessionId: "mcp-core-session", |
| 139 | providerInstanceId: ProviderInstanceId.make("codex"), |
| 140 | }, |
| 141 | client: undefined, |
| 142 | issuedAt: 0, |
| 143 | capabilities: new Set(["orchestration"]), |
| 144 | }; |
| 145 | const client = McpSchema.McpServerClient.of({ |
| 146 | clientId: 1, |
| 147 | protocolVersion: "2025-06-18", |
| 148 | clientCapabilities: {}, |
| 149 | clientInfo: { name: "mcp-core", version: "1" }, |
| 150 | initializePayload: { |
| 151 | protocolVersion: "2025-06-18", |
| 152 | capabilities: {}, |
| 153 | clientInfo: { name: "mcp-core", version: "1" }, |
| 154 | }, |
| 155 | getClient: Effect.die("unused"), |
| 156 | }); |
| 157 | |
| 158 | it.effect("checks capability through the production registration", () => |
| 159 | Effect.gen(function* () { |
| 160 | const server = yield* McpServer.McpServer; |
| 161 | expect(server.tools.some(({ tool }) => tool.name === "t3_thread_organize")).toBe(true); |
| 162 | const result = yield* server |
| 163 | .callTool({ name: "t3_thread_organize", arguments: { action: "pin" } }) |
| 164 | .pipe( |
| 165 | Effect.provideService(McpInvocationContext.McpInvocationContext, { |
| 166 | ...scope, |
| 167 | capabilities: new Set<never>(), |
| 168 | }), |
| 169 | Effect.provideService(McpSchema.McpServerClient, client), |
| 170 | ); |
| 171 | expect(declaredFailure(result)).toMatchObject({ code: "capability_denied" }); |
| 172 | expect(result.isError).toBe(true); |
| 173 | expect(result.structuredContent).toBeUndefined(); |
| 174 | }).pipe( |
| 175 | Effect.provide( |
| 176 | layerThreadToolkit.pipe( |
| 177 | Layer.provideMerge(McpServer.McpServer.layer), |
| 178 | Layer.provide(NodeCrypto.layer), |
| 179 | Layer.provide(McpToolAccessTestkit.liveThreadsLayer), |
| 180 | ), |
| 181 | ), |
| 182 | ), |
| 183 | ); |
| 184 | |
| 185 | it.effect("returns a bounded public failure without serializing storage causes", () => |
| 186 | Effect.gen(function* () { |
| 187 | const server = yield* McpServer.McpServer; |
| 188 | const result = yield* server |
| 189 | .callTool({ name: "t3_thread_organize", arguments: { action: "pin" } }) |
| 190 | .pipe( |
| 191 | Effect.provideService(McpInvocationContext.McpInvocationContext, scope), |
| 192 | Effect.provideService(McpSchema.McpServerClient, client), |
| 193 | ); |
| 194 | expect(declaredFailure(result)).toEqual({ |
| 195 | _tag: "OrchestratorMcpFailure", |
| 196 | code: "orchestration_error", |
| 197 | message: "The operation could not be completed.", |
| 198 | }); |
| 199 | expect(result.isError).toBe(true); |
| 200 | expect(result.content).toEqual([ |
| 201 | { |
| 202 | type: "text", |
| 203 | text: '{"_tag":"OrchestratorMcpFailure","code":"orchestration_error","message":"The operation could not be completed."}', |
| 204 | }, |
| 205 | ]); |
| 206 | const definition = server.tools.find(({ tool }) => tool.name === "t3_thread_organize"); |
| 207 | expect(definition?.tool.outputSchema).toBeDefined(); |
| 208 | const validate = new AjvJsonSchemaValidator().getValidator( |
| 209 | definition!.tool.outputSchema! as JsonSchemaType, |
| 210 | ); |
| 211 | expect(result.structuredContent).toBeUndefined(); |
| 212 | expect(validate({ sequence: 1 }).valid).toBe(true); |
| 213 | expect(validate({ code: "orchestration_error" }).valid).toBe(false); |
| 214 | expect(validate({ sequence: "invalid" }).valid).toBe(false); |
| 215 | }).pipe( |
| 216 | Effect.provide( |
| 217 | layerThreadToolkit.pipe( |
| 218 | Layer.provideMerge(McpServer.McpServer.layer), |
| 219 | Layer.provide(NodeCrypto.layer), |
| 220 | Layer.provide( |
| 221 | Layer.mock(ThreadManagement.ThreadManagementService)({ |
| 222 | getThreadShell: () => |
| 223 | Effect.fail( |
| 224 | new OrchestratorProjectionError({ |
| 225 | threadId, |
| 226 | cause: new Error("private-storage-path"), |
| 227 | }), |
| 228 | ), |
| 229 | }), |
| 230 | ), |
| 231 | ), |
| 232 | ), |
| 233 | ), |
| 234 | ); |
| 235 | |
| 236 | it("bounds public command rejections and redacts internal dispatch causes", () => { |
| 237 | const command = { commandId: CommandId.make("mcp-core-command"), commandType: "thread.settle" }; |
| 238 | expect( |
| 239 | dispatchFailure(new OrchestratorDispatchError({ ...command, cause: "🙂".repeat(1001) })) |
| 240 | .message, |
| 241 | ).toBe("🙂".repeat(1000)); |
| 242 | expect( |
| 243 | dispatchFailure( |
| 244 | new OrchestratorCommandRejectedError({ ...command, cause: "Run is not queued." }), |
| 245 | ).message, |
| 246 | ).toBe("Run is not queued."); |
| 247 | for (const cause of [ |
| 248 | undefined, |
| 249 | "", |
| 250 | new Error("private-storage-path"), |
| 251 | { message: "private-storage-path" }, |
| 252 | ]) { |
| 253 | expect(dispatchFailure(new OrchestratorDispatchError({ ...command, cause }))).toMatchObject({ |
| 254 | code: "orchestration_error", |
| 255 | message: "The operation could not be completed.", |
| 256 | }); |
| 257 | expect( |
| 258 | dispatchFailure(new OrchestratorCommandRejectedError({ ...command, cause })), |
| 259 | ).toMatchObject({ |
| 260 | code: "orchestration_error", |
| 261 | message: "The operation could not be completed.", |
| 262 | }); |
| 263 | } |
| 264 | expect( |
| 265 | dispatchFailure(new OrchestratorProjectionError({ threadId, cause: "private-storage-path" })), |
| 266 | ).toMatchObject({ |
| 267 | code: "orchestration_error", |
| 268 | message: "The operation could not be completed.", |
| 269 | }); |
| 270 | }); |
| 271 | |
| 272 | it.effect("returns an HTML render reference that Codex and Claude tool rows both carry", () => |
| 273 | Effect.gen(function* () { |
| 274 | const server = yield* McpServer.McpServer; |
| 275 | const result = yield* server |
| 276 | .callTool({ |
| 277 | name: "html_render", |
| 278 | arguments: { html: "<p>Revenue</p>", title: "Revenue", height: 240 }, |
| 279 | }) |
| 280 | .pipe( |
| 281 | Effect.provideService(McpInvocationContext.McpInvocationContext, scope), |
| 282 | Effect.provideService(McpSchema.McpServerClient, client), |
| 283 | ); |
| 284 | const reference = htmlRenderFromToolItem({ |
| 285 | toolName: "t3-code.html_render", |
| 286 | output: result.structuredContent, |
| 287 | }); |
| 288 | expect(reference).toMatchObject({ title: "Revenue", height: 240 }); |
| 289 | expect( |
| 290 | htmlRenderFromToolItem({ toolName: "mcp__t3-code__html_render", output: result.content }), |
| 291 | ).toEqual(reference); |
| 292 | }).pipe( |
| 293 | Effect.provide( |
| 294 | McpHttpServer.layerHtmlToolkit.pipe( |
| 295 | Layer.provideMerge(McpServer.McpServer.layer), |
| 296 | Layer.provide(PreviewBrowser.layer), |
| 297 | Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-mcp-html-render-" })), |
| 298 | Layer.provide(NodeServices.layer), |
| 299 | // The preview browser is not installed in a fresh home, so nothing downloads. |
| 300 | Layer.provide(FetchHttpClient.layer), |
| 301 | Layer.provide( |
| 302 | Layer.mock(ThreadManagement.ThreadManagementService)({ |
| 303 | // A live run: publishing stores a page, so it needs the caller's active turn. |
| 304 | getThreadShell: () => |
| 305 | Effect.succeed({ |
| 306 | id: threadId, |
| 307 | deletedAt: null, |
| 308 | archivedAt: null, |
| 309 | activeRunId: RunId.make("mcp-core-run"), |
| 310 | providerInstanceId: ProviderInstanceId.make("codex"), |
| 311 | } as OrchestrationV2ThreadShell), |
| 312 | }), |
| 313 | ), |
| 314 | ), |
| 315 | ), |
| 316 | ), |
| 317 | ); |
| 318 | |
| 319 | it.effect("returns invalid parameter errors through the production registration", () => |
| 320 | Effect.gen(function* () { |
| 321 | const server = yield* McpServer.McpServer; |
| 322 | const error = yield* server |
| 323 | .callTool({ name: "t3_thread_organize", arguments: { action: "invalid" } }) |
| 324 | .pipe( |
| 325 | Effect.provideService(McpInvocationContext.McpInvocationContext, scope), |
| 326 | Effect.provideService(McpSchema.McpServerClient, client), |
| 327 | Effect.flip, |
| 328 | ); |
| 329 | expect(error._tag).toBe("InvalidParams"); |
| 330 | }).pipe( |
| 331 | Effect.provide( |
| 332 | layerThreadToolkit.pipe( |
| 333 | Layer.provideMerge(McpServer.McpServer.layer), |
| 334 | Layer.provide(NodeCrypto.layer), |
| 335 | Layer.provide(Layer.mock(ThreadManagement.ThreadManagementService)({})), |
| 336 | ), |
| 337 | ), |
| 338 | ), |
| 339 | ); |
| 340 | |
| 341 | it.effect("keeps unexpected handler defects private through the production registration", () => |
| 342 | Effect.gen(function* () { |
| 343 | const server = yield* McpServer.McpServer; |
| 344 | const result = yield* server |
| 345 | .callTool({ name: "t3_thread_organize", arguments: { action: "pin" } }) |
| 346 | .pipe( |
| 347 | Effect.provideService(McpInvocationContext.McpInvocationContext, scope), |
| 348 | Effect.provideService(McpSchema.McpServerClient, client), |
| 349 | ); |
| 350 | expect(result.isError).toBe(true); |
| 351 | expect(result.structuredContent).toBeUndefined(); |
| 352 | expect(result.content).toEqual([ |
| 353 | { type: "text", text: "Tool execution failed due to an internal server error." }, |
| 354 | ]); |
| 355 | }).pipe( |
| 356 | Effect.provide( |
| 357 | layerThreadToolkit.pipe( |
| 358 | Layer.provideMerge(McpServer.McpServer.layer), |
| 359 | Layer.provide(NodeCrypto.layer), |
| 360 | Layer.provide( |
| 361 | Layer.mock(ThreadManagement.ThreadManagementService)({ |
| 362 | getThreadShell: () => Effect.die(new Error("private-storage-path")), |
| 363 | }), |
| 364 | ), |
| 365 | ), |
| 366 | ), |
| 367 | ), |
| 368 | ); |
| 369 | |
| 370 | it("keeps MCP preference output allowlisted and Unicode-bounded", () => { |
| 371 | const settings = { |
| 372 | ...DEFAULT_SERVER_SETTINGS, |
| 373 | privateCredential: "must-not-escape", |
| 374 | sourceControlWritingStyle: { |
| 375 | ...DEFAULT_SERVER_SETTINGS.sourceControlWritingStyle, |
| 376 | customInstructions: "🙂".repeat(4001), |
| 377 | }, |
| 378 | }; |
| 379 | const result = EnvironmentHandlers.preferences(settings); |
| 380 | expect(result).not.toHaveProperty("privateCredential"); |
| 381 | expect(result).not.toHaveProperty("providers"); |
| 382 | expect(result.sourceControlWritingStyle).toMatchObject({ |
| 383 | customInstructions: "🙂".repeat(4000), |
| 384 | truncated: true, |
| 385 | }); |
| 386 | }); |
| 387 | |
| 388 | it.effect("resolves reused attachment references from stored metadata", () => |
| 389 | Effect.gen(function* () { |
| 390 | const stored = ChatImageAttachment.make({ |
| 391 | type: "image", |
| 392 | id: "owned-image", |
| 393 | name: "original.png", |
| 394 | mimeType: "image/png", |
| 395 | sizeBytes: 12, |
| 396 | source: { |
| 397 | kind: "snap-shot", |
| 398 | capturedAt: "2026-09-10T00:00:00.000Z", |
| 399 | appName: "Terminal", |
| 400 | windowTitle: "Test", |
| 401 | accessibility: { format: "flat-text", text: "Stored context", truncated: false }, |
| 402 | }, |
| 403 | }); |
| 404 | const forged = yield* decodeMcpAttachmentInput({ |
| 405 | type: "image", |
| 406 | id: stored.id, |
| 407 | name: "changed.jpg", |
| 408 | mimeType: "image/jpeg", |
| 409 | sizeBytes: 99, |
| 410 | }); |
| 411 | const result = yield* AttachmentHandlers.resolveAttachmentReferences([forged], [stored]); |
| 412 | expect(result).toEqual([stored]); |
| 413 | const failure = yield* AttachmentHandlers.resolveAttachmentReferences( |
| 414 | [{ ...forged, id: "other-image" }], |
| 415 | [stored], |
| 416 | ).pipe(Effect.flip); |
| 417 | expect(failure.code).toBe("invalid_request"); |
| 418 | }), |
| 419 | ); |
| 420 | |
| 421 | const clientScope = ( |
| 422 | access: McpInvocationContext.McpClientCaller["access"], |
| 423 | ): McpInvocationContext.McpInvocationScope => ({ |
| 424 | environmentId: EnvironmentId.make("mcp-core-environment"), |
| 425 | requestNamespace: "client:session-1", |
| 426 | thread: undefined, |
| 427 | client: { sessionId: "session-1", label: "Claude Code", access }, |
| 428 | issuedAt: 0, |
| 429 | capabilities: new Set(["orchestration", "worktree", "pull-requests"]), |
| 430 | }); |
| 431 | |
| 432 | it.effect("a client caller targets any thread within its ceiling and cannot act as a thread", () => |
| 433 | Effect.gen(function* () { |
| 434 | const server = yield* McpServer.McpServer; |
| 435 | const call = ( |
| 436 | name: string, |
| 437 | args: Record<string, unknown>, |
| 438 | invocation: McpInvocationContext.McpInvocationScope, |
| 439 | ) => |
| 440 | server |
| 441 | .callTool({ name, arguments: args }) |
| 442 | .pipe( |
| 443 | Effect.provideService(McpInvocationContext.McpInvocationContext, invocation), |
| 444 | Effect.provideService(McpSchema.McpServerClient, client), |
| 445 | ); |
| 446 | |
| 447 | const untargeted = yield* call("t3_thread_organize", { action: "pin" }, clientScope("auto")); |
| 448 | expect(declaredFailure(untargeted)).toMatchObject({ code: "target_required" }); |
| 449 | |
| 450 | const pinned = yield* call( |
| 451 | "t3_thread_organize", |
| 452 | { action: "pin", threadId: "other-project-thread" }, |
| 453 | clientScope("auto"), |
| 454 | ); |
| 455 | expect(pinned.isError).toBe(false); |
| 456 | expect(pinned.structuredContent).toMatchObject({ sequence: 7 }); |
| 457 | |
| 458 | const aboveCeiling = yield* call( |
| 459 | "t3_thread_organize", |
| 460 | { action: "pin", threadId: "other-project-thread" }, |
| 461 | clientScope("approval-required"), |
| 462 | ); |
| 463 | expect(declaredFailure(aboveCeiling)).toMatchObject({ |
| 464 | code: "runtime_mode_escalation_denied", |
| 465 | }); |
| 466 | |
| 467 | const forked = yield* call( |
| 468 | "t3_thread_fork", |
| 469 | { sourcePoint: { type: "latest_stable" } }, |
| 470 | clientScope("auto"), |
| 471 | ); |
| 472 | expect(declaredFailure(forked)).toMatchObject({ code: "target_required" }); |
| 473 | }).pipe( |
| 474 | Effect.provide( |
| 475 | layerThreadToolkit.pipe( |
| 476 | Layer.provideMerge(McpServer.McpServer.layer), |
| 477 | Layer.provide(NodeCrypto.layer), |
| 478 | Layer.provide( |
| 479 | Layer.mock(ThreadManagement.ThreadManagementService)({ |
| 480 | getThreadShell: (id) => |
| 481 | Effect.succeed(McpToolAccessTestkit.liveThreadShell(id, { runtimeMode: "auto" })), |
| 482 | getProjectThreadRecords: () => |
| 483 | Effect.succeed({ |
| 484 | thread: { |
| 485 | id: ThreadId.make("other-project-thread"), |
| 486 | projectId: "other-project", |
| 487 | runtimeMode: "auto", |
| 488 | interactionMode: "default", |
| 489 | deletedAt: null, |
| 490 | }, |
| 491 | } as never), |
| 492 | dispatch: () => Effect.succeed({ sequence: 7, storedEvents: [] }), |
| 493 | }), |
| 494 | ), |
| 495 | ), |
| 496 | ), |
| 497 | ), |
| 498 | ); |
| 499 | |
| 500 | it.effect("a read-only client reads threads and is refused every write before it runs", () => |
| 501 | Effect.gen(function* () { |
| 502 | const server = yield* McpServer.McpServer; |
| 503 | const call = (name: string, args: Record<string, unknown>) => |
| 504 | server |
| 505 | .callTool({ name, arguments: args }) |
| 506 | .pipe( |
| 507 | Effect.provideService( |
| 508 | McpInvocationContext.McpInvocationContext, |
| 509 | clientScope("read-only"), |
| 510 | ), |
| 511 | Effect.provideService(McpSchema.McpServerClient, client), |
| 512 | ); |
| 513 | |
| 514 | const configuration = yield* call("t3_thread_configuration", { |
| 515 | threadId: "other-project-thread", |
| 516 | }); |
| 517 | expect(configuration.isError).toBe(false); |
| 518 | expect(configuration.structuredContent).toMatchObject({ runtimeMode: "auto" }); |
| 519 | |
| 520 | const pinned = yield* call("t3_thread_organize", { |
| 521 | action: "pin", |
| 522 | threadId: "other-project-thread", |
| 523 | }); |
| 524 | expect(declaredFailure(pinned)).toMatchObject({ code: "capability_denied" }); |
| 525 | expect(dispatched).toEqual([]); |
| 526 | |
| 527 | const configure = yield* call("t3_thread_configure", { |
| 528 | threadId: "other-project-thread", |
| 529 | modelSelection: { instanceId: "codex", model: "gpt-5" }, |
| 530 | }); |
| 531 | expect(declaredFailure(configure)).toMatchObject({ code: "capability_denied" }); |
| 532 | expect(dispatched).toEqual([]); |
| 533 | }).pipe( |
| 534 | Effect.provide( |
| 535 | layerThreadToolkit.pipe( |
| 536 | Layer.provideMerge(McpServer.McpServer.layer), |
| 537 | Layer.provide(NodeCrypto.layer), |
| 538 | Layer.provide( |
| 539 | Layer.mock(ThreadManagement.ThreadManagementService)({ |
| 540 | getThreadShell: () => |
| 541 | Effect.succeed({ |
| 542 | id: ThreadId.make("other-project-thread"), |
| 543 | projectId: "other-project", |
| 544 | deletedAt: null, |
| 545 | } as never), |
| 546 | getProjectThreadRecords: () => |
| 547 | Effect.succeed({ |
| 548 | thread: { |
| 549 | id: ThreadId.make("other-project-thread"), |
| 550 | projectId: "other-project", |
| 551 | modelSelection: { instanceId: "codex", model: "gpt-5" }, |
| 552 | runtimeMode: "auto", |
| 553 | interactionMode: "default", |
| 554 | deletedAt: null, |
| 555 | }, |
| 556 | } as never), |
| 557 | dispatch: () => |
| 558 | Effect.sync(() => { |
| 559 | dispatched.push("dispatch"); |
| 560 | return { sequence: 7 } as never; |
| 561 | }), |
| 562 | }), |
| 563 | ), |
| 564 | ), |
| 565 | ), |
| 566 | ), |
| 567 | ); |
| 568 | const dispatched: Array<string> = []; |
| 569 | |
| 570 | it.effect("refuses act-as-caller tools to a client caller", () => |
| 571 | Effect.gen(function* () { |
| 572 | const server = yield* McpServer.McpServer; |
| 573 | const result = yield* server |
| 574 | .callTool({ |
| 575 | name: "delegate_task", |
| 576 | arguments: { task: "Review", mode: "async" }, |
| 577 | }) |
| 578 | .pipe( |
| 579 | Effect.provideService( |
| 580 | McpInvocationContext.McpInvocationContext, |
| 581 | clientScope("full-access"), |
| 582 | ), |
| 583 | Effect.provideService(McpSchema.McpServerClient, client), |
| 584 | ); |
| 585 | expect(declaredFailure(result)).toMatchObject({ code: "thread_credential_required" }); |
| 586 | }).pipe( |
| 587 | Effect.provide( |
| 588 | layerOrchestratorToolkit.pipe( |
| 589 | Layer.provideMerge(McpServer.McpServer.layer), |
| 590 | Layer.provide(NodeCrypto.layer), |
| 591 | Layer.provide(Layer.mock(ThreadManagement.ThreadManagementService)({})), |
| 592 | Layer.provide(ServerSettings.layerTest()), |
| 593 | Layer.provide(Layer.mock(ProviderRegistry.ProviderRegistry)({})), |
| 594 | Layer.provide(Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({})), |
| 595 | Layer.provide(Layer.mock(ScheduledTaskService.ScheduledTaskService)({})), |
| 596 | Layer.provide(Layer.mock(ProjectService.ProjectService)({})), |
| 597 | Layer.provide(Layer.mock(SecretRequests.SecretRequests)({})), |
| 598 | ), |
| 599 | ), |
| 600 | ), |
| 601 | ); |
| 602 | |
| 603 | it.effect("a caller cannot rewrite a scheduled task that runs above its own modes", () => |
| 604 | Effect.gen(function* () { |
| 605 | const server = yield* McpServer.McpServer; |
| 606 | const call = (name: string, args: Record<string, unknown>) => |
| 607 | server |
| 608 | .callTool({ name, arguments: args }) |
| 609 | .pipe( |
| 610 | Effect.provideService(McpInvocationContext.McpInvocationContext, clientScope("auto")), |
| 611 | Effect.provideService(McpSchema.McpServerClient, client), |
| 612 | ); |
| 613 | const update = yield* call("update_scheduled_task", { |
| 614 | scheduledTaskId: "task-full-access", |
| 615 | prompt: "Run something else", |
| 616 | }); |
| 617 | expect(declaredFailure(update)).toMatchObject({ code: "runtime_mode_escalation_denied" }); |
| 618 | const remove = yield* call("delete_scheduled_task", { scheduledTaskId: "task-full-access" }); |
| 619 | expect(declaredFailure(remove)).toMatchObject({ code: "runtime_mode_escalation_denied" }); |
| 620 | const allowed = yield* call("update_scheduled_task", { |
| 621 | scheduledTaskId: "task-auto", |
| 622 | enabled: false, |
| 623 | }); |
| 624 | expect(allowed.isError).toBe(false); |
| 625 | }).pipe( |
| 626 | Effect.provide( |
| 627 | layerOrchestratorToolkit.pipe( |
| 628 | Layer.provideMerge(McpServer.McpServer.layer), |
| 629 | Layer.provide(NodeCrypto.layer), |
| 630 | Layer.provide(Layer.mock(ThreadManagement.ThreadManagementService)({})), |
| 631 | Layer.provide(ServerSettings.layerTest()), |
| 632 | Layer.provide(Layer.mock(ProviderRegistry.ProviderRegistry)({})), |
| 633 | Layer.provide(Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({})), |
| 634 | Layer.provide( |
| 635 | Layer.mock(ScheduledTaskService.ScheduledTaskService)({ |
| 636 | list: () => |
| 637 | Effect.succeed({ |
| 638 | tasks: [ |
| 639 | scheduledTask("task-full-access", "full-access"), |
| 640 | scheduledTask("task-auto", "auto"), |
| 641 | ], |
| 642 | }), |
| 643 | upsert: (input) => |
| 644 | Effect.succeed({ |
| 645 | task: { ...(scheduledTask(input.id ?? "task-auto", "auto") as object), ...input }, |
| 646 | } as never), |
| 647 | }), |
| 648 | ), |
| 649 | Layer.provide(Layer.mock(ProjectService.ProjectService)({})), |
| 650 | Layer.provide(Layer.mock(SecretRequests.SecretRequests)({})), |
| 651 | ), |
| 652 | ), |
| 653 | ), |
| 654 | ); |
| 655 | |
| 656 | function scheduledTask(id: string, runtimeMode: "auto" | "full-access"): never { |
| 657 | return { |
| 658 | id, |
| 659 | title: id, |
| 660 | prompt: "Check the build", |
| 661 | enabled: true, |
| 662 | projectId: "project-a", |
| 663 | threadId: null, |
| 664 | schedule: { type: "interval", everyMs: 3_600_000 }, |
| 665 | workspaceStrategy: { type: "worktree", baseRef: "main", startFromOrigin: true }, |
| 666 | modelSelection: { instanceId: "codex", model: "gpt-5" }, |
| 667 | runtimeMode, |
| 668 | interactionMode: "default", |
| 669 | createdBy: "user", |
| 670 | creationSource: "web", |
| 671 | nextRunAt: null, |
| 672 | lastRunStatus: "never", |
| 673 | lastRunAt: null, |
| 674 | lastRunThreadId: null, |
| 675 | lastRunError: null, |
| 676 | runCount: 0, |
| 677 | createdAt: "2026-10-01T00:00:00.000Z", |
| 678 | updatedAt: "2026-10-01T00:00:00.000Z", |
| 679 | } as never; |
| 680 | } |
| 681 | |
| 682 | it.effect.each(["t3_thread_interrupt", "t3_workflow_stop"])( |
| 683 | "a caller cannot use %s on a thread that runs above its own modes", |
| 684 | (toolName) => |
| 685 | Effect.gen(function* () { |
| 686 | const server = yield* McpServer.McpServer; |
| 687 | const result = yield* server |
| 688 | .callTool({ |
| 689 | name: toolName, |
| 690 | arguments: { |
| 691 | threadId: "full-access-thread", |
| 692 | ...(toolName === "t3_workflow_stop" ? { subagentId: "workflow-coordinator" } : {}), |
| 693 | }, |
| 694 | }) |
| 695 | .pipe( |
| 696 | Effect.provideService(McpInvocationContext.McpInvocationContext, clientScope("auto")), |
| 697 | Effect.provideService(McpSchema.McpServerClient, client), |
| 698 | ); |
| 699 | expect(declaredFailure(result)).toMatchObject({ code: "runtime_mode_escalation_denied" }); |
| 700 | }).pipe( |
| 701 | Effect.provide( |
| 702 | layerOrchestratorToolkit.pipe( |
| 703 | Layer.provideMerge(McpServer.McpServer.layer), |
| 704 | Layer.provide(NodeCrypto.layer), |
| 705 | Layer.provide( |
| 706 | Layer.mock(ThreadManagement.ThreadManagementService)({ |
| 707 | getThreadShell: () => |
| 708 | Effect.succeed({ |
| 709 | projectId: "project-a", |
| 710 | deletedAt: null, |
| 711 | runtimeMode: "full-access", |
| 712 | interactionMode: "default", |
| 713 | } as never), |
| 714 | getProjectThreadRecords: () => |
| 715 | Effect.succeed({ |
| 716 | thread: { |
| 717 | id: ThreadId.make("full-access-thread"), |
| 718 | projectId: "project-a", |
| 719 | runtimeMode: "full-access", |
| 720 | interactionMode: "default", |
| 721 | deletedAt: null, |
| 722 | }, |
| 723 | runs: [], |
| 724 | } as never), |
| 725 | stopWorkflow: () => Effect.die("workflow stop must not dispatch above the ceiling"), |
| 726 | interruptThread: () => Effect.die("interrupt must not dispatch above the ceiling"), |
| 727 | }), |
| 728 | ), |
| 729 | Layer.provide(ServerSettings.layerTest()), |
| 730 | Layer.provide(Layer.mock(ProviderRegistry.ProviderRegistry)({})), |
| 731 | Layer.provide(Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({})), |
| 732 | Layer.provide(Layer.mock(ScheduledTaskService.ScheduledTaskService)({})), |
| 733 | Layer.provide(Layer.mock(ProjectService.ProjectService)({})), |
| 734 | Layer.provide(Layer.mock(SecretRequests.SecretRequests)({})), |
| 735 | ), |
| 736 | ), |
| 737 | ), |
| 738 | ); |
| 739 | |
| 740 | it.effect("discovers workflow stop targets and requires orchestration capability", () => { |
| 741 | const threadId = ThreadId.make("workflow-parent"); |
| 742 | const shell = McpToolAccessTestkit.liveThreadShell(threadId); |
| 743 | const subagentId = NodeId.make("workflow-coordinator"); |
| 744 | const itemId = TurnItemId.make("workflow-coordinator-activity"); |
| 745 | const row: OrchestrationV2ProjectedTurnItem = { |
| 746 | position: 0, |
| 747 | visibility: "local", |
| 748 | sourceThreadId: threadId, |
| 749 | sourceItemId: itemId, |
| 750 | item: { |
| 751 | id: itemId, |
| 752 | threadId, |
| 753 | runId: shell.activeRunId, |
| 754 | nodeId: subagentId, |
| 755 | providerThreadId: null, |
| 756 | providerTurnId: null, |
| 757 | nativeItemRef: null, |
| 758 | parentItemId: null, |
| 759 | ordinal: 0, |
| 760 | status: "running", |
| 761 | title: "Workflow: checkout review", |
| 762 | startedAt: shell.createdAt, |
| 763 | completedAt: null, |
| 764 | updatedAt: shell.updatedAt, |
| 765 | type: "subagent", |
| 766 | subagentId, |
| 767 | origin: "provider_native", |
| 768 | driver: ProviderDriverKind.make("claudeAgent"), |
| 769 | providerInstanceId: ProviderInstanceId.make("claudeAgent"), |
| 770 | childThreadId: ThreadId.make("workflow-child"), |
| 771 | prompt: "Review checkout", |
| 772 | result: null, |
| 773 | }, |
| 774 | }; |
| 775 | const stopped: Array<{ readonly threadId: ThreadId; readonly subagentId: string }> = []; |
| 776 | return Effect.gen(function* () { |
| 777 | const server = yield* McpServer.McpServer; |
| 778 | const scope = clientScope("full-access"); |
| 779 | const read = yield* server |
| 780 | .callTool({ name: "t3_thread_read", arguments: { threadId, view: "activity" } }) |
| 781 | .pipe( |
| 782 | Effect.provideService(McpInvocationContext.McpInvocationContext, scope), |
| 783 | Effect.provideService(McpSchema.McpServerClient, client), |
| 784 | ); |
| 785 | expect(read.isError).toBe(false); |
| 786 | const result = yield* decodeThreadReadResult(read.structuredContent); |
| 787 | expect(result.items[0]?.subagentId).toBe(subagentId); |
| 788 | const call = (invocation: McpInvocationContext.McpInvocationScope) => |
| 789 | server |
| 790 | .callTool({ |
| 791 | name: "t3_workflow_stop", |
| 792 | arguments: { threadId, subagentId: result.items[0]?.subagentId }, |
| 793 | }) |
| 794 | .pipe( |
| 795 | Effect.provideService(McpInvocationContext.McpInvocationContext, invocation), |
| 796 | Effect.provideService(McpSchema.McpServerClient, client), |
| 797 | ); |
| 798 | const denied = yield* call({ ...scope, capabilities: new Set(["worktree"]) }); |
| 799 | expect(declaredFailure(denied)).toMatchObject({ code: "capability_denied" }); |
| 800 | expect(stopped).toEqual([]); |
| 801 | const allowed = yield* call(scope); |
| 802 | expect(allowed.isError).toBe(false); |
| 803 | expect(stopped).toEqual([{ threadId: "workflow-parent", subagentId: "workflow-coordinator" }]); |
| 804 | }).pipe( |
| 805 | Effect.provide( |
| 806 | layerOrchestratorToolkit.pipe( |
| 807 | Layer.provideMerge(McpServer.McpServer.layer), |
| 808 | Layer.provide(NodeCrypto.layer), |
| 809 | Layer.provide( |
| 810 | Layer.mock(ThreadManagement.ThreadManagementService)({ |
| 811 | getThreadShell: (id) => Effect.succeed(McpToolAccessTestkit.liveThreadShell(id)), |
| 812 | getProjectThreadRecords: () => |
| 813 | Effect.succeed(McpToolAccessTestkit.idleThreadProjection(shell)), |
| 814 | getTimelinePage: () => Effect.succeed({ items: [row], totalItems: 1, hasMore: false }), |
| 815 | stopWorkflow: (input) => |
| 816 | Effect.sync(() => { |
| 817 | stopped.push(input); |
| 818 | }), |
| 819 | }), |
| 820 | ), |
| 821 | Layer.provide(ServerSettings.layerTest()), |
| 822 | Layer.provide(Layer.mock(ProviderRegistry.ProviderRegistry)({})), |
| 823 | Layer.provide(Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({})), |
| 824 | Layer.provide(Layer.mock(ScheduledTaskService.ScheduledTaskService)({})), |
| 825 | Layer.provide(Layer.mock(ProjectService.ProjectService)({})), |
| 826 | Layer.provide(Layer.mock(SecretRequests.SecretRequests)({})), |
| 827 | ), |
| 828 | ), |
| 829 | ); |
| 830 | }); |
| 831 | |
| 832 | it.effect("only the caller that prepared a pending upload can discard it", () => |
| 833 | Effect.gen(function* () { |
| 834 | const server = yield* McpServer.McpServer; |
| 835 | const call = ( |
| 836 | name: string, |
| 837 | args: Record<string, unknown>, |
| 838 | invocation: McpInvocationContext.McpInvocationScope, |
| 839 | ) => |
| 840 | server |
| 841 | .callTool({ name, arguments: args }) |
| 842 | .pipe( |
| 843 | Effect.provideService(McpInvocationContext.McpInvocationContext, invocation), |
| 844 | Effect.provideService(McpSchema.McpServerClient, client), |
| 845 | ); |
| 846 | const prepared = yield* call( |
| 847 | "t3_attachment_prepare_upload", |
| 848 | { upload: { name: "shot.png", mimeType: "image/png", sizeBytes: 4 } }, |
| 849 | scope, |
| 850 | ); |
| 851 | const { attachmentId } = prepared.structuredContent as { readonly attachmentId: string }; |
| 852 | const otherThread = { |
| 853 | ...scope, |
| 854 | requestNamespace: "other-session", |
| 855 | thread: { ...scope.thread!, threadId: ThreadId.make("other-thread") }, |
| 856 | }; |
| 857 | |
| 858 | const refused = yield* call("t3_attachment_discard", { attachmentId }, otherThread); |
| 859 | expect(declaredFailure(refused)).toMatchObject({ code: "invalid_request" }); |
| 860 | |
| 861 | // Another prepare a day later keeps it: the file outlives its URL's 24 hours |
| 862 | // by as long as the upload took, up to the URL's own lifetime. |
| 863 | yield* TestClock.adjust(24 * 60 * 60 * 1000 + 5 * 60_000); |
| 864 | yield* call( |
| 865 | "t3_attachment_prepare_upload", |
| 866 | { upload: { name: "later.png", mimeType: "image/png", sizeBytes: 4 } }, |
| 867 | scope, |
| 868 | ); |
| 869 | |
| 870 | // A new provider session of the same thread still owns the upload. |
| 871 | const discarded = yield* call( |
| 872 | "t3_attachment_discard", |
| 873 | { attachmentId }, |
| 874 | { ...scope, requestNamespace: "mcp-core-session-2" }, |
| 875 | ); |
| 876 | expect(discarded.isError).toBe(false); |
| 877 | }).pipe( |
| 878 | Effect.provide( |
| 879 | McpHttpServer.layerAttachmentToolkit.pipe( |
| 880 | Layer.provideMerge(McpServer.McpServer.layer), |
| 881 | Layer.provide(McpToolAccessTestkit.liveThreadsLayer), |
| 882 | Layer.provide(ServerSecretStore.layer), |
| 883 | Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-mcp-attachment-" })), |
| 884 | Layer.provide(NodeServices.layer), |
| 885 | ), |
| 886 | ), |
| 887 | ), |
| 888 | ); |