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", () => {...@@ -49,6 +49,62 @@ it("normalizes empty successful notification responses to accepted", () => {
49 expect(resultResponse.status).toBe(200);49 expect(resultResponse.status).toBe(200);
50});50});
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
52it.effect("terminates HTTP MCP sessions with DELETE", () =>108it.effect("terminates HTTP MCP sessions with DELETE", () =>
53 Effect.scoped(109 Effect.scoped(
54 Effect.gen(function* () {110 Effect.gen(function* () {
apps/server/src/mcp/McpHttpServer.ts+47-18
...@@ -88,6 +88,37 @@ const McpAuthMiddlewareLive = HttpRouter.middleware<{...@@ -88,6 +88,37 @@ const McpAuthMiddlewareLive = HttpRouter.middleware<{
88 provides: McpInvocationContext.McpInvocationContext;88 provides: McpInvocationContext.McpInvocationContext;
89}>()(makeMcpAuthMiddleware).layer;89}>()(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
91const registerPreviewSnapshot = Effect.fn("McpHttpServer.registerPreviewSnapshot")(function* () {122const registerPreviewSnapshot = Effect.fn("McpHttpServer.registerPreviewSnapshot")(function* () {
92 const server = yield* McpServer.McpServer;123 const server = yield* McpServer.McpServer;
93 const broker = yield* PreviewAutomationBroker.PreviewAutomationBroker;124 const broker = yield* PreviewAutomationBroker.PreviewAutomationBroker;
...@@ -122,12 +153,8 @@ const registerPreviewSnapshot = Effect.fn("McpHttpServer.registerPreviewSnapshot...@@ -122,12 +153,8 @@ const registerPreviewSnapshot = Effect.fn("McpHttpServer.registerPreviewSnapshot
122 Effect.flatMap(Effect.fromOption),153 Effect.flatMap(Effect.fromOption),
123 Effect.provideService(PreviewAutomationBroker.PreviewAutomationBroker, broker),154 Effect.provideService(PreviewAutomationBroker.PreviewAutomationBroker, broker),
124 Effect.provideService(McpInvocationContext.McpInvocationContext, invocation),155 Effect.provideService(McpInvocationContext.McpInvocationContext, invocation),
125 Effect.matchCause({156 Effect.matchCauseEffect({
126 onFailure: (cause) =>157 onFailure: previewSnapshotFailure,
127 new McpSchema.CallToolResult({
128 isError: true,
129 content: [{ type: "text", text: Cause.pretty(cause) }],
130 }),
131 onSuccess: ({ encodedResult }) => {158 onSuccess: ({ encodedResult }) => {
132 const snapshot = encodedResult as {159 const snapshot = encodedResult as {
133 readonly screenshot: {160 readonly screenshot: {
...@@ -147,18 +174,20 @@ const registerPreviewSnapshot = Effect.fn("McpHttpServer.registerPreviewSnapshot...@@ -147,18 +174,20 @@ const registerPreviewSnapshot = Effect.fn("McpHttpServer.registerPreviewSnapshot
147 height: screenshot.height,174 height: screenshot.height,
148 },175 },
149 };176 };
150 return new McpSchema.CallToolResult({177 return Effect.succeed(
151 isError: false,178 new McpSchema.CallToolResult({
152 structuredContent: metadata,179 isError: false,
153 content: [180 structuredContent: metadata,
154 { type: "text", text: JSON.stringify(metadata) },181 content: [
155 {182 { type: "text", text: JSON.stringify(metadata) },
156 type: "image",183 {
157 data: new Uint8Array(Buffer.from(screenshot.data, "base64")),184 type: "image",
158 mimeType: screenshot.mimeType,185 data: new Uint8Array(Buffer.from(screenshot.data, "base64")),
159 },186 mimeType: screenshot.mimeType,
160 ],187 },
161 });188 ],
189 }),
190 );
162 },191 },
163 }),192 }),
164 );193 );