Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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
88 changes: 48 additions & 40 deletions apps/server/src/mcp/McpHttpServer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import * as PreviewAutomationBroker from "./PreviewAutomationBroker.ts";
const environmentId = EnvironmentId.make("environment-mcp-test");
const threadId = ThreadId.make("thread-mcp-test");
const tabId = PreviewTabId.make("tab-mcp-test");
const alternateTabId = PreviewTabId.make("tab-mcp-alternate");
const invocation = {
environmentId,
threadId,
Expand Down Expand Up @@ -154,49 +155,53 @@ it.effect("registers annotated tools and preserves authenticated request context
Effect.gen(function* () {
const server = yield* McpServer.McpServer;
const broker = yield* PreviewAutomationBroker.PreviewAutomationBroker;
const routedRequests: Array<{
readonly operation: string;
readonly tabId?: string | undefined;
}> = [];
const events = yield* broker.connect({
clientId: "mcp-test-client",
environmentId,
});
yield* Stream.runForEach(events, (event) =>
event.type === "connected"
? Effect.void
: broker.respond({
clientId: "mcp-test-client",
connectionId: event.connectionId,
requestId: event.request.requestId,
ok: true,
result:
event.request.operation === "snapshot"
? {
url: "http://example.test/",
title: "Example",
loading: false,
visibleText: "Example",
interactiveElements: [],
accessibilityTree: {},
consoleEntries: [],
networkEntries: [],
actionTimeline: [],
screenshot: {
mimeType: "image/png",
data: Buffer.from("png").toString("base64"),
width: 10,
height: 5,
},
}
: event.request.operation === "press"
? undefined
: {
available: true,
visible: true,
tabId,
url: "http://example.test/",
title: "Example",
loading: false,
},
}),
).pipe(Effect.forkScoped);
yield* Stream.runForEach(events, (event) => {
if (event.type === "connected") return Effect.void;
routedRequests.push(event.request);
return broker.respond({
clientId: "mcp-test-client",
connectionId: event.connectionId,
requestId: event.request.requestId,
ok: true,
result:
event.request.operation === "snapshot"
? {
url: "http://example.test/",
title: "Example",
loading: false,
visibleText: "Example",
interactiveElements: [],
accessibilityTree: {},
consoleEntries: [],
networkEntries: [],
actionTimeline: [],
screenshot: {
mimeType: "image/png",
data: Buffer.from("png").toString("base64"),
width: 10,
height: 5,
},
}
: event.request.operation === "press"
? undefined
: {
available: true,
visible: true,
tabId,
url: "http://example.test/",
title: "Example",
loading: false,
},
});
}).pipe(Effect.forkScoped);
yield* Effect.yieldNow;

const statusTool = server.tools.find(({ tool }) => tool.name === "preview_status");
Expand Down Expand Up @@ -239,7 +244,7 @@ it.effect("registers annotated tools and preserves authenticated request context
expect(malformed.isError).toBe(true);

const snapshot = yield* server
.callTool({ name: "preview_snapshot", arguments: {} })
.callTool({ name: "preview_snapshot", arguments: { tabId: alternateTabId } })
.pipe(
Effect.provideService(McpInvocationContext.McpInvocationContext, invocation),
Effect.provideService(McpSchema.McpServerClient, client),
Expand All @@ -249,6 +254,9 @@ it.effect("registers annotated tools and preserves authenticated request context
expect(snapshot.structuredContent).toMatchObject({
screenshot: { mimeType: "image/png", width: 10, height: 5 },
});
expect(routedRequests.find(({ operation }) => operation === "snapshot")?.tabId).toBe(
alternateTabId,
);

const press = yield* server
.callTool({ name: "preview_press", arguments: { key: "Enter" } })
Expand Down
90 changes: 90 additions & 0 deletions apps/server/src/mcp/PreviewAutomationBroker.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
type PreviewAutomationStreamEvent,
} from "@t3tools/contracts";
import * as Effect from "effect/Effect";
import * as Deferred from "effect/Deferred";
import * as Fiber from "effect/Fiber";
import * as Result from "effect/Result";
import * as Stream from "effect/Stream";
Expand Down Expand Up @@ -84,6 +85,95 @@ it.effect("atomically registers a connected host and correlates its response", (
),
);

it.effect("targets multiple tabs explicitly while retaining a default tab", () =>
Effect.scoped(
Effect.gen(function* () {
const broker = yield* makeBroker;
const appTabId = PreviewTabId.make("tab-web-app");
const simulatorTabId = PreviewTabId.make("tab-ios-simulator");
const openedTabIds = [appTabId, simulatorTabId];
let openIndex = 0;
const routedRequests: RoutedRequest[] = [];
const requests = requestsFrom(yield* broker.connect(makeHost()));
yield* Stream.runForEach(requests, (request) => {
routedRequests.push(request);
return broker.respond({
clientId: "client-1",
connectionId: request.connectionId,
requestId: request.requestId,
ok: true,
result:
request.operation === "open"
? { available: true, tabId: openedTabIds[openIndex++] }
: { url: "http://localhost:3200" },
});
}).pipe(Effect.forkScoped);
yield* Effect.yieldNow;

yield* broker.invoke({ scope, operation: "open", input: { reuseExistingTab: false } });
yield* broker.invoke({ scope, operation: "open", input: { reuseExistingTab: false } });
yield* broker.invoke({ scope, operation: "snapshot", input: {} });
yield* broker.invoke({ scope, operation: "snapshot", input: {}, tabId: appTabId });
yield* broker.invoke({ scope, operation: "snapshot", input: {} });

expect(routedRequests).toHaveLength(5);
expect(routedRequests[0]?.tabId).toBeUndefined();
expect(routedRequests[1]?.tabId).toBe(appTabId);
expect(routedRequests[2]?.tabId).toBe(simulatorTabId);
expect(routedRequests[2]?.tabIdExplicit).toBe(false);
expect(routedRequests[3]?.tabId).toBe(appTabId);
expect(routedRequests[3]?.tabIdExplicit).toBe(true);
expect(routedRequests[4]?.tabId).toBe(appTabId);
}),
),
);

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" },
});
if (request.tabId === newerTabId) {
yield* Deferred.succeed(releaseOlderResponse, undefined);
}
});
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: {} });

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

