| 1 | import * as Orchestrator from "../orchestration-v2/Orchestrator.ts"; |
| 2 | import * as ProjectionStore from "../orchestration-v2/ProjectionStore.ts"; |
| 3 | import { expect, it } from "@effect/vitest"; |
| 4 | import { NodeHttpServer } from "@effect/platform-node"; |
| 5 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 6 | import { |
| 7 | EnvironmentId, |
| 8 | OrchestratorMcpFailure, |
| 9 | PreviewTabId, |
| 10 | ProviderInstanceId, |
| 11 | ThreadId, |
| 12 | } from "@t3tools/contracts"; |
| 13 | import * as Deferred from "effect/Deferred"; |
| 14 | import * as Effect from "effect/Effect"; |
| 15 | import * as FileSystem from "effect/FileSystem"; |
| 16 | import * as Layer from "effect/Layer"; |
| 17 | import * as Path from "effect/Path"; |
| 18 | import * as Schema from "effect/Schema"; |
| 19 | import * as Stream from "effect/Stream"; |
| 20 | import { McpSchema, McpServer, Tool, Toolkit } from "effect/ai"; |
| 21 | import { |
| 22 | HttpBody, |
| 23 | HttpClient, |
| 24 | HttpClientRequest, |
| 25 | HttpRouter, |
| 26 | HttpServerResponse, |
| 27 | } from "effect/http"; |
| 28 | |
| 29 | import * as ProjectService from "../project/ProjectService.ts"; |
| 30 | import * as PullRequestReviewGuideService from "../pullRequest/PullRequestReviewGuideService.ts"; |
| 31 | import * as ServerConfig from "../config.ts"; |
| 32 | import * as McpHttpServer from "./McpHttpServer.ts"; |
| 33 | import * as McpToolAccess from "./McpToolAccess.ts"; |
| 34 | import * as McpToolAccessTestkit from "./McpToolAccess.testkit.ts"; |
| 35 | import * as McpInvocationContext from "./McpInvocationContext.ts"; |
| 36 | import * as McpSessionRegistry from "./McpSessionRegistry.ts"; |
| 37 | import * as PreviewAutomationBroker from "./PreviewAutomationBroker.ts"; |
| 38 | |
| 39 | const environmentId = EnvironmentId.make("environment-mcp-test"); |
| 40 | const threadId = ThreadId.make("thread-mcp-test"); |
| 41 | const tabId = PreviewTabId.make("tab-mcp-test"); |
| 42 | const alternateTabId = PreviewTabId.make("tab-mcp-alternate"); |
| 43 | const decodeJsonText = Schema.decodeUnknownSync(Schema.fromJsonString(Schema.Unknown)); |
| 44 | const invocation = { |
| 45 | environmentId, |
| 46 | requestNamespace: "provider-session-mcp-test", |
| 47 | thread: { |
| 48 | threadId, |
| 49 | providerSessionId: "provider-session-mcp-test", |
| 50 | providerInstanceId: ProviderInstanceId.make("codex"), |
| 51 | }, |
| 52 | client: undefined, |
| 53 | capabilities: new Set(["preview"] as const), |
| 54 | issuedAt: 1, |
| 55 | }; |
| 56 | const client = McpSchema.McpServerClient.of({ |
| 57 | clientId: 1, |
| 58 | clientCapabilities: {}, |
| 59 | clientInfo: { name: "mcp-test", version: "1.0.0" }, |
| 60 | protocolVersion: "2025-06-18", |
| 61 | initializePayload: { |
| 62 | protocolVersion: "2025-06-18", |
| 63 | capabilities: {}, |
| 64 | clientInfo: { name: "mcp-test", version: "1.0.0" }, |
| 65 | }, |
| 66 | getClient: Effect.die("unused"), |
| 67 | }); |
| 68 | const layerTest = McpHttpServer.layerPreviewToolkit.pipe( |
| 69 | Layer.provideMerge(McpServer.McpServer.layer), |
| 70 | Layer.provideMerge(McpToolAccessTestkit.liveThreadsLayer), |
| 71 | Layer.provideMerge(PreviewAutomationBroker.layer), |
| 72 | Layer.provideMerge(ServerConfig.layerTest(process.cwd(), { prefix: "t3-mcp-http-server-test-" })), |
| 73 | Layer.provideMerge(NodeServices.layer), |
| 74 | ); |
| 75 | const layerPullRequestsTest = McpHttpServer.layerPullRequestsToolkit.pipe( |
| 76 | Layer.provideMerge(McpServer.McpServer.layer), |
| 77 | Layer.provideMerge(McpToolAccessTestkit.liveThreadsLayer), |
| 78 | Layer.provide( |
| 79 | Layer.mergeAll( |
| 80 | Layer.mock(ProjectService.ProjectService)({}), |
| 81 | Layer.mock(PullRequestReviewGuideService.PullRequestReviewGuideService)({}), |
| 82 | Layer.mock(Orchestrator.OrchestratorV2)({}), |
| 83 | Layer.mock(ProjectionStore.ProjectionStoreV2)({}), |
| 84 | NodeServices.layer, |
| 85 | ), |
| 86 | ), |
| 87 | ); |
| 88 | |
| 89 | const snapshotResult = { |
| 90 | url: "http://example.test/", |
| 91 | title: "Example", |
| 92 | loading: false, |
| 93 | visibleText: "Example", |
| 94 | interactiveElements: [], |
| 95 | accessibilityTree: {}, |
| 96 | consoleEntries: [], |
| 97 | networkEntries: [], |
| 98 | actionTimeline: [], |
| 99 | screenshot: { |
| 100 | mimeType: "image/png", |
| 101 | data: Buffer.from("png").toString("base64"), |
| 102 | width: 10, |
| 103 | height: 5, |
| 104 | }, |
| 105 | }; |
| 106 | |
| 107 | /** Answers every snapshot request on a fresh broker host with the given result. */ |
| 108 | const serveSnapshots = (clientId: string, result: unknown) => |
| 109 | Effect.gen(function* () { |
| 110 | const broker = yield* PreviewAutomationBroker.PreviewAutomationBroker; |
| 111 | const connected = yield* Deferred.make<void>(); |
| 112 | const inputs: Array<unknown> = []; |
| 113 | const events = yield* broker.connect({ clientId, environmentId }); |
| 114 | yield* Stream.runForEach(events, (event) => { |
| 115 | if (event.type === "connected") return Deferred.succeed(connected, undefined); |
| 116 | inputs.push(event.request.input); |
| 117 | return broker.respond({ |
| 118 | clientId, |
| 119 | connectionId: event.connectionId, |
| 120 | requestId: event.request.requestId, |
| 121 | ok: true, |
| 122 | result, |
| 123 | }); |
| 124 | }).pipe(Effect.forkScoped); |
| 125 | yield* Deferred.await(connected); |
| 126 | return inputs; |
| 127 | }); |
| 128 | |
| 129 | const callSnapshot = (args: Record<string, unknown>) => |
| 130 | Effect.gen(function* () { |
| 131 | const server = yield* McpServer.McpServer; |
| 132 | return yield* server |
| 133 | .callTool({ name: "preview_snapshot", arguments: args }) |
| 134 | .pipe( |
| 135 | Effect.provideService(McpInvocationContext.McpInvocationContext, invocation), |
| 136 | Effect.provideService(McpSchema.McpServerClient, client), |
| 137 | ); |
| 138 | }); |
| 139 | |
| 140 | it("normalizes empty successful notification responses to accepted", () => { |
| 141 | const notificationResponse = McpHttpServer.normalizeMcpHttpResponse( |
| 142 | HttpServerResponse.text("", { status: 200, contentType: "application/json" }), |
| 143 | ); |
| 144 | expect(notificationResponse.status).toBe(202); |
| 145 | |
| 146 | const resultResponse = McpHttpServer.normalizeMcpHttpResponse( |
| 147 | HttpServerResponse.jsonUnsafe({ jsonrpc: "2.0", id: 1, result: {} }), |
| 148 | ); |
| 149 | expect(resultResponse.status).toBe(200); |
| 150 | }); |
| 151 | |
| 152 | it.effect.each([{}, { includeImage: false }])( |
| 153 | "returns bounded structural preview snapshot failures %#", |
| 154 | (input) => |
| 155 | Effect.scoped( |
| 156 | Effect.gen(function* () { |
| 157 | const server = yield* McpServer.McpServer; |
| 158 | const broker = yield* PreviewAutomationBroker.PreviewAutomationBroker; |
| 159 | const events = yield* broker.connect({ |
| 160 | clientId: "mcp-failure-client", |
| 161 | environmentId, |
| 162 | }); |
| 163 | yield* Stream.runForEach(events, (event) => |
| 164 | event.type === "connected" |
| 165 | ? Effect.void |
| 166 | : broker.respond({ |
| 167 | clientId: "mcp-failure-client", |
| 168 | connectionId: event.connectionId, |
| 169 | requestId: event.request.requestId, |
| 170 | ok: false, |
| 171 | error: { |
| 172 | _tag: "PreviewAutomationExecutionError", |
| 173 | message: "sensitive renderer failure", |
| 174 | detail: { consoleOutput: "sensitive browser output" }, |
| 175 | }, |
| 176 | }), |
| 177 | ).pipe(Effect.forkScoped); |
| 178 | yield* Effect.yieldNow; |
| 179 | |
| 180 | const snapshot = yield* server |
| 181 | .callTool({ name: "preview_snapshot", arguments: input }) |
| 182 | .pipe( |
| 183 | Effect.provideService(McpInvocationContext.McpInvocationContext, invocation), |
| 184 | Effect.provideService(McpSchema.McpServerClient, client), |
| 185 | ); |
| 186 | |
| 187 | const message = "Preview automation snapshot failed on client mcp-failure-client."; |
| 188 | expect(snapshot.isError).toBe(true); |
| 189 | expect(snapshot.content).toEqual([ |
| 190 | { type: "text", text: `Preview snapshot failed: ${message}` }, |
| 191 | ]); |
| 192 | expect(snapshot.structuredContent).toEqual({ |
| 193 | error: { |
| 194 | _tag: "PreviewAutomationExecutionError", |
| 195 | operation: "snapshot", |
| 196 | failureCount: 1, |
| 197 | message, |
| 198 | }, |
| 199 | }); |
| 200 | }), |
| 201 | ).pipe(Effect.provide(layerTest)), |
| 202 | ); |
| 203 | |
| 204 | it.effect.each([ |
| 205 | { args: {}, advice: "No active preview tab was found for snapshot. Call preview_open first." }, |
| 206 | { |
| 207 | args: { tabId: alternateTabId }, |
| 208 | advice: `Preview tab ${alternateTabId} was not found for snapshot. Omit tabId to use the current tab, or call preview_open.`, |
| 209 | }, |
| 210 | ])("tells the agent to open a tab when the snapshot has none $args", ({ args, advice }) => |
| 211 | Effect.scoped( |
| 212 | Effect.gen(function* () { |
| 213 | const broker = yield* PreviewAutomationBroker.PreviewAutomationBroker; |
| 214 | const connected = yield* Deferred.make<void>(); |
| 215 | const events = yield* broker.connect({ clientId: "mcp-no-tab-client", environmentId }); |
| 216 | yield* Stream.runForEach(events, (event) => |
| 217 | event.type === "connected" |
| 218 | ? Deferred.succeed(connected, undefined) |
| 219 | : broker.respond({ |
| 220 | clientId: "mcp-no-tab-client", |
| 221 | connectionId: event.connectionId, |
| 222 | requestId: event.request.requestId, |
| 223 | ok: false, |
| 224 | error: { _tag: "PreviewAutomationTabNotFoundError", message: "no tab" }, |
| 225 | }), |
| 226 | ).pipe(Effect.forkScoped); |
| 227 | yield* Deferred.await(connected); |
| 228 | |
| 229 | const snapshot = yield* callSnapshot(args); |
| 230 | |
| 231 | expect(snapshot.isError).toBe(true); |
| 232 | expect(snapshot.content).toEqual([ |
| 233 | { type: "text", text: `Preview snapshot failed: ${advice}` }, |
| 234 | ]); |
| 235 | }), |
| 236 | ).pipe(Effect.provide(layerTest)), |
| 237 | ); |
| 238 | |
| 239 | it.effect("tells the agent how to recover when no preview host can run the snapshot", () => |
| 240 | Effect.gen(function* () { |
| 241 | const snapshot = yield* callSnapshot({}); |
| 242 | |
| 243 | expect(snapshot.isError).toBe(true); |
| 244 | const [text] = snapshot.content; |
| 245 | expect(text?.type === "text" ? text.text : "").toContain( |
| 246 | "Retry preview_status, then call preview_open", |
| 247 | ); |
| 248 | expect(snapshot.structuredContent).toMatchObject({ |
| 249 | error: { _tag: "PreviewAutomationNoAvailableHostError" }, |
| 250 | }); |
| 251 | }).pipe(Effect.provide(layerTest)), |
| 252 | ); |
| 253 | |
| 254 | it.effect.each([ |
| 255 | { mode: "default", input: {}, images: false }, |
| 256 | { mode: "explicit image", input: { includeImage: true }, images: true }, |
| 257 | { mode: "text only", input: { includeImage: false }, images: false }, |
| 258 | ])("returns fresh $mode snapshots on repeated MCP calls", ({ input, images }) => |
| 259 | Effect.scoped( |
| 260 | Effect.gen(function* () { |
| 261 | const server = yield* McpServer.McpServer; |
| 262 | const broker = yield* PreviewAutomationBroker.PreviewAutomationBroker; |
| 263 | const connected = yield* Deferred.make<void>(); |
| 264 | const png = |
| 265 | "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jRZkAAAAASUVORK5CYII="; |
| 266 | const page = { |
| 267 | url: "http://example.test/", |
| 268 | loading: false, |
| 269 | visibleText: "Save your changes", |
| 270 | interactiveElements: [ |
| 271 | { |
| 272 | tag: "button", |
| 273 | role: "button", |
| 274 | name: "Save", |
| 275 | selector: "#save", |
| 276 | x: 0, |
| 277 | y: 0, |
| 278 | width: 20, |
| 279 | height: 10, |
| 280 | }, |
| 281 | ], |
| 282 | accessibilityTree: { role: "document", name: "Example" }, |
| 283 | consoleEntries: [], |
| 284 | networkEntries: [], |
| 285 | actionTimeline: [], |
| 286 | }; |
| 287 | const screenshot = { mimeType: "image/png", width: 1, height: 1 }; |
| 288 | let requests = 0; |
| 289 | const events = yield* broker.connect({ clientId: "mcp-image-option-client", environmentId }); |
| 290 | yield* Stream.runForEach(events, (event) => { |
| 291 | if (event.type === "connected") return Deferred.succeed(connected, undefined); |
| 292 | requests += 1; |
| 293 | expect(event.request).toMatchObject({ |
| 294 | operation: "snapshot", |
| 295 | tabId: alternateTabId, |
| 296 | threadId, |
| 297 | }); |
| 298 | expect(event.request.input).toEqual({}); |
| 299 | return broker.respond({ |
| 300 | clientId: "mcp-image-option-client", |
| 301 | connectionId: event.connectionId, |
| 302 | requestId: event.request.requestId, |
| 303 | ok: true, |
| 304 | result: { |
| 305 | ...page, |
| 306 | title: `Snapshot ${requests}`, |
| 307 | screenshot: { ...screenshot, data: png }, |
| 308 | }, |
| 309 | }); |
| 310 | }).pipe(Effect.forkScoped); |
| 311 | yield* Deferred.await(connected); |
| 312 | |
| 313 | for (const call of [1, 2, 3, 4, 5, 6]) { |
| 314 | const snapshot = yield* server |
| 315 | .callTool({ |
| 316 | name: "preview_snapshot", |
| 317 | arguments: { ...input, tabId: alternateTabId }, |
| 318 | }) |
| 319 | .pipe( |
| 320 | Effect.provideService(McpInvocationContext.McpInvocationContext, invocation), |
| 321 | Effect.provideService(McpSchema.McpServerClient, client), |
| 322 | ); |
| 323 | const metadata = { ...page, title: `Snapshot ${call}`, screenshot }; |
| 324 | const { accessibilityTree: _tree, ...boundedMetadata } = metadata; |
| 325 | expect(snapshot.isError).toBe(false); |
| 326 | expect(snapshot.structuredContent).toEqual({ |
| 327 | ...boundedMetadata, |
| 328 | omitted: ["accessibilityTree (use interactiveElements locators or preview_evaluate)"], |
| 329 | }); |
| 330 | const [identity, text, ...rest] = snapshot.content; |
| 331 | expect(identity?.type === "text" ? decodeJsonText(identity.text) : null).toEqual({ |
| 332 | url: page.url, |
| 333 | }); |
| 334 | expect(text?.type === "text" ? decodeJsonText(text.text) : null).toEqual(boundedMetadata); |
| 335 | expect(rest).toEqual([ |
| 336 | { |
| 337 | type: "text", |
| 338 | text: "Snapshot text was bounded. Omitted: accessibilityTree (use interactiveElements locators or preview_evaluate).", |
| 339 | }, |
| 340 | ...(images |
| 341 | ? [ |
| 342 | { |
| 343 | type: "image", |
| 344 | mimeType: "image/png", |
| 345 | data: new Uint8Array(Buffer.from(png, "base64")), |
| 346 | }, |
| 347 | ] |
| 348 | : []), |
| 349 | ]); |
| 350 | } |
| 351 | |
| 352 | // Output selection belongs to this call, not the MCP session's history. |
| 353 | const nextDefault = yield* server |
| 354 | .callTool({ |
| 355 | name: "preview_snapshot", |
| 356 | arguments: { tabId: alternateTabId }, |
| 357 | }) |
| 358 | .pipe( |
| 359 | Effect.provideService(McpInvocationContext.McpInvocationContext, invocation), |
| 360 | Effect.provideService(McpSchema.McpServerClient, client), |
| 361 | ); |
| 362 | expect(nextDefault.content.map((content) => content.type)).toEqual(["text", "text", "text"]); |
| 363 | expect(nextDefault.structuredContent).toMatchObject({ title: "Snapshot 7", screenshot }); |
| 364 | expect(nextDefault.structuredContent).not.toHaveProperty("accessibilityTree"); |
| 365 | expect(requests).toBe(7); |
| 366 | }), |
| 367 | ).pipe(Effect.provide(layerTest)), |
| 368 | ); |
| 369 | |
| 370 | it.effect("refuses preview tools to a client outside a thread before they run", () => |
| 371 | Effect.gen(function* () { |
| 372 | const server = yield* McpServer.McpServer; |
| 373 | const readOnly = { |
| 374 | ...invocation, |
| 375 | thread: undefined, |
| 376 | requestNamespace: "client:session-1", |
| 377 | client: { sessionId: "session-1", label: "Claude Code", access: "read-only" as const }, |
| 378 | }; |
| 379 | const click = yield* server |
| 380 | .callTool({ name: "preview_click", arguments: { locator: "text=Send" } }) |
| 381 | .pipe( |
| 382 | Effect.provideService(McpInvocationContext.McpInvocationContext, readOnly), |
| 383 | Effect.provideService(McpSchema.McpServerClient, client), |
| 384 | ); |
| 385 | expect(click.isError).toBe(true); |
| 386 | expect(click.content).toEqual([ |
| 387 | { type: "text", text: expect.stringContaining("needs an agent running inside T3 Code") }, |
| 388 | ]); |
| 389 | }).pipe(Effect.provide(layerTest)), |
| 390 | ); |
| 391 | |
| 392 | it.effect("rejects non-boolean snapshot image options before selecting a browser host", () => |
| 393 | Effect.gen(function* () { |
| 394 | const server = yield* McpServer.McpServer; |
| 395 | for (const includeImage of ["false", 0, null]) { |
| 396 | const result = yield* server |
| 397 | .callTool({ |
| 398 | name: "preview_snapshot", |
| 399 | arguments: { includeImage }, |
| 400 | }) |
| 401 | .pipe( |
| 402 | Effect.provideService(McpInvocationContext.McpInvocationContext, invocation), |
| 403 | Effect.provideService(McpSchema.McpServerClient, client), |
| 404 | ); |
| 405 | expect(result.isError).toBe(true); |
| 406 | expect(result.content).toEqual([{ type: "text", text: "Preview snapshot failed: AiError." }]); |
| 407 | expect(result.structuredContent).toEqual({ |
| 408 | error: { _tag: "AiError", operation: "snapshot", failureCount: 1 }, |
| 409 | }); |
| 410 | } |
| 411 | }).pipe(Effect.provide(layerTest)), |
| 412 | ); |
| 413 | |
| 414 | it.effect("saves the snapshot PNG on request and reports its path", () => |
| 415 | Effect.scoped( |
| 416 | Effect.gen(function* () { |
| 417 | const config = yield* ServerConfig.ServerConfig; |
| 418 | const fileSystem = yield* FileSystem.FileSystem; |
| 419 | const path = yield* Path.Path; |
| 420 | const inputs = yield* serveSnapshots("mcp-save-client", snapshotResult); |
| 421 | |
| 422 | const snapshot = yield* callSnapshot({ save: true, includeImage: true }); |
| 423 | |
| 424 | expect(snapshot.isError).toBe(false); |
| 425 | // The browser never receives the server-only `save` flag. |
| 426 | expect(inputs).toEqual([{}]); |
| 427 | expect(snapshot.content.map((content) => content.type)).toContain("image"); |
| 428 | const structured = snapshot.structuredContent as { readonly screenshotPath?: string }; |
| 429 | const screenshotPath = structured.screenshotPath; |
| 430 | expect(typeof screenshotPath).toBe("string"); |
| 431 | expect(path.dirname(screenshotPath!)).toBe(config.browserArtifactsDir); |
| 432 | expect(path.basename(screenshotPath!)).toMatch( |
| 433 | /^browser-screenshot-example-test-[0-9a-z]+-[0-9a-f]{8}\.png$/, |
| 434 | ); |
| 435 | expect(Buffer.from(yield* fileSystem.readFile(screenshotPath!)).toString()).toBe("png"); |
| 436 | const [, text] = snapshot.content; |
| 437 | expect(text?.type === "text" ? text.text : "").toContain(screenshotPath); |
| 438 | |
| 439 | const unsaved = yield* callSnapshot({}); |
| 440 | expect(unsaved.structuredContent).not.toHaveProperty("screenshotPath"); |
| 441 | |
| 442 | // A save without the image skips the page dump. |
| 443 | const pathOnly = yield* callSnapshot({ save: true }); |
| 444 | const saved = pathOnly.structuredContent as { readonly screenshotPath: string }; |
| 445 | expect(saved).toEqual({ url: snapshotResult.url, screenshotPath: expect.any(String) }); |
| 446 | expect(Buffer.from(yield* fileSystem.readFile(saved.screenshotPath)).toString()).toBe("png"); |
| 447 | const [only, ...others] = pathOnly.content; |
| 448 | expect(others).toEqual([]); |
| 449 | expect(only?.type === "text" ? decodeJsonText(only.text) : null).toEqual(saved); |
| 450 | }), |
| 451 | ).pipe(Effect.provide(layerTest)), |
| 452 | ); |
| 453 | |
| 454 | it.effect("reports a tagged error when the screenshot cannot be saved", () => |
| 455 | Effect.scoped( |
| 456 | Effect.gen(function* () { |
| 457 | const config = yield* ServerConfig.ServerConfig; |
| 458 | const fileSystem = yield* FileSystem.FileSystem; |
| 459 | // A regular file where the artifacts directory should be makes every write fail. |
| 460 | yield* fileSystem.writeFileString(config.browserArtifactsDir, ""); |
| 461 | yield* serveSnapshots("mcp-save-failure-client", snapshotResult); |
| 462 | |
| 463 | const snapshot = yield* callSnapshot({ save: true }); |
| 464 | |
| 465 | expect(snapshot.isError).toBe(true); |
| 466 | expect(snapshot.content).toEqual([ |
| 467 | { type: "text", text: "Preview snapshot failed: PreviewScreenshotSaveError." }, |
| 468 | ]); |
| 469 | expect(snapshot.structuredContent).toEqual({ |
| 470 | error: { _tag: "PreviewScreenshotSaveError", operation: "snapshot", failureCount: 1 }, |
| 471 | }); |
| 472 | }), |
| 473 | ).pipe(Effect.provide(layerTest)), |
| 474 | ); |
| 475 | |
| 476 | it.effect( |
| 477 | "registers the pull request toolkit and surfaces a missing capability as a tool error", |
| 478 | () => |
| 479 | Effect.gen(function* () { |
| 480 | const server = yield* McpServer.McpServer; |
| 481 | const names = server.tools.map(({ tool }) => tool.name); |
| 482 | expect(names).toEqual( |
| 483 | expect.arrayContaining([ |
| 484 | "link_pull_request", |
| 485 | "unlink_pull_request", |
| 486 | "list_thread_pull_requests", |
| 487 | ]), |
| 488 | ); |
| 489 | const linkTool = server.tools.find(({ tool }) => tool.name === "link_pull_request"); |
| 490 | expect(linkTool?.tool.annotations?.idempotentHint).toBe(true); |
| 491 | expect(linkTool?.tool.annotations?.openWorldHint).toBe(false); |
| 492 | expect(linkTool?.tool.description).toContain("Register every pull request you open"); |
| 493 | |
| 494 | const denied = yield* server |
| 495 | .callTool({ name: "list_thread_pull_requests", arguments: {} }) |
| 496 | .pipe( |
| 497 | // A preview-only credential: the token predates the toolkit or was minted elsewhere. |
| 498 | Effect.provideService(McpInvocationContext.McpInvocationContext, invocation), |
| 499 | Effect.provideService(McpSchema.McpServerClient, client), |
| 500 | ); |
| 501 | expect(denied.isError).toBe(true); |
| 502 | expect(denied.content).toEqual([ |
| 503 | { type: "text", text: "MCP credential does not grant the pull-requests capability." }, |
| 504 | ]); |
| 505 | }).pipe(Effect.provide(layerPullRequestsTest)), |
| 506 | ); |
| 507 | |
| 508 | it.effect("returns server ARIA refs to agents in text and structured results", () => |
| 509 | Effect.scoped( |
| 510 | Effect.gen(function* () { |
| 511 | const accessibilityTree = |
| 512 | '- button "delete" [ref=t3-snapshot-e1]\n- iframe [ref=t3-snapshot-e2]:\n - textbox "child" [ref=t3-snapshot-f1e1]'; |
| 513 | yield* serveSnapshots("mcp-server-refs", { ...snapshotResult, accessibilityTree }); |
| 514 | const result = yield* callSnapshot({ includeImage: false }); |
| 515 | expect(result.isError).toBe(false); |
| 516 | expect(result.structuredContent).toMatchObject({ accessibilityTree }); |
| 517 | expect( |
| 518 | result.content.some( |
| 519 | (entry) => entry.type === "text" && entry.text.includes("t3-snapshot-f1e1"), |
| 520 | ), |
| 521 | ).toBe(true); |
| 522 | }), |
| 523 | ).pipe(Effect.provide(layerTest)), |
| 524 | ); |
| 525 | |
| 526 | it.effect("bounds large ARIA trees without dropping every server locator", () => |
| 527 | Effect.scoped( |
| 528 | Effect.gen(function* () { |
| 529 | const accessibilityTree = Array.from( |
| 530 | { length: 2_000 }, |
| 531 | (_, index) => `- button "删除 ${index}" [ref=t3-snapshot-e${index}]`, |
| 532 | ).join("\n"); |
| 533 | yield* serveSnapshots("mcp-large-server-refs", { ...snapshotResult, accessibilityTree }); |
| 534 | const result = yield* callSnapshot({ includeImage: false }); |
| 535 | expect(result.isError).toBe(false); |
| 536 | const metadata = result.structuredContent as { accessibilityTree: string }; |
| 537 | expect(metadata.accessibilityTree).toContain("[ref=t3-snapshot-e0]"); |
| 538 | expect(metadata.accessibilityTree.length).toBeLessThan(accessibilityTree.length); |
| 539 | const body = result.content[1]; |
| 540 | expect(Buffer.byteLength(body?.type === "text" ? body.text : "", "utf8")).toBeLessThanOrEqual( |
| 541 | McpHttpServer.MAX_SNAPSHOT_TEXT_BYTES, |
| 542 | ); |
| 543 | }), |
| 544 | ).pipe(Effect.provide(layerTest)), |
| 545 | ); |
| 546 | |
| 547 | it.effect("keeps the snapshot text under the agent's output ceiling", () => |
| 548 | Effect.scoped( |
| 549 | Effect.gen(function* () { |
| 550 | // Mirrors the real failure: a [role] container whose innerText is the whole |
| 551 | // project list, repeated for several elements, plus a big AX tree. |
| 552 | const pageText = "/Users/theo/Code/project\nClaude, Codex · 79 threads\n".repeat(600); |
| 553 | const element = (name: string, index: number) => ({ |
| 554 | tag: "div", |
| 555 | role: "presentation", |
| 556 | name, |
| 557 | selector: `div:nth-of-type(${index})`, |
| 558 | x: 0, |
| 559 | y: 0, |
| 560 | width: 10, |
| 561 | height: 10, |
| 562 | }); |
| 563 | const oversized = { |
| 564 | ...snapshotResult, |
| 565 | visibleText: pageText, |
| 566 | interactiveElements: [ |
| 567 | element(pageText, 1), |
| 568 | element(pageText, 2), |
| 569 | element(pageText, 3), |
| 570 | element("Continue", 4), |
| 571 | ], |
| 572 | accessibilityTree: { nodes: Array.from({ length: 2_000 }, (_, i) => ({ nodeId: `${i}` })) }, |
| 573 | consoleEntries: Array.from({ length: 100 }, (_, i) => ({ |
| 574 | level: "log", |
| 575 | text: `entry ${i}`, |
| 576 | timestamp: "t", |
| 577 | })), |
| 578 | }; |
| 579 | yield* serveSnapshots("mcp-bounded-client", oversized); |
| 580 | |
| 581 | const snapshot = yield* callSnapshot({ includeImage: false }); |
| 582 | |
| 583 | expect(snapshot.isError).toBe(false); |
| 584 | const [identity, text, notice] = snapshot.content; |
| 585 | expect(identity?.type === "text" ? decodeJsonText(identity.text) : null).toEqual({ |
| 586 | url: oversized.url, |
| 587 | }); |
| 588 | expect(text?.type).toBe("text"); |
| 589 | const body = text?.type === "text" ? text.text : ""; |
| 590 | expect(Buffer.byteLength(body, "utf8")).toBeLessThanOrEqual( |
| 591 | McpHttpServer.MAX_SNAPSHOT_TEXT_BYTES, |
| 592 | ); |
| 593 | const parsed = decodeJsonText(body) as { |
| 594 | readonly accessibilityTree?: unknown; |
| 595 | readonly visibleText: string; |
| 596 | readonly interactiveElements: ReadonlyArray<{ readonly name: string }>; |
| 597 | readonly consoleEntries: ReadonlyArray<{ readonly text: string }>; |
| 598 | }; |
| 599 | expect(parsed.accessibilityTree).toBeUndefined(); |
| 600 | expect(parsed.visibleText.length).toBeLessThanOrEqual(8_001); |
| 601 | expect(parsed.interactiveElements).toHaveLength(4); |
| 602 | expect(parsed.interactiveElements[0]?.name.length).toBeLessThanOrEqual(201); |
| 603 | expect(parsed.interactiveElements[3]?.name).toBe("Continue"); |
| 604 | expect(parsed.consoleEntries).toHaveLength(40); |
| 605 | expect(parsed.consoleEntries[0]?.text).toBe("entry 60"); |
| 606 | expect(notice?.type === "text" ? notice.text : "").toContain("accessibilityTree"); |
| 607 | expect(notice?.type === "text" ? notice.text : "").toContain("60 older console entries"); |
| 608 | // Claude Code shows the model structuredContent instead of the text, so it is bounded too. |
| 609 | expect(snapshot.structuredContent).toEqual({ |
| 610 | ...parsed, |
| 611 | omitted: expect.arrayContaining(["60 older console entries"]), |
| 612 | }); |
| 613 | }), |
| 614 | ).pipe(Effect.provide(layerTest)), |
| 615 | ); |
| 616 | |
| 617 | it.effect("bounds the snapshot text even when nothing but logs and the title are large", () => |
| 618 | Effect.scoped( |
| 619 | Effect.gen(function* () { |
| 620 | const oversized = { |
| 621 | ...snapshotResult, |
| 622 | title: "t".repeat(70_000), |
| 623 | interactiveElements: [], |
| 624 | consoleEntries: [{ level: "log", text: "x".repeat(70_000), timestamp: "t" }], |
| 625 | }; |
| 626 | yield* serveSnapshots("mcp-bounded-logs-client", oversized); |
| 627 | |
| 628 | const snapshot = yield* callSnapshot({ includeImage: false }); |
| 629 | |
| 630 | const [, text] = snapshot.content; |
| 631 | const body = text?.type === "text" ? text.text : ""; |
| 632 | expect(Buffer.byteLength(body, "utf8")).toBeLessThanOrEqual( |
| 633 | McpHttpServer.MAX_SNAPSHOT_TEXT_BYTES, |
| 634 | ); |
| 635 | const parsed = decodeJsonText(body) as { |
| 636 | readonly title: string; |
| 637 | readonly consoleEntries: ReadonlyArray<{ readonly text: string }>; |
| 638 | }; |
| 639 | expect(parsed.title.length).toBe(2_049); |
| 640 | expect(parsed.consoleEntries[0]?.text.length).toBe(501); |
| 641 | const notice = snapshot.content[2]; |
| 642 | const noticeText = notice?.type === "text" ? notice.text : ""; |
| 643 | expect(noticeText).toContain("url or title after 2048 characters"); |
| 644 | expect(noticeText).toContain("console entries text after 500 characters"); |
| 645 | }), |
| 646 | ).pipe(Effect.provide(layerTest)), |
| 647 | ); |
| 648 | |
| 649 | it.effect("bounds page text made of wide characters before dropping locators", () => |
| 650 | Effect.scoped( |
| 651 | Effect.gen(function* () { |
| 652 | // The character caps alone leave 8,000 three-byte characters, about 24 KB. |
| 653 | yield* serveSnapshots("mcp-wide-text-client", { |
| 654 | ...snapshotResult, |
| 655 | visibleText: "界".repeat(9_000), |
| 656 | interactiveElements: Array.from({ length: 20 }, (_, i) => ({ |
| 657 | tag: "button", |
| 658 | role: "button", |
| 659 | name: `Button ${i}`, |
| 660 | selector: `#button-${i}`, |
| 661 | x: 0, |
| 662 | y: 0, |
| 663 | width: 10, |
| 664 | height: 10, |
| 665 | })), |
| 666 | }); |
| 667 | |
| 668 | const snapshot = yield* callSnapshot({ includeImage: false }); |
| 669 | |
| 670 | const [, text, notice] = snapshot.content; |
| 671 | const body = text?.type === "text" ? text.text : ""; |
| 672 | expect(Buffer.byteLength(body, "utf8")).toBeLessThanOrEqual( |
| 673 | McpHttpServer.MAX_SNAPSHOT_TEXT_BYTES, |
| 674 | ); |
| 675 | const parsed = decodeJsonText(body) as { |
| 676 | readonly visibleText: string; |
| 677 | readonly interactiveElements: ReadonlyArray<unknown>; |
| 678 | }; |
| 679 | expect(parsed.visibleText).toMatch(/^界+…$/); |
| 680 | expect(parsed.interactiveElements).toHaveLength(20); |
| 681 | expect(notice?.type === "text" ? notice.text : "").toContain( |
| 682 | "visibleText after 4000 characters", |
| 683 | ); |
| 684 | }), |
| 685 | ).pipe(Effect.provide(layerTest)), |
| 686 | ); |
| 687 | |
| 688 | it.effect("sheds log entries before locators when every list is full", () => |
| 689 | Effect.scoped( |
| 690 | Effect.gen(function* () { |
| 691 | const long = "x".repeat(2_000); |
| 692 | const oversized = { |
| 693 | ...snapshotResult, |
| 694 | interactiveElements: Array.from({ length: 20 }, (_, i) => ({ |
| 695 | tag: "button", |
| 696 | role: "button", |
| 697 | name: `Button ${i}`, |
| 698 | selector: `#button-${i}`, |
| 699 | x: 0, |
| 700 | y: 0, |
| 701 | width: 10, |
| 702 | height: 10, |
| 703 | })), |
| 704 | consoleEntries: Array.from({ length: 200 }, () => ({ |
| 705 | level: long, |
| 706 | text: long, |
| 707 | timestamp: long, |
| 708 | source: long, |
| 709 | })), |
| 710 | networkEntries: Array.from({ length: 200 }, () => ({ |
| 711 | url: long, |
| 712 | method: long, |
| 713 | status: 200, |
| 714 | failed: false, |
| 715 | errorText: long, |
| 716 | timestamp: long, |
| 717 | })), |
| 718 | actionTimeline: Array.from({ length: 200 }, () => ({ |
| 719 | id: long, |
| 720 | action: long, |
| 721 | status: "succeeded", |
| 722 | startedAt: long, |
| 723 | completedAt: long, |
| 724 | error: long, |
| 725 | })), |
| 726 | }; |
| 727 | yield* serveSnapshots("mcp-full-logs-client", oversized); |
| 728 | |
| 729 | const snapshot = yield* callSnapshot({ includeImage: false }); |
| 730 | |
| 731 | const [, text, notice] = snapshot.content; |
| 732 | const body = text?.type === "text" ? text.text : ""; |
| 733 | expect(Buffer.byteLength(body, "utf8")).toBeLessThanOrEqual( |
| 734 | McpHttpServer.MAX_SNAPSHOT_TEXT_BYTES, |
| 735 | ); |
| 736 | const parsed = decodeJsonText(body) as { |
| 737 | readonly interactiveElements: ReadonlyArray<unknown>; |
| 738 | readonly consoleEntries: ReadonlyArray<unknown>; |
| 739 | readonly networkEntries: ReadonlyArray<unknown>; |
| 740 | readonly actionTimeline: ReadonlyArray<unknown>; |
| 741 | }; |
| 742 | // Locators survive; the log lists take the cut. |
| 743 | expect(parsed.interactiveElements).toHaveLength(20); |
| 744 | expect( |
| 745 | parsed.consoleEntries.length + parsed.networkEntries.length + parsed.actionTimeline.length, |
| 746 | ).toBeLessThan(120); |
| 747 | const noticeText = notice?.type === "text" ? notice.text : ""; |
| 748 | expect(noticeText).toContain("40 of 40 actionTimeline"); |
| 749 | expect(noticeText).not.toMatch(/\d+ of \d+ interactiveElements/); |
| 750 | }), |
| 751 | ).pipe(Effect.provide(layerTest)), |
| 752 | ); |
| 753 | |
| 754 | it.effect("terminates HTTP MCP sessions with DELETE", () => |
| 755 | Effect.scoped( |
| 756 | Effect.gen(function* () { |
| 757 | const token = "providerTokenWithoutDots"; |
| 758 | const scope: McpInvocationContext.McpInvocationScope = { |
| 759 | environmentId, |
| 760 | requestNamespace: "provider-session", |
| 761 | thread: { |
| 762 | threadId: ThreadId.make("thread-provider"), |
| 763 | providerSessionId: "provider-session", |
| 764 | providerInstanceId: ProviderInstanceId.make("codex"), |
| 765 | }, |
| 766 | client: undefined, |
| 767 | capabilities: new Set(["orchestration"]), |
| 768 | issuedAt: 1, |
| 769 | }; |
| 770 | const layerServer = McpHttpServer.layerMcpTransport.pipe( |
| 771 | Layer.provide( |
| 772 | Layer.mock(McpSessionRegistry.McpSessionRegistry)({ |
| 773 | resolve: (presented) => |
| 774 | Effect.succeed( |
| 775 | presented === token |
| 776 | ? (scope as McpInvocationContext.McpThreadInvocationScope) |
| 777 | : undefined, |
| 778 | ), |
| 779 | }), |
| 780 | ), |
| 781 | ); |
| 782 | yield* HttpRouter.serve(layerServer, { |
| 783 | disableListenLog: true, |
| 784 | disableLogger: true, |
| 785 | }).pipe(Layer.build); |
| 786 | const httpClient = (yield* HttpClient.HttpClient).pipe( |
| 787 | HttpClient.mapRequest(HttpClientRequest.bearerToken(token)), |
| 788 | ); |
| 789 | |
| 790 | const initializeResponse = yield* httpClient.post("/mcp", { |
| 791 | headers: { accept: "application/json, text/event-stream" }, |
| 792 | body: HttpBody.text( |
| 793 | `{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"mcp-test","version":"1.0.0"}}}`, |
| 794 | "application/json", |
| 795 | ), |
| 796 | }); |
| 797 | const sessionId = initializeResponse.headers["mcp-session-id"]; |
| 798 | expect(initializeResponse.status).toBe(200); |
| 799 | expect(sessionId).not.toBeNull(); |
| 800 | |
| 801 | const missingSessionResponse = yield* httpClient.del("/mcp"); |
| 802 | expect(missingSessionResponse.status).toBe(400); |
| 803 | |
| 804 | const unknownSessionResponse = yield* httpClient.del("/mcp", { |
| 805 | headers: { "mcp-session-id": "unknown-session" }, |
| 806 | }); |
| 807 | expect(unknownSessionResponse.status).toBe(404); |
| 808 | |
| 809 | const terminateResponse = yield* httpClient.del("/mcp", { |
| 810 | headers: { "mcp-session-id": sessionId! }, |
| 811 | }); |
| 812 | expect(terminateResponse.status).toBe(204); |
| 813 | |
| 814 | const reusedSessionResponse = yield* httpClient.post("/mcp", { |
| 815 | headers: { |
| 816 | accept: "application/json, text/event-stream", |
| 817 | "mcp-session-id": sessionId!, |
| 818 | }, |
| 819 | body: HttpBody.text( |
| 820 | `{"jsonrpc":"2.0","id":2,"method":"ping","params":{}}`, |
| 821 | "application/json", |
| 822 | ), |
| 823 | }); |
| 824 | expect(reusedSessionResponse.status).toBe(404); |
| 825 | }), |
| 826 | ).pipe(Effect.provide(NodeHttpServer.layerTest)), |
| 827 | ); |
| 828 | |
| 829 | it.effect("registers annotated tools and preserves authenticated request context", () => |
| 830 | Effect.scoped( |
| 831 | Effect.gen(function* () { |
| 832 | const server = yield* McpServer.McpServer; |
| 833 | const broker = yield* PreviewAutomationBroker.PreviewAutomationBroker; |
| 834 | const toolIcon = { |
| 835 | _tag: "website" as const, |
| 836 | pageUrl: "http://example.test/", |
| 837 | }; |
| 838 | const routedRequests: Array<{ |
| 839 | readonly operation: string; |
| 840 | readonly tabId?: string | undefined; |
| 841 | }> = []; |
| 842 | const events = yield* broker.connect({ |
| 843 | clientId: "mcp-test-client", |
| 844 | environmentId, |
| 845 | }); |
| 846 | yield* Stream.runForEach(events, (event) => { |
| 847 | if (event.type === "connected") return Effect.void; |
| 848 | routedRequests.push(event.request); |
| 849 | return broker.respond({ |
| 850 | clientId: "mcp-test-client", |
| 851 | connectionId: event.connectionId, |
| 852 | requestId: event.request.requestId, |
| 853 | ok: true, |
| 854 | result: |
| 855 | event.request.operation === "snapshot" |
| 856 | ? snapshotResult |
| 857 | : event.request.operation === "evaluate" |
| 858 | ? ["Connect", "Continue"] |
| 859 | : event.request.operation === "press" |
| 860 | ? undefined |
| 861 | : { |
| 862 | available: true, |
| 863 | visible: true, |
| 864 | tabId, |
| 865 | url: "http://example.test/", |
| 866 | title: "Example", |
| 867 | loading: false, |
| 868 | }, |
| 869 | }); |
| 870 | }).pipe(Effect.forkScoped); |
| 871 | yield* Effect.yieldNow; |
| 872 | |
| 873 | const statusTool = server.tools.find(({ tool }) => tool.name === "preview_status"); |
| 874 | expect(statusTool?.tool.annotations?.readOnlyHint).toBe(true); |
| 875 | expect(statusTool?.tool.annotations?.idempotentHint).toBe(true); |
| 876 | expect(statusTool?.tool.annotations?.destructiveHint).toBe(false); |
| 877 | |
| 878 | const snapshotTool = server.tools.find(({ tool }) => tool.name === "preview_snapshot"); |
| 879 | expect(snapshotTool?.tool.annotations?.readOnlyHint).toBe(false); |
| 880 | expect(snapshotTool?.tool.annotations?.idempotentHint).toBe(false); |
| 881 | expect(snapshotTool?.tool.annotations?.openWorldHint).toBe(true); |
| 882 | |
| 883 | const clickTool = server.tools.find(({ tool }) => tool.name === "preview_click"); |
| 884 | expect(clickTool?.tool.annotations?.readOnlyHint).toBe(false); |
| 885 | expect(clickTool?.tool.annotations?.destructiveHint).toBe(true); |
| 886 | expect(clickTool?.tool.annotations?.openWorldHint).toBe(true); |
| 887 | expect(clickTool?.tool.outputSchema).toMatchObject({ |
| 888 | type: "object", |
| 889 | additionalProperties: true, |
| 890 | description: "The preview action completed successfully.", |
| 891 | }); |
| 892 | |
| 893 | const navigateTool = server.tools.find(({ tool }) => tool.name === "preview_navigate"); |
| 894 | expect(navigateTool?.tool.annotations?.destructiveHint).toBe(false); |
| 895 | expect(navigateTool?.tool.annotations?.openWorldHint).toBe(true); |
| 896 | |
| 897 | const status = yield* server |
| 898 | .callTool({ name: "preview_status", arguments: {} }) |
| 899 | .pipe( |
| 900 | Effect.provideService(McpInvocationContext.McpInvocationContext, invocation), |
| 901 | Effect.provideService(McpSchema.McpServerClient, client), |
| 902 | ); |
| 903 | expect(status.isError).toBe(false); |
| 904 | expect(status.structuredContent).toMatchObject({ |
| 905 | available: true, |
| 906 | tabId, |
| 907 | }); |
| 908 | |
| 909 | const malformed = yield* server |
| 910 | .callTool({ name: "preview_click", arguments: { selector: "" } }) |
| 911 | .pipe( |
| 912 | Effect.provideService(McpInvocationContext.McpInvocationContext, invocation), |
| 913 | Effect.provideService(McpSchema.McpServerClient, client), |
| 914 | Effect.flip, |
| 915 | ); |
| 916 | expect(malformed._tag).toBe("InvalidParams"); |
| 917 | |
| 918 | const snapshot = yield* server |
| 919 | .callTool({ |
| 920 | name: "preview_snapshot", |
| 921 | arguments: { tabId: alternateTabId, includeImage: true }, |
| 922 | }) |
| 923 | .pipe( |
| 924 | Effect.provideService(McpInvocationContext.McpInvocationContext, invocation), |
| 925 | Effect.provideService(McpSchema.McpServerClient, client), |
| 926 | ); |
| 927 | expect(snapshot.isError).toBe(false); |
| 928 | expect(snapshot.content.some((content) => content.type === "image")).toBe(true); |
| 929 | expect(snapshot.structuredContent).toMatchObject({ |
| 930 | screenshot: { mimeType: "image/png", width: 10, height: 5 }, |
| 931 | }); |
| 932 | expect(routedRequests.find(({ operation }) => operation === "snapshot")?.tabId).toBe( |
| 933 | alternateTabId, |
| 934 | ); |
| 935 | |
| 936 | // Arrays and primitives are wrapped so structuredContent stays a JSON object. |
| 937 | // Claude Code rejects the whole result otherwise. |
| 938 | const evaluateTool = server.tools.find(({ tool }) => tool.name === "preview_evaluate"); |
| 939 | expect(evaluateTool?.tool.outputSchema).toMatchObject({ type: "object" }); |
| 940 | const evaluated = yield* server |
| 941 | .callTool({ name: "preview_evaluate", arguments: { expression: "buttons()" } }) |
| 942 | .pipe( |
| 943 | Effect.provideService(McpInvocationContext.McpInvocationContext, invocation), |
| 944 | Effect.provideService(McpSchema.McpServerClient, client), |
| 945 | ); |
| 946 | expect(evaluated.isError).toBe(false); |
| 947 | expect(evaluated.structuredContent).toEqual({ value: ["Connect", "Continue"], toolIcon }); |
| 948 | const evaluatedText = evaluated.content[0]; |
| 949 | expect(evaluatedText?.type === "text" ? decodeJsonText(evaluatedText.text) : null).toEqual({ |
| 950 | toolIcon, |
| 951 | value: ["Connect", "Continue"], |
| 952 | }); |
| 953 | |
| 954 | const actionRequests = [ |
| 955 | { name: "preview_click", arguments: { x: 10, y: 10 } }, |
| 956 | { name: "preview_type", arguments: { text: "Hello" } }, |
| 957 | { name: "preview_press", arguments: { key: "Enter" } }, |
| 958 | { name: "preview_scroll", arguments: { deltaY: 100 } }, |
| 959 | { name: "preview_wait_for", arguments: { text: "Example" } }, |
| 960 | ]; |
| 961 | for (const request of actionRequests) { |
| 962 | const result = yield* server |
| 963 | .callTool(request) |
| 964 | .pipe( |
| 965 | Effect.provideService(McpInvocationContext.McpInvocationContext, invocation), |
| 966 | Effect.provideService(McpSchema.McpServerClient, client), |
| 967 | ); |
| 968 | expect(result.isError).toBe(false); |
| 969 | expect(result.structuredContent).toEqual({ toolIcon }); |
| 970 | expect(routedRequests.at(-1)?.operation).toBe("status"); |
| 971 | const text = result.content[0]; |
| 972 | expect(text?.type === "text" ? decodeJsonText(text.text) : null).toEqual({ toolIcon }); |
| 973 | } |
| 974 | }), |
| 975 | ).pipe(Effect.provide(layerTest)), |
| 976 | ); |
| 977 | |
| 978 | it.effect("admits provider and OAuth client credentials and points only clients at OAuth", () => |
| 979 | Effect.scoped( |
| 980 | Effect.gen(function* () { |
| 981 | const providerToken = "providerTokenWithoutDots"; |
| 982 | const clientToken = "client-payload.client-signature"; |
| 983 | const providerScope: McpInvocationContext.McpInvocationScope = { |
| 984 | environmentId, |
| 985 | requestNamespace: "provider-session", |
| 986 | thread: { |
| 987 | threadId: ThreadId.make("thread-provider"), |
| 988 | providerSessionId: "provider-session", |
| 989 | providerInstanceId: ProviderInstanceId.make("codex"), |
| 990 | }, |
| 991 | client: undefined, |
| 992 | capabilities: new Set(["orchestration"]), |
| 993 | issuedAt: 1, |
| 994 | }; |
| 995 | const clientScope: McpInvocationContext.McpInvocationScope = { |
| 996 | environmentId, |
| 997 | requestNamespace: "client:session-1", |
| 998 | thread: undefined, |
| 999 | client: { sessionId: "session-1", label: "Claude Code", access: "auto" }, |
| 1000 | capabilities: new Set(["orchestration"]), |
| 1001 | issuedAt: 1, |
| 1002 | }; |
| 1003 | const seen: Array<McpInvocationContext.McpInvocationScope> = []; |
| 1004 | const ProbeToolkit = Toolkit.make( |
| 1005 | Tool.make("probe", { |
| 1006 | description: "Reports the caller.", |
| 1007 | success: Schema.Struct({ ok: Schema.Boolean }), |
| 1008 | failure: OrchestratorMcpFailure, |
| 1009 | failureMode: "return", |
| 1010 | dependencies: [McpInvocationContext.McpInvocationContext], |
| 1011 | }), |
| 1012 | ); |
| 1013 | const serverLayer = McpHttpServer.toolkitRegistration( |
| 1014 | ProbeToolkit, |
| 1015 | McpToolAccess.toLayer(ProbeToolkit, { |
| 1016 | probe: McpToolAccess.reads(() => |
| 1017 | McpInvocationContext.McpInvocationContext.pipe( |
| 1018 | Effect.tap((scope) => Effect.sync(() => seen.push(scope))), |
| 1019 | Effect.as({ ok: true }), |
| 1020 | ), |
| 1021 | ), |
| 1022 | }), |
| 1023 | ).pipe( |
| 1024 | Layer.provideMerge(McpHttpServer.layerMcpTransport), |
| 1025 | Layer.provide( |
| 1026 | Layer.mock(McpSessionRegistry.McpSessionRegistry)({ |
| 1027 | resolve: (token) => |
| 1028 | Effect.succeed( |
| 1029 | token === providerToken |
| 1030 | ? (providerScope as McpInvocationContext.McpThreadInvocationScope) |
| 1031 | : undefined, |
| 1032 | ), |
| 1033 | }), |
| 1034 | ), |
| 1035 | Layer.provide( |
| 1036 | Layer.succeed(McpHttpServer.McpClientAuthenticator, { |
| 1037 | authenticate: (request) => |
| 1038 | Effect.succeed( |
| 1039 | request.headers.authorization === `Bearer ${clientToken}` ? clientScope : undefined, |
| 1040 | ), |
| 1041 | }), |
| 1042 | ), |
| 1043 | ); |
| 1044 | yield* HttpRouter.serve(serverLayer, { disableListenLog: true, disableLogger: true }).pipe( |
| 1045 | Layer.build, |
| 1046 | ); |
| 1047 | const httpClient = yield* HttpClient.HttpClient; |
| 1048 | const call = (token: string | undefined) => |
| 1049 | Effect.gen(function* () { |
| 1050 | const headers = { |
| 1051 | accept: "application/json, text/event-stream", |
| 1052 | ...(token === undefined ? {} : { authorization: `Bearer ${token}` }), |
| 1053 | }; |
| 1054 | const initialize = yield* httpClient.post("/mcp", { |
| 1055 | headers, |
| 1056 | body: HttpBody.text( |
| 1057 | `{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"probe","version":"1.0.0"}}}`, |
| 1058 | "application/json", |
| 1059 | ), |
| 1060 | }); |
| 1061 | if (initialize.status !== 200) return initialize; |
| 1062 | return yield* httpClient.post("/mcp", { |
| 1063 | headers: { |
| 1064 | ...headers, |
| 1065 | "mcp-session-id": initialize.headers["mcp-session-id"]!, |
| 1066 | "mcp-protocol-version": "2025-06-18", |
| 1067 | }, |
| 1068 | body: HttpBody.text( |
| 1069 | `{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"probe","arguments":{}}}`, |
| 1070 | "application/json", |
| 1071 | ), |
| 1072 | }); |
| 1073 | }); |
| 1074 | |
| 1075 | expect((yield* call(providerToken)).status).toBe(200); |
| 1076 | expect((yield* call(clientToken)).status).toBe(200); |
| 1077 | expect(seen.map((scope) => scope.requestNamespace)).toEqual([ |
| 1078 | "provider-session", |
| 1079 | "client:session-1", |
| 1080 | ]); |
| 1081 | |
| 1082 | const missing = yield* call(undefined); |
| 1083 | expect(missing.status).toBe(401); |
| 1084 | expect(missing.headers["www-authenticate"]).toMatch( |
| 1085 | /^Bearer resource_metadata="http:\/\/[^"]+\/\.well-known\/oauth-protected-resource\/mcp"$/, |
| 1086 | ); |
| 1087 | |
| 1088 | const expiredClient = yield* call("stale-payload.stale-signature"); |
| 1089 | expect(expiredClient.headers["www-authenticate"]).toContain("resource_metadata="); |
| 1090 | expect(expiredClient.headers["www-authenticate"]).toContain('error="invalid_token"'); |
| 1091 | |
| 1092 | const deadProvider = yield* call("deadProviderToken"); |
| 1093 | expect(deadProvider.status).toBe(401); |
| 1094 | expect(deadProvider.headers["www-authenticate"]).toBe('Bearer error="invalid_token"'); |
| 1095 | }), |
| 1096 | ).pipe(Effect.provide(NodeHttpServer.layerTest)), |
| 1097 | ); |