History

Merge remote-tracking branch 'origin/codex/batch17-cloud-mcp-boundaries' into codex/effect-lint-integration

Rhys Sullivan committed 6 months ago

Commit 3a856292 parents cd10e67 fcae4133 files · +158 −90Browse files

Unified · Split

3 files
  1. apps/cloud/src/mcp-session.ts +27 −13
  2. apps/cloud/src/mcp.ts +28 −24
  3. apps/cloud/src/mcp/response-peek.ts +103 −53
apps/cloud/src/mcp-session.ts+27−13
@@ -145,6 +145,7 @@ const makeDbHandle = (options: { · 144 unmodified lines
145 return {145 return {
146 sql,146 sql,
147 db: drizzle(sql, { schema: combinedSchema }) as DrizzleDb,147 db: drizzle(sql, { schema: combinedSchema }) as DrizzleDb,
148 // oxlint-disable-next-line executor/no-promise-catch -- boundary: postgres.js close is best-effort during DO/runtime cleanup
148 end: () => sql.end({ timeout: 0 }).catch(() => undefined),149 end: () => sql.end({ timeout: 0 }).catch(() => undefined),
149 };150 };
150};151};
@@ -268,9 +269,13 @@ export class McpSessionDO extends DurableObject { · 117 unmodified lines
268 this.transportJsonResponseMode = null;269 this.transportJsonResponseMode = null;
269 270
270 await Promise.all([271 await Promise.all([
272 // oxlint-disable-next-line executor/no-promise-catch -- boundary: Durable Object storage cleanup is best-effort after session invalidation
271 this.ctx.storage.delete(TRANSPORT_STATE_KEY).catch(() => false),273 this.ctx.storage.delete(TRANSPORT_STATE_KEY).catch(() => false),
274 // oxlint-disable-next-line executor/no-promise-catch -- boundary: Durable Object storage cleanup is best-effort after session invalidation
272 this.ctx.storage.delete(SESSION_META_KEY).catch(() => false),275 this.ctx.storage.delete(SESSION_META_KEY).catch(() => false),
276 // oxlint-disable-next-line executor/no-promise-catch -- boundary: Durable Object storage cleanup is best-effort after session invalidation
273 this.ctx.storage.delete(LAST_ACTIVITY_KEY).catch(() => false),277 this.ctx.storage.delete(LAST_ACTIVITY_KEY).catch(() => false),
278 // oxlint-disable-next-line executor/no-promise-catch -- boundary: Durable Object alarm cleanup is best-effort after session invalidation
274 this.ctx.storage.deleteAlarm().catch(() => undefined),279 this.ctx.storage.deleteAlarm().catch(() => undefined),
275 ]);280 ]);
276 }).pipe(Effect.withSpan("mcp.session.clear_state"));281 }).pipe(Effect.withSpan("mcp.session.clear_state"));
@@ -322,6 +327,7 @@ export class McpSessionDO extends DurableObject { · 45 unmodified lines
322 }327 }
323 if (self.mcpServer) {328 if (self.mcpServer) {
324 const mcpServer = self.mcpServer;329 const mcpServer = self.mcpServer;
330 // oxlint-disable-next-line executor/no-promise-catch -- boundary: MCP SDK close failure is ignored during best-effort runtime teardown
325 yield* Effect.promise(() => mcpServer.close().catch(() => undefined));331 yield* Effect.promise(() => mcpServer.close().catch(() => undefined));
326 self.mcpServer = null;332 self.mcpServer = null;
327 }333 }
@@ -332,7 +338,10 @@ export class McpSessionDO extends DurableObject { · 4 unmodified lines
332 }338 }
333 self.initialized = false;339 self.initialized = false;
334 self.transportJsonResponseMode = null;340 self.transportJsonResponseMode = null;
335 }).pipe(Effect.orDie);341 }).pipe(
342 // oxlint-disable-next-line executor/no-effect-escape-hatch -- boundary: DO cleanup has no typed failure surface
343 Effect.orDie,
344 );
336 }345 }
337 346
338 private installRuntime(347 private installRuntime(
@@ -388,6 +397,7 @@ export class McpSessionDO extends DurableObject { · 49 unmodified lines
388 "mcp.request.session_id_present": !!request.headers.get("mcp-session-id"),397 "mcp.request.session_id_present": !!request.headers.get("mcp-session-id"),
389 },398 },
390 }),399 }),
400 // oxlint-disable-next-line executor/no-effect-escape-hatch -- boundary: cold DO restore is re-entered from Promise-only Durable Object method
391 Effect.orDie,401 Effect.orDie,
392 );402 );
393 }403 }
@@ -409,7 +419,11 @@ export class McpSessionDO extends DurableObject { · 15 unmodified lines
409 yield* Effect.annotateCurrentSpan({419 yield* Effect.annotateCurrentSpan({
410 "mcp.session.transport_upgraded_json_response": true,420 "mcp.session.transport_upgraded_json_response": true,
411 });421 });
412 }).pipe(Effect.withSpan("McpSessionDO.ensureJsonResponseTransportForPost"), Effect.orDie);422 }).pipe(
423 Effect.withSpan("McpSessionDO.ensureJsonResponseTransportForPost"),
424 // oxlint-disable-next-line executor/no-effect-escape-hatch -- boundary: transport rebuild is internal DO runtime state
425 Effect.orDie,
426 );
413 }427 }
414 428
415 private validateSessionOwner(request: Request): Effect.Effect<Response | null> {429 private validateSessionOwner(request: Request): Effect.Effect<Response | null> {
@@ -435,17 +449,15 @@ export class McpSessionDO extends DurableObject { · 19 unmodified lines
435 const self = this;449 const self = this;
436 return Effect.gen(function* () {450 return Effect.gen(function* () {
437 const dbHandle = makeEphemeralDb();451 const dbHandle = makeEphemeralDb();
438 try {452 return yield* resolveSessionMeta(token.organizationId, token.userId).pipe(
439 const sessionMeta = yield* resolveSessionMeta(token.organizationId, token.userId).pipe(453 Effect.provide(makeResolveOrganizationServices(dbHandle)),
440 Effect.provide(makeResolveOrganizationServices(dbHandle)),454 Effect.tap((sessionMeta) =>
441 );455 Effect.promise(() => self.saveSessionMeta(sessionMeta)).pipe(
442 yield* Effect.promise(() => self.saveSessionMeta(sessionMeta)).pipe(456 Effect.withSpan("mcp.session.save_meta"),
443 Effect.withSpan("mcp.session.save_meta"),457 ),
444 );458 ),
445 return sessionMeta;459 Effect.ensuring(Effect.promise(() => dbHandle.end())),
446 } finally {460 );
447 yield* Effect.promise(() => dbHandle.end());
448 }
449 }).pipe(Effect.withSpan("mcp.session.resolve_and_store_meta"));461 }).pipe(Effect.withSpan("mcp.session.resolve_and_store_meta"));
450 }462 }
451 463
@@ -463,6 +475,7 @@ export class McpSessionDO extends DurableObject { · 11 unmodified lines
463 }),475 }),
464 (eff) => withIncomingParent(incoming, eff),476 (eff) => withIncomingParent(incoming, eff),
465 Effect.provide(DoTelemetryLive),477 Effect.provide(DoTelemetryLive),
478 // oxlint-disable-next-line executor/no-effect-escape-hatch -- boundary: Durable Object init method can only reject its Promise
466 Effect.orDie,479 Effect.orDie,
467 ),480 ),
468 );481 );
@@ -511,6 +524,7 @@ export class McpSessionDO extends DurableObject { · 42 unmodified lines
511 return yield* Effect.failCause(cause);524 return yield* Effect.failCause(cause);
512 }),525 }),
513 ),526 ),
527 // oxlint-disable-next-line executor/no-effect-escape-hatch -- boundary: doInit is called only from Promise-only Durable Object init
514 Effect.orDie,528 Effect.orDie,
515 );529 );
516 }530 }
apps/cloud/src/mcp.ts+28−24
@@ -16,7 +16,7 @@ · 15 unmodified lines
16 16
17import { env } from "cloudflare:workers";17import { env } from "cloudflare:workers";
18import { HttpEffect, HttpServerRequest, HttpServerResponse } from "effect/unstable/http";18import { HttpEffect, HttpServerRequest, HttpServerResponse } from "effect/unstable/http";
19import { Cause, Context, Effect, Layer, Option, Schema } from "effect";19import { Cause, Context, Effect, Layer, Option, Predicate, Result, Schema } from "effect";
20 20
21import { createCachedRemoteJWKSet } from "./jwks-cache";21import { createCachedRemoteJWKSet } from "./jwks-cache";
22import { captureCause } from "./observability";22import { captureCause } from "./observability";
@@ -171,7 +171,7 @@ export const McpAuthLive = Layer.succeed(McpAuth)({ · 148 unmodified lines
171 }),171 }),
172 );172 );
173 if (!verified) return mcpUnauthorized("invalid_token", "The access token is invalid");173 if (!verified) return mcpUnauthorized("invalid_token", "The access token is invalid");
174 if ("_tag" in verified) return verified;174 if (Predicate.isTagged(verified, "Unauthorized")) return verified;
175 if (!verified.accountId) {175 if (!verified.accountId) {
176 yield* Effect.annotateCurrentSpan({ "mcp.auth.outcome": "missing_subject" });176 yield* Effect.annotateCurrentSpan({ "mcp.auth.outcome": "missing_subject" });
177 return mcpUnauthorized("invalid_token", "The access token is invalid");177 return mcpUnauthorized("invalid_token", "The access token is invalid");
@@ -211,8 +211,7 @@ type CfRequestMetadata = { · 33 unmodified lines
211const requestWithCf = (request: Request): Request & { cf?: CfRequestMetadata } =>211const requestWithCf = (request: Request): Request & { cf?: CfRequestMetadata } =>
212 request as Request & { cf?: CfRequestMetadata };212 request as Request & { cf?: CfRequestMetadata };
213 213
214const getCfMeta = (request: Request): CfRequestMetadata =>214const getCfMeta = (request: Request): CfRequestMetadata => requestWithCf(request).cf ?? {};
215 requestWithCf(request).cf ?? {};
216 215
217const HEADERS_TO_DUMP = [216const HEADERS_TO_DUMP = [
218 "accept",217 "accept",
@@ -285,22 +284,28 @@ const InitializeParams = Schema.Struct({ · 66 unmodified lines
285const NamedParams = Schema.Struct({ name: Schema.optional(Schema.String) });284const NamedParams = Schema.Struct({ name: Schema.optional(Schema.String) });
286const UriParams = Schema.Struct({ uri: Schema.optional(Schema.String) });285const UriParams = Schema.Struct({ uri: Schema.optional(Schema.String) });
287 286
288const decodeJsonRpcEnvelope = Schema.decodeUnknownOption(JsonRpcEnvelope);287const decodeJsonRpcEnvelopeString = Schema.decodeUnknownOption(
288 Schema.fromJsonString(JsonRpcEnvelope),
289);
289const decodeInitializeParams = Schema.decodeUnknownOption(InitializeParams);290const decodeInitializeParams = Schema.decodeUnknownOption(InitializeParams);
290const decodeNamedParams = Schema.decodeUnknownOption(NamedParams);291const decodeNamedParams = Schema.decodeUnknownOption(NamedParams);
291const decodeUriParams = Schema.decodeUnknownOption(UriParams);292const decodeUriParams = Schema.decodeUnknownOption(UriParams);
292const decodeElicitationReplyResult = Schema.decodeUnknownOption(ElicitationReplyResult);293const decodeElicitationReplyResult = Schema.decodeUnknownOption(ElicitationReplyResult);
293 294
295const isMcpAuthorized = (value: McpAuthResult): value is McpAuthorizedResult =>
296 Predicate.isTagged(value, "Authorized");
297const isMcpUnauthorized = (value: McpAuthResult): value is McpUnauthorizedResult =>
298 Predicate.isTagged(value, "Unauthorized");
299
294const readJsonRpcEnvelope = (request: Request): Effect.Effect<Option.Option<JsonRpcEnvelope>> =>300const readJsonRpcEnvelope = (request: Request): Effect.Effect<Option.Option<JsonRpcEnvelope>> =>
295 Effect.promise(async () => {301 Effect.tryPromise({
296 try {302 try: () => request.clone().text(),
297 const text = await request.clone().text();303 catch: () => undefined,
298 if (!text) return Option.none();304 }).pipe(
299 return decodeJsonRpcEnvelope(JSON.parse(text));305 Effect.map((text) => (text ? decodeJsonRpcEnvelopeString(text) : Option.none())),
300 } catch {306 Effect.catchCause(() => Effect.succeed(Option.none())),
301 return Option.none();307 Effect.withSpan("mcp.request.read_json_rpc"),
302 }308 );
303 }).pipe(Effect.withSpan("mcp.request.read_json_rpc"));
304 309
305const methodAttrs = (envelope: JsonRpcEnvelope): Record<string, unknown> => {310const methodAttrs = (envelope: JsonRpcEnvelope): Record<string, unknown> => {
306 const params = envelope.params ?? {};311 const params = envelope.params ?? {};
@@ -409,15 +414,14 @@ const protectedResourceMetadata = Effect.sync(() => · 102 unmodified lines
409 }),414 }),
410);415);
411 416
412const authorizationServerMetadata = Effect.promise(async () => {417const authorizationServerMetadata = Effect.tryPromise({
413 try {418 try: async () => {
414 const res = await fetch(`${AUTHKIT_DOMAIN}/.well-known/oauth-authorization-server`);419 const res = await fetch(`${AUTHKIT_DOMAIN}/.well-known/oauth-authorization-server`);
415 if (!res.ok) return jsonResponse({ error: "upstream_error" }, 502);420 if (!res.ok) return jsonResponse({ error: "upstream_error" }, 502);
416 return jsonResponse(await res.json());421 return jsonResponse(await res.json());
417 } catch {422 },
418 return jsonResponse({ error: "upstream_error" }, 502);423 catch: () => undefined,
419 }424}).pipe(Effect.catchCause(() => Effect.succeed(jsonResponse({ error: "upstream_error" }, 502))));
420});
421 425
422// ---------------------------------------------------------------------------426// ---------------------------------------------------------------------------
423// DO dispatch427// DO dispatch
@@ -545,7 +549,7 @@ const authorizeMcpOrganization = ( · 121 unmodified lines
545 Effect.catchCause((error) =>549 Effect.catchCause((error) =>
546 Effect.gen(function* () {550 Effect.gen(function* () {
547 yield* Effect.annotateCurrentSpan({551 yield* Effect.annotateCurrentSpan({
548 "mcp.auth.organization_authorize_error": String(error),552 "mcp.auth.organization_authorize_error": Cause.pretty(error),
549 });553 });
550 return false;554 return false;
551 }),555 }),
@@ -662,7 +666,7 @@ export const mcpApp: Effect.Effect< · 110 unmodified lines
662 const auth = yield* McpAuth;666 const auth = yield* McpAuth;
663 const authResult = yield* auth.verifyBearer(request).pipe(Effect.result);667 const authResult = yield* auth.verifyBearer(request).pipe(Effect.result);
664 668
665 if (authResult._tag === "Failure") {669 if (Result.isFailure(authResult)) {
666 yield* annotateMcpRequest(request, {670 yield* annotateMcpRequest(request, {
667 token: null,671 token: null,
668 parseBody: request.method === "POST",672 parseBody: request.method === "POST",
@@ -675,11 +679,11 @@ export const mcpApp: Effect.Effect< · 6 unmodified lines
675 // POST bodies are JSON-RPC payloads worth parsing; GET (SSE) and DELETE679 // POST bodies are JSON-RPC payloads worth parsing; GET (SSE) and DELETE
676 // don't carry one.680 // don't carry one.
677 yield* annotateMcpRequest(request, {681 yield* annotateMcpRequest(request, {
678 token: authValue._tag === "Authorized" ? authValue.token : null,682 token: isMcpAuthorized(authValue) ? authValue.token : null,
679 parseBody: request.method === "POST",683 parseBody: request.method === "POST",
680 });684 });
681 685
682 if (authValue._tag === "Unauthorized") {686 if (isMcpUnauthorized(authValue)) {
683 return unauthorized(authValue, PROTECTED_RESOURCE_METADATA_URL);687 return unauthorized(authValue, PROTECTED_RESOURCE_METADATA_URL);
684 }688 }
685 const token = authValue.token;689 const token = authValue.token;
apps/cloud/src/mcp/response-peek.ts+103−53
@@ -1,23 +1,57 @@
1import * as Sentry from "@sentry/cloudflare";1import * as Sentry from "@sentry/cloudflare";
2import { Effect } from "effect";2import { Cause, Data, Effect, Exit, Option, Schema } from "effect";
3 3
4import { jsonRpcWebResponse } from "./responses";4import { jsonRpcWebResponse } from "./responses";
5 5
6const SSE_PEEK_TIMEOUT_MS = 10_000;6const SSE_PEEK_TIMEOUT_MS = 10_000;
7 7
8type SandboxOutcome = {8class ResponseBodyTimeoutError extends Data.TaggedError("ResponseBodyTimeoutError")<{
9 readonly status?: string;9 readonly timeoutMs: number;
10 readonly error?: { readonly kind?: string; readonly message?: string };10}> {}
11};11
12 12class ResponseBodyReadError extends Data.TaggedError("ResponseBodyReadError") {}
13type JsonRpcResponseBody = {13
14 readonly jsonrpc?: string;14class McpInternalJsonRpcError extends Data.TaggedError("McpInternalJsonRpcError")<{
15 readonly error?: { readonly code?: number; readonly message?: string };15 readonly message: string;
16 readonly result?: {16}> {}
17 readonly isError?: boolean;17
18 readonly structuredContent?: SandboxOutcome;18const ResponseBodyTimeoutErrorData = Schema.Struct({
19 };19 _tag: Schema.Literal("ResponseBodyTimeoutError"),
20};20 timeoutMs: Schema.Number,
21});
22const decodeResponseBodyTimeoutError = Schema.decodeUnknownOption(ResponseBodyTimeoutErrorData);
23
24const SandboxOutcomeSchema = Schema.Struct({
25 status: Schema.optional(Schema.String),
26 error: Schema.optional(
27 Schema.Struct({
28 kind: Schema.optional(Schema.String),
29 message: Schema.optional(Schema.String),
30 }),
31 ),
32});
33
34const JsonRpcResponseBodySchema = Schema.Struct({
35 jsonrpc: Schema.optional(Schema.String),
36 error: Schema.optional(
37 Schema.Struct({
38 code: Schema.optional(Schema.Number),
39 message: Schema.optional(Schema.String),
40 }),
41 ),
42 result: Schema.optional(
43 Schema.Struct({
44 isError: Schema.optional(Schema.Boolean),
45 structuredContent: Schema.optional(SandboxOutcomeSchema),
46 }),
47 ),
48});
49
50const decodeJsonRpcResponseBody = Schema.decodeUnknownOption(
51 Schema.fromJsonString(JsonRpcResponseBodySchema),
52);
53
54type JsonRpcResponseBody = typeof JsonRpcResponseBodySchema.Type;
21 55
22const responseBodyShape = (body: string): string => {56const responseBodyShape = (body: string): string => {
23 const trimmed = body.trimStart();57 const trimmed = body.trimStart();
@@ -31,18 +65,18 @@ const responseBodyShape = (body: string): string => { · 7 unmodified lines
31 65
32const parseFirstJsonRpc = (contentType: string, body: string): JsonRpcResponseBody | null => {66const parseFirstJsonRpc = (contentType: string, body: string): JsonRpcResponseBody | null => {
33 if (!body) return null;67 if (!body) return null;
34 try {68 if (contentType.includes("text/event-stream")) {
35 if (contentType.includes("text/event-stream")) {69 for (const line of body.split(/\r?\n/)) {
36 for (const line of body.split(/\r?\n/)) {70 if (line.startsWith("data:")) {
37 if (line.startsWith("data:")) return JSON.parse(line.slice(5).trimStart());71 return Option.getOrNull(decodeJsonRpcResponseBody(line.slice(5).trimStart()));
38 }72 }
39 return null;
40 }73 }
41 if (contentType.includes("application/json")) return JSON.parse(body);
42 return null;
43 } catch {
44 return null;74 return null;
45 }75 }
76 if (contentType.includes("application/json")) {
77 return Option.getOrNull(decodeJsonRpcResponseBody(body));
78 }
79 return null;
46};80};
47 81
48const jsonRpcResponseAttrs = (payload: JsonRpcResponseBody | null): Record<string, unknown> => {82const jsonRpcResponseAttrs = (payload: JsonRpcResponseBody | null): Record<string, unknown> => {
@@ -52,8 +86,10 @@ const jsonRpcResponseAttrs = (payload: JsonRpcResponseBody | null): Record<strin · 3 unmodified lines
52 if (err && typeof err === "object") {86 if (err && typeof err === "object") {
53 attrs["mcp.rpc.is_error"] = true;87 attrs["mcp.rpc.is_error"] = true;
54 if (typeof err.code === "number") attrs["mcp.rpc.error.code"] = err.code;88 if (typeof err.code === "number") attrs["mcp.rpc.error.code"] = err.code;
55 if (typeof err.message === "string") {89 // oxlint-disable-next-line executor/no-unknown-error-message -- boundary: schema-decoded JSON-RPC error message is protocol telemetry
56 attrs["mcp.rpc.error.message"] = err.message.slice(0, 500);90 const { message } = err;
91 if (typeof message === "string") {
92 attrs["mcp.rpc.error.message"] = message.slice(0, 500);
57 }93 }
58 }94 }
59 if (payload.result?.isError === true) attrs["mcp.tool.result.is_error"] = true;95 if (payload.result?.isError === true) attrs["mcp.tool.result.is_error"] = true;
@@ -61,20 +97,14 @@ const jsonRpcResponseAttrs = (payload: JsonRpcResponseBody | null): Record<strin · 1 unmodified line
61 if (structured && typeof structured.status === "string") {97 if (structured && typeof structured.status === "string") {
62 attrs["mcp.tool.sandbox.status"] = structured.status;98 attrs["mcp.tool.sandbox.status"] = structured.status;
63 if (structured.error?.kind) attrs["mcp.tool.sandbox.error.kind"] = structured.error.kind;99 if (structured.error?.kind) attrs["mcp.tool.sandbox.error.kind"] = structured.error.kind;
64 if (typeof structured.error?.message === "string") {100 const message = structured.error?.["message"];
65 attrs["mcp.tool.sandbox.error.message"] = structured.error.message.slice(0, 500);101 if (typeof message === "string") {
102 attrs["mcp.tool.sandbox.error.message"] = message.slice(0, 500);
66 }103 }
67 }104 }
68 return attrs;105 return attrs;
69};106};
70 107
71class ResponseBodyTimeoutError extends Error {
72 constructor(readonly timeoutMs: number) {
73 super(`Timed out waiting for MCP response body after ${timeoutMs}ms`);
74 this.name = "ResponseBodyTimeoutError";
75 }
76}
77
78const readResponseText = async (response: Response, timeoutMs: number | null): Promise<string> => {108const readResponseText = async (response: Response, timeoutMs: number | null): Promise<string> => {
79 if (timeoutMs === null) return await response.text();109 if (timeoutMs === null) return await response.text();
80 110
@@ -85,11 +115,14 @@ const readResponseText = async (response: Response, timeoutMs: number | null): P · 4 unmodified lines
85 let timeout: ReturnType<typeof setTimeout> | undefined;115 let timeout: ReturnType<typeof setTimeout> | undefined;
86 const timeoutPromise = new Promise<never>((_, reject) => {116 const timeoutPromise = new Promise<never>((_, reject) => {
87 timeout = setTimeout(() => {117 timeout = setTimeout(() => {
118 // oxlint-disable-next-line executor/no-promise-catch -- boundary: best-effort stream cancellation inside timeout callback
88 void reader.cancel().catch(() => undefined);119 void reader.cancel().catch(() => undefined);
89 reject(new ResponseBodyTimeoutError(timeoutMs));120 // oxlint-disable-next-line executor/no-promise-reject -- boundary: Promise.race timeout adapter for Web ReadableStream
121 reject(new ResponseBodyTimeoutError({ timeoutMs }));
90 }, timeoutMs);122 }, timeoutMs);
91 });123 });
92 const readPromise = (async () => {124 const readPromise = (async () => {
125 // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: Web stream reader cleanup must clear host timeout after success or failure
93 try {126 try {
94 let text = "";127 let text = "";
95 for (;;) {128 for (;;) {
@@ -125,9 +158,18 @@ const withoutBodyHeaders = (response: Response) => { · 29 unmodified lines
125 });158 });
126};159};
127 160
161const isResponseBodyTimeoutError = (error: unknown) =>
162 Option.isSome(decodeResponseBodyTimeoutError(error));
163
164const responsePeekError = (error: unknown): ResponseBodyTimeoutError | ResponseBodyReadError =>
165 Option.match(decodeResponseBodyTimeoutError(error), {
166 onNone: () => new ResponseBodyReadError(),
167 onSome: ({ timeoutMs }) => new ResponseBodyTimeoutError({ timeoutMs }),
168 });
169
128const responseReadFailure = (error: unknown) =>170const responseReadFailure = (error: unknown) =>
129 Effect.gen(function* () {171 Effect.gen(function* () {
130 const timedOut = error instanceof ResponseBodyTimeoutError;172 const timedOut = isResponseBodyTimeoutError(error);
131 yield* Effect.annotateCurrentSpan({173 yield* Effect.annotateCurrentSpan({
132 "mcp.response.status_code": timedOut ? 504 : 500,174 "mcp.response.status_code": timedOut ? 504 : 500,
133 "mcp.response.content_type": "application/json",175 "mcp.response.content_type": "application/json",
@@ -135,7 +177,7 @@ const responseReadFailure = (error: unknown) => · 1 unmodified line
135 "mcp.response.body.length": 0,177 "mcp.response.body.length": 0,
136 "mcp.response.jsonrpc.detected": true,178 "mcp.response.jsonrpc.detected": true,
137 "mcp.peek_response.timed_out": timedOut,179 "mcp.peek_response.timed_out": timedOut,
138 "mcp.peek_response.error": String(error),180 "mcp.peek_response.error": timedOut ? "ResponseBodyTimeoutError" : "ResponseBodyReadError",
139 });181 });
140 return jsonRpcWebResponse(182 return jsonRpcWebResponse(
141 timedOut ? 504 : 500,183 timedOut ? 504 : 500,
@@ -149,8 +191,8 @@ const responseReadFailure = (error: unknown) => · 7 unmodified lines
149const reportInternalJsonRpcError = (payload: JsonRpcResponseBody | null) =>191const reportInternalJsonRpcError = (payload: JsonRpcResponseBody | null) =>
150 Effect.sync(() => {192 Effect.sync(() => {
151 if (payload?.error?.code !== -32603) return;193 if (payload?.error?.code !== -32603) return;
152 const msg = payload.error.message ?? "unknown";194 const message = payload.error["message"] ?? "unknown";
153 Sentry.captureException(new Error(`MCP internal error (-32603): ${msg}`));195 Sentry.captureException(new McpInternalJsonRpcError({ message }));
154 });196 });
155 197
156export const peekAndAnnotate = (response: Response): Effect.Effect<Response> =>198export const peekAndAnnotate = (response: Response): Effect.Effect<Response> =>
@@ -167,21 +209,29 @@ export const peekAndAnnotate = (response: Response): Effect.Effect<Response> => · 10 unmodified lines
167 209
168 const isSseResponse = contentType.includes("text/event-stream");210 const isSseResponse = contentType.includes("text/event-stream");
169 const timeoutMs = isSseResponse ? SSE_PEEK_TIMEOUT_MS : null;211 const timeoutMs = isSseResponse ? SSE_PEEK_TIMEOUT_MS : null;
170 const textResult = yield* Effect.result(Effect.tryPromise({212 const textExit = yield* Effect.exit(
171 try: () => readResponseText(response, timeoutMs),213 Effect.tryPromise({
172 catch: (error) => error,214 try: () => readResponseText(response, timeoutMs),
173 }).pipe(215 catch: responsePeekError,
174 Effect.withSpan("mcp.peek_response", {216 }).pipe(
175 attributes: {217 Effect.withSpan("mcp.peek_response", {
176 "http.response.content_type": contentType,218 attributes: {
177 "http.response.status_code": response.status,219 "http.response.content_type": contentType,
178 "mcp.peek_response.timeout_ms": timeoutMs ?? 0,220 "http.response.status_code": response.status,
179 },221 "mcp.peek_response.timeout_ms": timeoutMs ?? 0,
180 }),222 },
181 ));223 }),
182 if (textResult._tag === "Failure") return yield* responseReadFailure(textResult.failure);224 ),
183 225 );
184 const text = textResult.success;226 if (Exit.isFailure(textExit)) {
227 const error = Option.getOrElse(
228 Cause.findErrorOption(textExit.cause),
229 () => new ResponseBodyReadError(),
230 );
231 return yield* responseReadFailure(error);
232 }
233
234 const text = textExit.value;
185 const payload = parseFirstJsonRpc(contentType, text);235 const payload = parseFirstJsonRpc(contentType, text);
186 yield* Effect.annotateCurrentSpan({236 yield* Effect.annotateCurrentSpan({
187 "mcp.response.status_code": response.status,237 "mcp.response.status_code": response.status,