it.effect("announces a live replacement stream before delivering requests", () =>
Effect.scoped(
Effect.gen(function* () {
Expand Down
56 changes: 50 additions & 6 deletions apps/server/src/mcp/PreviewAutomationBroker.ts
Comment thread
juliusmarminge marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,13 @@ import {
PreviewAutomationTargetNotEditableError,
PreviewAutomationTimeoutError,
PreviewAutomationUnsupportedClientError,
PreviewTabId,
type PreviewAutomationError,
type PreviewAutomationOperation,
type PreviewAutomationHost,
type PreviewAutomationHostFocus,
type PreviewAutomationResponse,
type PreviewAutomationStreamEvent,
type PreviewTabId,
} from "@t3tools/contracts";
import * as Context from "effect/Context";
import * as Clock from "effect/Clock";
Expand All @@ -29,6 +29,7 @@ import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
import * as Option from "effect/Option";
import * as Queue from "effect/Queue";
import * as Schema from "effect/Schema";
import * as Stream from "effect/Stream";
import * as SynchronizedRef from "effect/SynchronizedRef";

Expand Down Expand Up @@ -79,6 +80,8 @@ interface HostAssignment {
readonly connectionId: ClientConnection["connectionId"];
readonly queue: ClientConnection["queue"];
readonly expiresAt: number;
readonly tabId?: PreviewTabId;
readonly tabSequence?: number;
}

interface PreviewAutomationRequestErrorContext {
Expand Down Expand Up @@ -144,6 +147,14 @@ const selectorDiagnosticsFromInput = (
const hostAssignmentKey = (scope: McpInvocationContext.McpInvocationScope): string =>
`${scope.environmentId}\u0000${scope.providerSessionId}`;

const isPreviewTabId = Schema.is(PreviewTabId);

const readResultTabId = (result: unknown): PreviewTabId | null | undefined => {
if (typeof result !== "object" || result === null || !("tabId" in result)) return undefined;
const tabId = result.tabId;
return tabId === null || isPreviewTabId(tabId) ? tabId : undefined;
};

const supportsOperation = (
connection: ClientConnection,
operation: PreviewAutomationOperation,
Expand Down Expand Up @@ -459,10 +470,13 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() {
connectionId: connection.connectionId,
queue: connection.queue,
expiresAt: input.scope.expiresAt,
...(assigned?.tabId === undefined ? {} : { tabId: assigned.tabId }),
...(assigned?.tabSequence === undefined ? {} : { tabSequence: assigned.tabSequence }),
});

const requestId = `preview-${current.requestSequence}`;
const tabId = input.tabId;
const requestSequence = current.requestSequence;
const requestId = `preview-${requestSequence}`;
const tabId = input.tabId ?? assigned?.tabId;
const selectorDiagnostics = selectorDiagnosticsFromInput(input.input);
const context: PreviewAutomationRequestErrorContext = {
operation: input.operation,
Expand All @@ -480,7 +494,7 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() {
const pending = new Map(current.pending);
pending.set(requestId, { queue: connection.queue, deferred, context });
return [
{ connection, requestId, requestContext: context },
{ connection, requestId, requestContext: context, requestSequence },
{ ...current, assignments, pending, requestSequence: current.requestSequence + 1 },
] as const;
});
Expand All @@ -493,7 +507,7 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() {
providerInstanceId: input.scope.providerInstanceId,
});
}
const { connection, requestId, requestContext } = route;
const { connection, requestId, requestContext, requestSequence } = route;
const removePending = SynchronizedRef.update(state, (next) => {
if (!next.pending.has(requestId)) return next;
const pending = new Map(next.pending);
Expand All @@ -508,6 +522,7 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() {
requestId,
threadId: input.scope.threadId,
tabId: requestContext.tabId,
tabIdExplicit: input.tabId !== undefined,
operation: input.operation,
input: input.input,
timeoutMs,
Expand All @@ -526,7 +541,36 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() {
onSome: (value) => Effect.succeed(value as A),
});
});
return yield* awaitResponse().pipe(Effect.ensuring(removePending));
const result = yield* awaitResponse().pipe(Effect.ensuring(removePending));
const responseTabId = readResultTabId(result);
const resultTabId = responseTabId === undefined ? input.tabId : responseTabId;
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
if (resultTabId !== undefined) {
const assignmentKey = hostAssignmentKey(input.scope);
yield* SynchronizedRef.update(state, (current) => {
const assignment = current.assignments.get(assignmentKey);
if (
!assignment ||
assignment.connectionId !== connection.connectionId ||
assignment.queue !== connection.queue ||
(assignment.tabSequence ?? -1) > requestSequence
) {
return current;
}
const assignments = new Map(current.assignments);
if (resultTabId === null) {
const { tabId: _tabId, ...withoutTabId } = assignment;
assignments.set(assignmentKey, { ...withoutTabId, tabSequence: requestSequence });
} else {
assignments.set(assignmentKey, {
...assignment,
tabId: resultTabId,
tabSequence: requestSequence,
});
}
return { ...current, assignments };
});
}
return result;
});

return PreviewAutomationBroker.of({ connect, focusHost, respond, invoke });
Expand Down
Loading
Loading