authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-06-20 11:20:14-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-06-20 11:20:14-07:00
log1e5f62801b7c4d247247bc3648310a20560e5b43
treea87bc85d72ad9f8943dd9f0bc4fe4b7fa9c730b0
parented6ba7439d55b8c2cf7324d5107c2c1cb3ee0920
signature Signed by PGP key B5690EEEBB952194

[codex] Structure MCP snapshot failures (#3423)

Co-authored-by: codex <codex@users.noreply.github.com>

2 files changed, 103 insertions(+), 18 deletions(-)

apps/server/src/mcp/McpHttpServer.test.ts+56
......@@ -49,6 +49,62 @@ it("normalizes empty successful notification responses to accepted", () => {
4949 expect(resultResponse.status).toBe(200);
5050});
5151
52it.effect("returns bounded structural preview snapshot failures", () =>
53 Effect.scoped(
54 Effect.gen(function* () {
55 const server = yield* McpServer.McpServer;
56 const broker = yield* PreviewAutomationBroker.PreviewAutomationBroker;
57 const requests = yield* broker.connect({
58 clientId: "mcp-failure-client",
59 environmentId,
60 threadId,
61 tabId,
62 visible: true,
63 supportsAutomation: true,
64 focusedAt: "2026-06-11T00:00:00.000Z",
65 });
66 yield* Stream.runForEach(requests, (request) =>
67 broker.respond({
68 requestId: request.requestId,
69 ok: false,
70 error: {
71 _tag: "PreviewAutomationExecutionError",
72 message: "sensitive renderer failure",
73 detail: { consoleOutput: "sensitive browser output" },
74 },
75 }),
76 ).pipe(Effect.forkScoped);
77 yield* Effect.yieldNow;
78 yield* broker.reportOwner({
79 clientId: "mcp-failure-client",
80 environmentId,
81 threadId,
82 tabId,
83 visible: true,
84 supportsAutomation: true,
85 focusedAt: "2026-06-11T00:00:00.000Z",
86 });
87
88 const snapshot = yield* server
89 .callTool({ name: "preview_snapshot", arguments: {} })
90 .pipe(
91 Effect.provideService(McpInvocationContext.McpInvocationContext, invocation),
92 Effect.provideService(McpSchema.McpServerClient, client),
93 );
94
95 expect(snapshot.isError).toBe(true);
96 expect(snapshot.content).toEqual([{ type: "text", text: "Preview snapshot failed." }]);
97 expect(snapshot.structuredContent).toEqual({
98 error: {
99 _tag: "PreviewAutomationExecutionError",
100 operation: "snapshot",
101 failureCount: 1,
102 },
103 });
104 }),
105 ).pipe(Effect.provide(TestLayer)),
106);
107
52108it.effect("terminates HTTP MCP sessions with DELETE", () =>
53109 Effect.scoped(
54110 Effect.gen(function* () {
apps/server/src/mcp/McpHttpServer.ts+47-18
......@@ -88,6 +88,37 @@ const McpAuthMiddlewareLive = HttpRouter.middleware<{
8888 provides: McpInvocationContext.McpInvocationContext;
8989}>()(makeMcpAuthMiddleware).layer;
9090
91const previewSnapshotFailure = <E>(cause: Cause.Cause<E>) => {
92 if (Cause.hasInterrupts(cause) || cause.reasons.some(Cause.isDieReason)) {
93 return Effect.failCause(cause).pipe(Effect.orDie);
94 }
95 const failures = cause.reasons.filter(Cause.isFailReason);
96 const firstFailure = failures[0]?.error;
97 const errorTag =
98 typeof firstFailure === "object" &&
99 firstFailure !== null &&
100 "_tag" in firstFailure &&
101 typeof firstFailure._tag === "string"
102 ? firstFailure._tag
103 : "PreviewSnapshotError";
104 const result = new McpSchema.CallToolResult({
105 isError: true,
106 structuredContent: {
107 error: {
108 _tag: errorTag,
109 operation: "snapshot",
110 failureCount: failures.length,
111 },
112 },
113 content: [{ type: "text", text: "Preview snapshot failed." }],
114 });
115 return Effect.logWarning("preview snapshot failed", {
116 operation: "snapshot",
117 errorTag,
118 failureCount: failures.length,
119 }).pipe(Effect.as(result));
120};
121
91122const registerPreviewSnapshot = Effect.fn("McpHttpServer.registerPreviewSnapshot")(function* () {
92123 const server = yield* McpServer.McpServer;
93124 const broker = yield* PreviewAutomationBroker.PreviewAutomationBroker;
......@@ -122,12 +153,8 @@ const registerPreviewSnapshot = Effect.fn("McpHttpServer.registerPreviewSnapshot
122153 Effect.flatMap(Effect.fromOption),
123154 Effect.provideService(PreviewAutomationBroker.PreviewAutomationBroker, broker),
124155 Effect.provideService(McpInvocationContext.McpInvocationContext, invocation),
125 Effect.matchCause({
126 onFailure: (cause) =>
127 new McpSchema.CallToolResult({
128 isError: true,
129 content: [{ type: "text", text: Cause.pretty(cause) }],
130 }),
156 Effect.matchCauseEffect({
157 onFailure: previewSnapshotFailure,
131158 onSuccess: ({ encodedResult }) => {
132159 const snapshot = encodedResult as {
133160 readonly screenshot: {
......@@ -147,18 +174,20 @@ const registerPreviewSnapshot = Effect.fn("McpHttpServer.registerPreviewSnapshot
147174 height: screenshot.height,
148175 },
149176 };
150 return new McpSchema.CallToolResult({
151 isError: false,
152 structuredContent: metadata,
153 content: [
154 { type: "text", text: JSON.stringify(metadata) },
155 {
156 type: "image",
157 data: new Uint8Array(Buffer.from(screenshot.data, "base64")),
158 mimeType: screenshot.mimeType,
159 },
160 ],
161 });
177 return Effect.succeed(
178 new McpSchema.CallToolResult({
179 isError: false,
180 structuredContent: metadata,
181 content: [
182 { type: "text", text: JSON.stringify(metadata) },
183 {
184 type: "image",
185 data: new Uint8Array(Buffer.from(screenshot.data, "base64")),
186 mimeType: screenshot.mimeType,
187 },
188 ],
189 }),
190 );
162191 },
163192 }),
164193 );