authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-09-24 22:05:20-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-24 22:05:20-07:00
log1e192b25583dd682324acd52f396be3d2ec9ee23
treec895e16ac23b8c541f25d6cf91f7d5fad4ed9c3a
parent99641fd09a509feb644b3c8ef718c12c60cf436c
signature Signed by PGP key B5690EEEBB952194

fix(mcp): preview errors tell agents what to do instead (#13559)

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

4 files changed, 70 insertions(+), 11 deletions(-)

apps/server/src/mcp/McpHttpServer.test.ts+53-1
...@@ -161,21 +161,73 @@ it.effect.each([{}, { includeImage: false }])(...@@ -161,21 +161,73 @@ it.effect.each([{}, { includeImage: false }])(
161 Effect.provideService(McpSchema.McpServerClient, client),161 Effect.provideService(McpSchema.McpServerClient, client),
162 );162 );
163163
164 const message = "Preview automation snapshot failed on client mcp-failure-client.";
164 expect(snapshot.isError).toBe(true);165 expect(snapshot.isError).toBe(true);
165 expect(snapshot.content).toEqual([166 expect(snapshot.content).toEqual([
166 { type: "text", text: "Preview snapshot failed: PreviewAutomationExecutionError." },167 { type: "text", text: `Preview snapshot failed: ${message}` },
167 ]);168 ]);
168 expect(snapshot.structuredContent).toEqual({169 expect(snapshot.structuredContent).toEqual({
169 error: {170 error: {
170 _tag: "PreviewAutomationExecutionError",171 _tag: "PreviewAutomationExecutionError",
171 operation: "snapshot",172 operation: "snapshot",
172 failureCount: 1,173 failureCount: 1,
174 message,
173 },175 },
174 });176 });
175 }),177 }),
176 ).pipe(Effect.provide(TestLayer)),178 ).pipe(Effect.provide(TestLayer)),
177);179);
178180
181it.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
216it.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
179it.effect.each([231it.effect.each([
180 { mode: "default", input: {}, images: true },232 { mode: "default", input: {}, images: true },
181 { mode: "explicit image", input: { includeImage: true }, images: true },233 { mode: "explicit image", input: { includeImage: true }, images: true },
apps/server/src/mcp/McpHttpServer.ts+9-2
...@@ -13,6 +13,7 @@ import * as Stream from "effect/Stream";...@@ -13,6 +13,7 @@ import * as Stream from "effect/Stream";
13import type * as Types from "effect/Types";13import type * as Types from "effect/Types";
14import { McpProtocol, McpSchema, McpServer, Tool } from "effect/unstable/ai";14import { McpProtocol, McpSchema, McpServer, Tool } from "effect/unstable/ai";
15import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http";15import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http";
16import { PreviewAutomationError } from "@t3tools/contracts";
1617
17import packageJson from "../../package.json" with { type: "json" };18import packageJson from "../../package.json" with { type: "json" };
18import * as ServerConfig from "../config.ts";19import * as ServerConfig from "../config.ts";
...@@ -328,6 +329,8 @@ const saveScreenshot = Effect.fn("McpHttpServer.saveScreenshot")(function* (...@@ -328,6 +329,8 @@ const saveScreenshot = Effect.fn("McpHttpServer.saveScreenshot")(function* (
328 return screenshotPath;329 return screenshotPath;
329});330});
330331
332const isPreviewAutomationError = Schema.is(PreviewAutomationError);
333
331const previewSnapshotFailure = <E>(cause: Cause.Cause<E>) => {334const previewSnapshotFailure = <E>(cause: Cause.Cause<E>) => {
332 if (Cause.hasInterrupts(cause) || cause.reasons.some(Cause.isDieReason)) {335 if (Cause.hasInterrupts(cause) || cause.reasons.some(Cause.isDieReason)) {
333 return Effect.failCause(cause).pipe(Effect.orDie);336 return Effect.failCause(cause).pipe(Effect.orDie);
...@@ -341,6 +344,9 @@ const previewSnapshotFailure = <E>(cause: Cause.Cause<E>) => {...@@ -341,6 +344,9 @@ const previewSnapshotFailure = <E>(cause: Cause.Cause<E>) => {
341 typeof firstFailure._tag === "string"344 typeof firstFailure._tag === "string"
342 ? firstFailure._tag345 ? firstFailure._tag
343 : "PreviewSnapshotError";346 : "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;
344 const result = new McpSchema.CallToolResult({350 const result = new McpSchema.CallToolResult({
345 isError: true,351 isError: true,
346 structuredContent: {352 structuredContent: {
...@@ -348,10 +354,11 @@ const previewSnapshotFailure = <E>(cause: Cause.Cause<E>) => {...@@ -348,10 +354,11 @@ const previewSnapshotFailure = <E>(cause: Cause.Cause<E>) => {
348 _tag: errorTag,354 _tag: errorTag,
349 operation: "snapshot",355 operation: "snapshot",
350 failureCount: failures.length,356 failureCount: failures.length,
357 ...(message === undefined ? {} : { message }),
351 },358 },
352 },359 },
353 // Agents usually see only the text content, so name the tag there too.360 // Some clients show only the text content and others only structuredContent, so both carry it.
354 content: [{ type: "text", text: `Preview snapshot failed: ${errorTag}.` }],361 content: [{ type: "text", text: `Preview snapshot failed: ${message ?? `${errorTag}.`}` }],
355 });362 });
356 return Effect.logWarning("preview snapshot failed", {363 return Effect.logWarning("preview snapshot failed", {
357 operation: "snapshot",364 operation: "snapshot",
apps/server/src/mcp/McpInvocationContext.test.ts+2-1
...@@ -34,7 +34,8 @@ it.effect("reports the scoped credential context when preview capability is unav...@@ -34,7 +34,8 @@ it.effect("reports the scoped credential context when preview capability is unav
34 providerSessionId: invocation.providerSessionId,34 providerSessionId: invocation.providerSessionId,
35 providerInstanceId: invocation.providerInstanceId,35 providerInstanceId: invocation.providerInstanceId,
36 });36 });
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");
38 });39 });
39});40});
4041
packages/contracts/src/previewAutomation.ts+6-7
...@@ -649,6 +649,7 @@ const McpCapabilityErrorFields = {...@@ -649,6 +649,7 @@ const McpCapabilityErrorFields = {
649 providerInstanceId: ProviderInstanceId,649 providerInstanceId: ProviderInstanceId,
650};650};
651651
652/** Agents read this message, so it names the next step and not only the failure. */
652export class PreviewAutomationUnavailableError extends Schema.TaggedError<PreviewAutomationUnavailableError>()(653export class PreviewAutomationUnavailableError extends Schema.TaggedError<PreviewAutomationUnavailableError>()(
653 "PreviewAutomationUnavailableError",654 "PreviewAutomationUnavailableError",
654 {655 {
...@@ -657,7 +658,7 @@ export class PreviewAutomationUnavailableError extends Schema.TaggedError<Previe...@@ -657,7 +658,7 @@ export class PreviewAutomationUnavailableError extends Schema.TaggedError<Previe
657 },658 },
658) {659) {
659 override get message(): string {660 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.`;
661 }662 }
662}663}
663664
...@@ -722,8 +723,7 @@ export class PreviewAutomationNoAvailableHostError extends Schema.TaggedError<Pr...@@ -722,8 +723,7 @@ export class PreviewAutomationNoAvailableHostError extends Schema.TaggedError<Pr
722 },723 },
723) {724) {
724 override get message(): string {725 override get message(): string {
725 const summary = `No preview automation host is available for ${this.operation} in environment ${this.environmentId}.`;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.`;
726 return summary;
727 }727 }
728}728}
729729
...@@ -747,10 +747,9 @@ export class PreviewAutomationTabNotFoundError extends Schema.TaggedError<Previe...@@ -747,10 +747,9 @@ export class PreviewAutomationTabNotFoundError extends Schema.TaggedError<Previe
747 },747 },
748) {748) {
749 override get message(): string {749 override get message(): string {
750 const summary = this.tabId750 return this.tabId
751 ? `Preview tab ${this.tabId} was not found for ${this.operation}.`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}.`;752 : `No active preview tab was found for ${this.operation}. Call preview_open first.`;
753 return summary;
754 }753 }
755}754}
756755