Skip to content

Commit 1e192b2

Browse files
t3dotggclaude
andauthored
fix(mcp): preview errors tell agents what to do instead (pingdotgg#13559)
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1 parent 99641fd commit 1e192b2

4 files changed

Lines changed: 70 additions & 11 deletions

File tree

‎apps/server/src/mcp/McpHttpServer.test.ts‎

Lines changed: 53 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -161,21 +161,73 @@ it.effect.each([{}, { includeImage: false }])(
161161
Effect.provideService(McpSchema.McpServerClient, client),
162162
);
163163

164+
const message = "Preview automation snapshot failed on client mcp-failure-client.";
164165
expect(snapshot.isError).toBe(true);
165166
expect(snapshot.content).toEqual([
166-
{ type: "text", text: "Preview snapshot failed: PreviewAutomationExecutionError." },
167+
{ type: "text", text: `Preview snapshot failed: ${message}` },
167168
]);
168169
expect(snapshot.structuredContent).toEqual({
169170
error: {
170171
_tag: "PreviewAutomationExecutionError",
171172
operation: "snapshot",
172173
failureCount: 1,
174+
message,
173175
},
174176
});
175177
}),
176178
).pipe(Effect.provide(TestLayer)),
177179
);
178180

181+
it.effect.each([
182+
{ args: {}, advice: "No active preview tab was found for snapshot. Call preview_open first." },
183+
{
184+
args: { tabId: alternateTabId },
185+
advice: `Preview tab ${alternateTabId} was not found for snapshot. Omit tabId to use the current tab, or call preview_open.`,
186+
},
187+
])("tells the agent to open a tab when the snapshot has none $args", ({ args, advice }) =>
188+
Effect.scoped(
189+
Effect.gen(function* () {
190+
const broker = yield* PreviewAutomationBroker.PreviewAutomationBroker;
191+
const connected = yield* Deferred.make<void>();
192+
const events = yield* broker.connect({ clientId: "mcp-no-tab-client", environmentId });
193+
yield* Stream.runForEach(events, (event) =>
194+
event.type === "connected"
195+
? Deferred.succeed(connected, undefined)
196+
: broker.respond({
197+
clientId: "mcp-no-tab-client",
198+
connectionId: event.connectionId,
199+
requestId: event.request.requestId,
200+
ok: false,
201+
error: { _tag: "PreviewAutomationTabNotFoundError", message: "no tab" },
202+
}),
203+
).pipe(Effect.forkScoped);
204+
yield* Deferred.await(connected);
205+
206+
const snapshot = yield* callSnapshot(args);
207+
208+
expect(snapshot.isError).toBe(true);
209+
expect(snapshot.content).toEqual([
210+
{ type: "text", text: `Preview snapshot failed: ${advice}` },
211+
]);
212+
}),
213+
).pipe(Effect.provide(TestLayer)),
214+
);
215+
216+
it.effect("tells the agent how to fall back when no desktop app can run the snapshot", () =>
217+
Effect.gen(function* () {
218+
const snapshot = yield* callSnapshot({});
219+
220+
expect(snapshot.isError).toBe(true);
221+
const [text] = snapshot.content;
222+
expect(text?.type === "text" ? text.text : "").toContain(
223+
"use a headless browser from the shell",
224+
);
225+
expect(snapshot.structuredContent).toMatchObject({
226+
error: { _tag: "PreviewAutomationNoAvailableHostError" },
227+
});
228+
}).pipe(Effect.provide(TestLayer)),
229+
);
230+
179231
it.effect.each([
180232
{ mode: "default", input: {}, images: true },
181233
{ mode: "explicit image", input: { includeImage: true }, images: true },

‎apps/server/src/mcp/McpHttpServer.ts‎

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import * as Stream from "effect/Stream";
1313
import type * as Types from "effect/Types";
1414
import { McpProtocol, McpSchema, McpServer, Tool } from "effect/unstable/ai";
1515
import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http";
16+
import { PreviewAutomationError } from "@t3tools/contracts";
1617

1718
import packageJson from "../../package.json" with { type: "json" };
1819
import * as ServerConfig from "../config.ts";
@@ -328,6 +329,8 @@ const saveScreenshot = Effect.fn("McpHttpServer.saveScreenshot")(function* (
328329
return screenshotPath;
329330
});
330331

332+
const isPreviewAutomationError = Schema.is(PreviewAutomationError);
333+
331334
const previewSnapshotFailure = <E>(cause: Cause.Cause<E>) => {
332335
if (Cause.hasInterrupts(cause) || cause.reasons.some(Cause.isDieReason)) {
333336
return Effect.failCause(cause).pipe(Effect.orDie);
@@ -341,17 +344,21 @@ const previewSnapshotFailure = <E>(cause: Cause.Cause<E>) => {
341344
typeof firstFailure._tag === "string"
342345
? firstFailure._tag
343346
: "PreviewSnapshotError";
347+
// Preview errors build their message on the server, never from page output,
348+
// and it tells the agent what to do next, such as falling back to a shell browser.
349+
const message = isPreviewAutomationError(firstFailure) ? firstFailure.message : undefined;
344350
const result = new McpSchema.CallToolResult({
345351
isError: true,
346352
structuredContent: {
347353
error: {
348354
_tag: errorTag,
349355
operation: "snapshot",
350356
failureCount: failures.length,
357+
...(message === undefined ? {} : { message }),
351358
},
352359
},
353-
// Agents usually see only the text content, so name the tag there too.
354-
content: [{ type: "text", text: `Preview snapshot failed: ${errorTag}.` }],
360+
// Some clients show only the text content and others only structuredContent, so both carry it.
361+
content: [{ type: "text", text: `Preview snapshot failed: ${message ?? `${errorTag}.`}` }],
355362
});
356363
return Effect.logWarning("preview snapshot failed", {
357364
operation: "snapshot",

‎apps/server/src/mcp/McpInvocationContext.test.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,8 @@ it.effect("reports the scoped credential context when preview capability is unav
3434
providerSessionId: invocation.providerSessionId,
3535
providerInstanceId: invocation.providerInstanceId,
3636
});
37-
expect(error.message).toBe("MCP credential does not grant the preview capability.");
37+
expect(error.message).toContain("MCP credential does not grant the preview capability");
38+
expect(error.message).toContain("use a headless browser from the shell");
3839
});
3940
});
4041

‎packages/contracts/src/previewAutomation.ts‎

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -649,6 +649,7 @@ const McpCapabilityErrorFields = {
649649
providerInstanceId: ProviderInstanceId,
650650
};
651651

652+
/** Agents read this message, so it names the next step and not only the failure. */
652653
export class PreviewAutomationUnavailableError extends Schema.TaggedError<PreviewAutomationUnavailableError>()(
653654
"PreviewAutomationUnavailableError",
654655
{
@@ -657,7 +658,7 @@ export class PreviewAutomationUnavailableError extends Schema.TaggedError<Previe
657658
},
658659
) {
659660
override get message(): string {
660-
return `MCP credential does not grant the ${this.capability} capability.`;
661+
return `MCP credential does not grant the ${this.capability} capability: browser preview tools are off for this thread. Do not retry them. To check a page, use a headless browser from the shell, such as Playwright, or curl. The user can turn on "Agent browser access" in Settings; it applies when the agent session next starts.`;
661662
}
662663
}
663664

@@ -722,8 +723,7 @@ export class PreviewAutomationNoAvailableHostError extends Schema.TaggedError<Pr
722723
},
723724
) {
724725
override get message(): string {
725-
const summary = `No preview automation host is available for ${this.operation} in environment ${this.environmentId}.`;
726-
return summary;
726+
return `No preview automation host is available for ${this.operation} in environment ${this.environmentId}. Preview tools run in a T3 Code desktop app that is open and connected to this environment; a headless server has no browser of its own. Do not retry. To check a page, use a headless browser from the shell, such as Playwright, or curl, or ask the user to open this thread in the T3 Code desktop app.`;
727727
}
728728
}
729729

@@ -747,10 +747,9 @@ export class PreviewAutomationTabNotFoundError extends Schema.TaggedError<Previe
747747
},
748748
) {
749749
override get message(): string {
750-
const summary = this.tabId
751-
? `Preview tab ${this.tabId} was not found for ${this.operation}.`
752-
: `No active preview tab was found for ${this.operation}.`;
753-
return summary;
750+
return this.tabId
751+
? `Preview tab ${this.tabId} was not found for ${this.operation}. Omit tabId to use the current tab, or call preview_open.`
752+
: `No active preview tab was found for ${this.operation}. Call preview_open first.`;
754753
}
755754
}
756755

0 commit comments

Comments
 (0)