Skip to content
Merged
47 changes: 33 additions & 14 deletions apps/server/src/mcp/McpHttpServer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -250,7 +250,10 @@ it.effect.each([
const { accessibilityTree: _tree, ...boundedMetadata } = metadata;
expect(snapshot.isError).toBe(false);
expect(snapshot.structuredContent).toEqual(metadata);
const [text, ...rest] = snapshot.content;
const [identity, text, ...rest] = snapshot.content;
expect(identity?.type === "text" ? decodeJsonText(identity.text) : null).toEqual({
url: page.url,
});
expect(text?.type === "text" ? decodeJsonText(text.text) : null).toEqual(boundedMetadata);
expect(rest).toEqual([
{
Expand Down Expand Up @@ -279,7 +282,12 @@ it.effect.each([
Effect.provideService(McpInvocationContext.McpInvocationContext, invocation),
Effect.provideService(McpSchema.McpServerClient, client),
);
expect(nextDefault.content.map((content) => content.type)).toEqual(["text", "text", "image"]);
expect(nextDefault.content.map((content) => content.type)).toEqual([
"text",
"text",
"text",
"image",
]);
expect(nextDefault.structuredContent).toEqual({ ...page, title: "Snapshot 7", screenshot });
expect(requests).toBe(7);
}),
Expand Down Expand Up @@ -329,7 +337,7 @@ it.effect("saves the snapshot PNG on request and reports its path", () =>
/^browser-screenshot-example-test-[0-9a-z]+-[0-9a-f]{8}\.png$/,
);
expect(Buffer.from(yield* fileSystem.readFile(screenshotPath!)).toString()).toBe("png");
const text = snapshot.content.find((content) => content.type === "text");
const [, text] = snapshot.content;
expect(text?.type === "text" ? text.text : "").toContain(screenshotPath);

const unsaved = yield* callSnapshot({});
Expand Down Expand Up @@ -429,7 +437,10 @@ it.effect("keeps the snapshot text under the agent's output ceiling", () =>
const snapshot = yield* callSnapshot({ includeImage: false });

expect(snapshot.isError).toBe(false);
const [text, notice] = snapshot.content;
const [identity, text, notice] = snapshot.content;
expect(identity?.type === "text" ? decodeJsonText(identity.text) : null).toEqual({
url: oversized.url,
});
expect(text?.type).toBe("text");
const body = text?.type === "text" ? text.text : "";
expect(Buffer.byteLength(body, "utf8")).toBeLessThanOrEqual(
Expand Down Expand Up @@ -471,7 +482,7 @@ it.effect("bounds the snapshot text even when nothing but logs and the title are

const snapshot = yield* callSnapshot({ includeImage: false });

const [text] = snapshot.content;
const [, text] = snapshot.content;
const body = text?.type === "text" ? text.text : "";
expect(Buffer.byteLength(body, "utf8")).toBeLessThanOrEqual(
McpHttpServer.MAX_SNAPSHOT_TEXT_BYTES,
Expand All @@ -482,7 +493,7 @@ it.effect("bounds the snapshot text even when nothing but logs and the title are
};
expect(parsed.title.length).toBe(2_049);
expect(parsed.consoleEntries[0]?.text.length).toBe(501);
const notice = snapshot.content[1];
const notice = snapshot.content[2];
const noticeText = notice?.type === "text" ? notice.text : "";
expect(noticeText).toContain("url or title after 2048 characters");
expect(noticeText).toContain("console entries text after 500 characters");
Expand Down Expand Up @@ -533,7 +544,7 @@ it.effect("sheds log entries before locators when every list is full", () =>

const snapshot = yield* callSnapshot({ includeImage: false });

const [text, notice] = snapshot.content;
const [, text, notice] = snapshot.content;
const body = text?.type === "text" ? text.text : "";
expect(Buffer.byteLength(body, "utf8")).toBeLessThanOrEqual(
McpHttpServer.MAX_SNAPSHOT_TEXT_BYTES,
Expand Down Expand Up @@ -615,6 +626,10 @@ it.effect("registers annotated tools and preserves authenticated request context
Effect.gen(function* () {
const server = yield* McpServer.McpServer;
const broker = yield* PreviewAutomationBroker.PreviewAutomationBroker;
const toolIcon = {
_tag: "website" as const,
pageUrl: "http://example.test/",
};
const routedRequests: Array<{
readonly operation: string;
readonly tabId?: string | undefined;
Expand Down Expand Up @@ -664,7 +679,7 @@ it.effect("registers annotated tools and preserves authenticated request context
expect(clickTool?.tool.annotations?.readOnlyHint).toBe(false);
expect(clickTool?.tool.annotations?.destructiveHint).toBe(true);
expect(clickTool?.tool.annotations?.openWorldHint).toBe(true);
expect(clickTool?.tool.outputSchema).toEqual({
expect(clickTool?.tool.outputSchema).toMatchObject({
type: "object",
additionalProperties: false,
description: "The preview action completed successfully.",
Expand Down Expand Up @@ -721,10 +736,12 @@ it.effect("registers annotated tools and preserves authenticated request context
Effect.provideService(McpSchema.McpServerClient, client),
);
expect(evaluated.isError).toBe(false);
expect(evaluated.structuredContent).toEqual({ value: ["Connect", "Continue"] });
expect(evaluated.content).toEqual([
{ type: "text", text: '{"value":["Connect","Continue"]}' },
]);
expect(evaluated.structuredContent).toEqual({ value: ["Connect", "Continue"], toolIcon });
const evaluatedText = evaluated.content[0];
expect(evaluatedText?.type === "text" ? decodeJsonText(evaluatedText.text) : null).toEqual({
toolIcon,
value: ["Connect", "Continue"],
});

const actionRequests = [
{ name: "preview_click", arguments: { x: 10, y: 10 } },
Expand All @@ -741,8 +758,10 @@ it.effect("registers annotated tools and preserves authenticated request context
Effect.provideService(McpSchema.McpServerClient, client),
);
expect(result.isError).toBe(false);
expect(result.structuredContent).toEqual({});
expect(result.content).toEqual([{ type: "text", text: "{}" }]);
expect(result.structuredContent).toEqual({ toolIcon });
expect(routedRequests.at(-1)?.operation).toBe("status");
const text = result.content[0];
expect(text?.type === "text" ? decodeJsonText(text.text) : null).toEqual({ toolIcon });
}
}),
).pipe(Effect.provide(TestLayer)),
Expand Down
7 changes: 7 additions & 0 deletions apps/server/src/mcp/McpHttpServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -410,6 +410,13 @@ const registerPreviewSnapshot = Effect.fn("McpHttpServer.registerPreviewSnapshot
isError: false,
structuredContent: metadata,
content: [
// Keep the page identity readable even if a provider truncates the snapshot.
{
type: "text",
text: encodeJsonText({
url: cutText(snapshot.url, MAX_SNAPSHOT_IDENTIFIER_CHARS),
}),
},
{ type: "text", text: bounded.text },
...(bounded.omitted.length === 0
? []
Expand Down
102 changes: 61 additions & 41 deletions apps/server/src/mcp/PreviewAutomationBroker.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,50 +127,70 @@ it.effect("targets multiple tabs explicitly while retaining a default tab", () =
),
);

it.effect("does not let an older response replace a newer explicit tab target", () =>
Effect.scoped(
Effect.gen(function* () {
const broker = yield* makeBroker;
const olderTabId = PreviewTabId.make("tab-older-request");
const newerTabId = PreviewTabId.make("tab-newer-request");
const releaseOlderResponse = yield* Deferred.make<void>();
const routedRequests: RoutedRequest[] = [];
const requests = requestsFrom(yield* broker.connect(makeHost()));
yield* Stream.runForEach(requests, (request) => {
routedRequests.push(request);
const response = Effect.gen(function* () {
if (request.tabId === olderTabId) {
yield* Deferred.await(releaseOlderResponse);
}
yield* broker.respond({
clientId: "client-1",
connectionId: request.connectionId,
requestId: request.requestId,
ok: true,
result: { url: "http://localhost:3200" },
it.effect.each([true, false])(
"keeps an older target stable while a newer explicit tab responds (implicit: %s)",
(implicit) =>
Effect.scoped(
Effect.gen(function* () {
const broker = yield* makeBroker;
const olderTabId = PreviewTabId.make("tab-older-request");
const newerTabId = PreviewTabId.make("tab-newer-request");
const releaseOlderResponse = yield* Deferred.make<void>();
const routedRequests: RoutedRequest[] = [];
const requests = requestsFrom(yield* broker.connect(makeHost()));
yield* Stream.runForEach(requests, (request) => {
routedRequests.push(request);
const response = Effect.gen(function* () {
if (request.tabId === olderTabId && request.operation === "snapshot") {
yield* Deferred.await(releaseOlderResponse);
}
yield* broker.respond({
clientId: "client-1",
connectionId: request.connectionId,
requestId: request.requestId,
ok: true,
result: { url: "http://localhost:3200" },
});
if (request.tabId === newerTabId) {
yield* Deferred.succeed(releaseOlderResponse, undefined);
}
});
if (request.tabId === newerTabId) {
yield* Deferred.succeed(releaseOlderResponse, undefined);
}
return response.pipe(Effect.forkScoped, Effect.asVoid);
}).pipe(Effect.forkScoped);
yield* Effect.yieldNow;

yield* broker.invoke({ scope, operation: "status", input: {}, tabId: olderTabId });
let capturedTabId: PreviewTabId | undefined;
const older = yield* broker
.invoke({
scope,
operation: "snapshot",
input: {},
...(implicit ? {} : { tabId: olderTabId }),
onTargetTab: (tabId) => {
capturedTabId = tabId;
},
})
.pipe(Effect.forkScoped);
yield* Effect.yieldNow;
const newer = yield* broker
.invoke({ scope, operation: "snapshot", input: {}, tabId: newerTabId })
.pipe(Effect.forkScoped);
yield* Fiber.join(newer);
yield* Fiber.join(older);
yield* broker.invoke({
scope,
operation: "status",
input: {},
tabId: olderTabId,
updateCurrentTab: false,
});
return response.pipe(Effect.forkScoped, Effect.asVoid);
}).pipe(Effect.forkScoped);
yield* Effect.yieldNow;

const older = yield* broker
.invoke({ scope, operation: "snapshot", input: {}, tabId: olderTabId })
.pipe(Effect.forkScoped);
yield* Effect.yieldNow;
const newer = yield* broker
.invoke({ scope, operation: "snapshot", input: {}, tabId: newerTabId })
.pipe(Effect.forkScoped);
yield* Fiber.join(newer);
yield* Fiber.join(older);
yield* broker.invoke({ scope, operation: "snapshot", input: {} });
yield* broker.invoke({ scope, operation: "snapshot", input: {} });

expect(routedRequests.at(-1)?.tabId).toBe(newerTabId);
}),
),
expect(routedRequests.at(-1)?.tabId).toBe(newerTabId);
expect(capturedTabId).toBe(olderTabId);
}),
),
);

it.effect("tracks the tab returned by a targeted recording stop", () =>
Expand Down
6 changes: 6 additions & 0 deletions apps/server/src/mcp/PreviewAutomationBroker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,10 @@ export interface PreviewAutomationInvokeInput {
readonly input: unknown;
readonly tabId?: PreviewTabId;
readonly timeoutMs?: number;
/** 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. */
readonly onTargetTab?: (tabId: PreviewTabId | undefined) => void;
}

export class PreviewAutomationBroker extends Context.Service<
Expand Down Expand Up @@ -541,6 +545,7 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() {
});
}
const { connection, requestId, requestContext, requestSequence } = route;
input.onTargetTab?.(requestContext.tabId);
const removePending = SynchronizedRef.update(state, (next) => {
if (!next.pending.has(requestId)) return next;
const pending = new Map(next.pending);
Expand Down Expand Up @@ -575,6 +580,7 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() {
});
});
const result = yield* awaitResponse().pipe(Effect.ensuring(removePending));
if (input.updateCurrentTab === false) return result;
const responseTabId = readResultTabId(result);
const resultTabId = responseTabId === undefined ? input.tabId : responseTabId;
if (resultTabId === undefined) return result;
Expand Down
69 changes: 52 additions & 17 deletions apps/server/src/mcp/toolkits/preview/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
PreviewAutomationRecordingTransferError,
PreviewAutomationRecordingDesktopUpdateRequiredError,
PreviewAutomationRecordingArtifact,
type ToolActivityIcon,
type ThreadId,
type PreviewAutomationOperation,
type PreviewAutomationOpenInput,
Expand Down Expand Up @@ -55,22 +56,47 @@ const invoke = Effect.fn("PreviewToolkit.invoke")(function* <A>(
timeoutMs?: number,
tabId?: PreviewTabId,
): Effect.fn.Return<
A,
{ result: A; toolIcon?: ToolActivityIcon },
import("@t3tools/contracts").PreviewAutomationError,
McpInvocationContext.McpInvocationContext | PreviewAutomationBroker.PreviewAutomationBroker
> {
const scope = yield* McpInvocationContext.requireMcpCapability("preview");
const broker = yield* PreviewAutomationBroker.PreviewAutomationBroker;
return yield* broker.invoke<A>({
let targetTabId = tabId;
const result = yield* broker.invoke<A>({
onTargetTab: (resolvedTabId) => {
targetTabId = resolvedTabId;
},
scope,
operation,
input,
...(timeoutMs === undefined ? {} : { timeoutMs }),
...(tabId === undefined ? {} : { tabId }),
});
if (["status", "open", "navigate", "snapshot"].includes(operation)) return { result };
const statusTabId =
(operation !== "evaluate" && typeof result === "object" && result !== null
? (result as { tabId?: PreviewTabId }).tabId
: undefined) ?? targetTabId;
const page = yield* broker
.invoke<PreviewAutomationStatus>({
scope,
operation: "status",
input: {},
timeoutMs: 500,
updateCurrentTab: false,
...(statusTabId === undefined ? {} : { tabId: statusTabId }),
Comment thread
maria-rcks marked this conversation as resolved.
})
.pipe(Effect.catch(() => Effect.succeed(null)));
return {
result,
...(page?.url && /^https?:\/\//i.test(page.url) && page.url.length <= 4096
? { toolIcon: { _tag: "website" as const, pageUrl: page.url } }
: {}),
};
});

const invokeTargeted = <A>(
const invokeTargeted = <A extends object>(
operation: PreviewAutomationOperation,
input: {
readonly tabId?: PreviewTabId | undefined;
Expand All @@ -79,7 +105,12 @@ const invokeTargeted = <A>(
timeoutMs?: number,
) => {
const { tabId, ...operationInput } = input;
return invoke<A>(operation, operationInput, timeoutMs, tabId);
return invoke<A>(operation, operationInput, timeoutMs, tabId).pipe(
Effect.map(({ result, toolIcon }) => ({
...result,
...(toolIcon ? { toolIcon } : {}),
})),
);
};

const UploadedRecordingArtifact = Schema.Struct({
Expand Down Expand Up @@ -170,28 +201,32 @@ const handlers = {
const { includeImage: _includeImage, save: _save, ...operationInput } = input ?? {};
return invokeTargeted<PreviewAutomationSnapshot>("snapshot", operationInput);
},
preview_click: (input) =>
invokeTargeted<void>("click", input, input.timeoutMs).pipe(Effect.as({})),
preview_type: (input) => invokeTargeted<void>("type", input, input.timeoutMs).pipe(Effect.as({})),
preview_press: (input) => invokeTargeted<void>("press", input).pipe(Effect.as({})),
preview_scroll: (input) => invokeTargeted<void>("scroll", input).pipe(Effect.as({})),
preview_evaluate: (input) =>
invokeTargeted<unknown>("evaluate", input).pipe(
Effect.map((result) => ({ value: result ?? null })),
preview_click: (input) => invokeTargeted<object>("click", input, input.timeoutMs),
preview_type: (input) => invokeTargeted<object>("type", input, input.timeoutMs),
preview_press: (input) => invokeTargeted<object>("press", input),
preview_scroll: (input) => invokeTargeted<object>("scroll", input),
preview_evaluate: ({ tabId, ...input }) =>
invoke<unknown>("evaluate", input, undefined, tabId).pipe(
Effect.map(({ result, toolIcon }) => ({
value: result ?? null,
...(toolIcon ? { toolIcon } : {}),
})),
),
preview_wait_for: (input) =>
invokeTargeted<void>("waitFor", input, input.timeoutMs).pipe(Effect.as({})),
preview_wait_for: (input) => invokeTargeted<object>("waitFor", input, input.timeoutMs),
preview_recording_start: (input) =>
invokeTargeted<PreviewAutomationRecordingStatus>("recordingStart", input ?? {}),
preview_recording_stop: (input) =>
Effect.gen(function* () {
const scope = yield* McpInvocationContext.requireMcpCapability("preview");
const response = yield* invokeTargeted<unknown>(
const { tabId, ...operationInput } = input;
const response = yield* invoke<unknown>(
"recordingStop",
{ ...input, transferToEnvironment: true },
{ ...operationInput, transferToEnvironment: true },
PREVIEW_RECORDING_STOP_TIMEOUT_MS,
tabId,
);
return yield* claimPreviewRecording(scope.threadId, response);
const artifact = yield* claimPreviewRecording(scope.threadId, response.result);
return { ...artifact, ...(response.toolIcon ? { toolIcon: response.toolIcon } : {}) };
}),
} satisfies Parameters<typeof PreviewToolkit.toLayer>[0];

Expand Down
Loading
Loading