Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 67 additions & 0 deletions apps/server/src/mcp/McpHttpServer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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<string, unknown>) =>
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)),
);
6 changes: 5 additions & 1 deletion apps/server/src/mcp/PreviewAutomationBroker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -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),
Expand Down
1 change: 1 addition & 0 deletions apps/server/src/mcp/toolkits/preview/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ const invoke = Effect.fn("PreviewToolkit.invoke")(function* <A>(
operation: "status",
input: {},
timeoutMs: 500,
disconnectOnTimeout: false,
updateCurrentTab: false,
...(statusTabId === undefined ? {} : { tabId: statusTabId }),
})
Expand Down
Loading