diff --git a/apps/server/src/mcp/McpHttpServer.test.ts b/apps/server/src/mcp/McpHttpServer.test.ts index 5619f19b2549..f4af8ec35a5a 100644 --- a/apps/server/src/mcp/McpHttpServer.test.ts +++ b/apps/server/src/mcp/McpHttpServer.test.ts @@ -4,11 +4,13 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { EnvironmentId, PreviewTabId, ProviderInstanceId, ThreadId } from "@t3tools/contracts"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; +import * as TestClock from "effect/testing/TestClock"; import { McpProtocol, McpSchema, McpServer } from "effect/unstable/ai"; import { HttpBody, HttpClient, HttpRouter, HttpServerResponse } from "effect/unstable/http"; @@ -870,3 +872,68 @@ it.effect("registers annotated tools and preserves authenticated request context }), ).pipe(Effect.provide(TestLayer)), ); + +it.effect("keeps the host when the optional page metadata lookup times out", () => + Effect.scoped( + Effect.gen(function* () { + const server = yield* McpServer.McpServer; + const broker = yield* PreviewAutomationBroker.PreviewAutomationBroker; + const clientId = "mcp-slow-metadata-client"; + const statusResult = { + available: true, + visible: true, + tabId, + url: "http://example.test/", + title: "Example", + loading: false, + }; + const stalled = yield* Deferred.make<{ + readonly connectionId: string; + readonly requestId: string; + }>(); + let stalledOnce = false; + const events = yield* broker.connect({ clientId, environmentId }); + yield* Stream.runForEach(events, (event) => { + if (event.type === "connected") return Effect.void; + // The click's follow-up status read never answers within its budget. + if (event.request.operation === "status" && !stalledOnce) { + stalledOnce = true; + return Deferred.succeed(stalled, { + connectionId: event.connectionId, + requestId: event.request.requestId, + }); + } + return broker.respond({ + clientId, + connectionId: event.connectionId, + requestId: event.request.requestId, + ok: true, + result: event.request.operation === "click" ? undefined : statusResult, + }); + }).pipe(Effect.forkScoped); + yield* Effect.yieldNow; + + const callTool = (name: string, args: Record) => + server + .callTool({ name, arguments: args }) + .pipe( + Effect.provideService(McpInvocationContext.McpInvocationContext, invocation), + Effect.provideService(McpSchema.McpServerClient, client), + ); + + const click = yield* callTool("preview_click", { x: 10, y: 10 }).pipe(Effect.forkScoped); + const late = yield* Deferred.await(stalled); + yield* TestClock.adjust(500); + const clicked = yield* Fiber.join(click); + expect(clicked.isError).toBe(false); + expect(clicked.structuredContent).toEqual({}); + + // The metadata reply landing after its deadline is dropped, not applied. + yield* broker.respond({ clientId, ...late, ok: true, result: statusResult }); + + const status = yield* callTool("preview_status", {}); + expect(status.isError).toBe(false); + expect(status.structuredContent).toMatchObject({ available: true, tabId }); + }), + ).pipe(Effect.provide(TestLayer)), +); diff --git a/apps/server/src/mcp/PreviewAutomationBroker.ts b/apps/server/src/mcp/PreviewAutomationBroker.ts index 65e3064f49a5..83fbfef756f0 100644 --- a/apps/server/src/mcp/PreviewAutomationBroker.ts +++ b/apps/server/src/mcp/PreviewAutomationBroker.ts @@ -45,6 +45,8 @@ export interface PreviewAutomationInvokeInput { readonly input: unknown; readonly tabId?: PreviewTabId; readonly timeoutMs?: number; + /** Best-effort reads keep the host: an unanswered deadline fails only this call. */ + readonly disconnectOnTimeout?: boolean; /** Background metadata reads must not change the agent's current tab. */ readonly updateCurrentTab?: boolean; /** Capture the routed tab before another request changes the current assignment. */ @@ -616,7 +618,9 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() { Effect.gen(function* () { // An unanswered request invalidates this connection. Do not replay // actions: the client may have applied them before becoming unreachable. - yield* disconnect(connection.clientId, connection.queue, true); + if (input.disconnectOnTimeout !== false) { + yield* disconnect(connection.clientId, connection.queue, true); + } return yield* new PreviewAutomationTimeoutError(requestContext); }), onSome: (value) => Effect.succeed(value as A), diff --git a/apps/server/src/mcp/toolkits/preview/handlers.ts b/apps/server/src/mcp/toolkits/preview/handlers.ts index b70e68862683..3a3c1b11f421 100644 --- a/apps/server/src/mcp/toolkits/preview/handlers.ts +++ b/apps/server/src/mcp/toolkits/preview/handlers.ts @@ -84,6 +84,7 @@ const invoke = Effect.fn("PreviewToolkit.invoke")(function* ( operation: "status", input: {}, timeoutMs: 500, + disconnectOnTimeout: false, updateCurrentTab: false, ...(statusTabId === undefined ? {} : { tabId: statusTabId }), })