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 }])(
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
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
179231it.effect.each([
180232 { mode: "default", input: {}, images: true },
181233 { 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";
1313import type * as Types from "effect/Types";
1414import { McpProtocol, McpSchema, McpServer, Tool } from "effect/unstable/ai";
1515import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http";
16import { PreviewAutomationError } from "@t3tools/contracts";
1617
1718import packageJson from "../../package.json" with { type: "json" };
1819import * as ServerConfig from "../config.ts";
......@@ -328,6 +329,8 @@ const saveScreenshot = Effect.fn("McpHttpServer.saveScreenshot")(function* (
328329 return screenshotPath;
329330});
330331
332const isPreviewAutomationError = Schema.is(PreviewAutomationError);
333
331334const 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,6 +344,9 @@ 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: {
......@@ -348,10 +354,11 @@ const previewSnapshotFailure = <E>(cause: Cause.Cause<E>) => {
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+2-1
......@@ -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+6-7
......@@ -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. */
652653export 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