From f6799892191362948da8027c1e14da63b7584c44 Mon Sep 17 00:00:00 2001 From: Guillermo Casanova Date: Thu, 17 Sep 2026 14:41:43 -0300 Subject: [PATCH] fix(server): keep preview hosts when optional page metadata times out The preview toolkit follows most operations with a best-effort status read for the tool icon, bounded at 500 ms. The broker treats every unanswered deadline as a dead host and disconnects it, so one slow metadata reply evicted the only desktop host and every later preview call failed with no available host until the app reconnected. Let callers opt out of eviction for best-effort reads. The metadata lookup now fails only itself; the primary action result still returns and the host stays routable. Unanswered primary operations keep evicting as before. Closes #12273 --- apps/server/src/mcp/McpHttpServer.test.ts | 67 +++++++++++++++++++ .../server/src/mcp/PreviewAutomationBroker.ts | 6 +- .../src/mcp/toolkits/preview/handlers.ts | 1 + 3 files changed, 73 insertions(+), 1 deletion(-) 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 }), })