Unified · Split
| @@ -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 | } |
| @@ -16,7 +16,7 @@ · 15 unmodified lines | |||
| 16 | 16 | ||
| 17 | import { env } from "cloudflare:workers"; | 17 | import { env } from "cloudflare:workers"; |
| 18 | import { HttpEffect, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"; | 18 | import { HttpEffect, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"; |
| 19 | import { Cause, Context, Effect, Layer, Option, Schema } from "effect"; | 19 | import { Cause, Context, Effect, Layer, Option, Predicate, Result, Schema } from "effect"; |
| 20 | 20 | ||
| 21 | import { createCachedRemoteJWKSet } from "./jwks-cache"; | 21 | import { createCachedRemoteJWKSet } from "./jwks-cache"; |
| 22 | import { captureCause } from "./observability"; | 22 | import { 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 | |||
| 211 | const requestWithCf = (request: Request): Request & { cf?: CfRequestMetadata } => | 211 | const requestWithCf = (request: Request): Request & { cf?: CfRequestMetadata } => |
| 212 | request as Request & { cf?: CfRequestMetadata }; | 212 | request as Request & { cf?: CfRequestMetadata }; |
| 213 | 213 | ||
| 214 | const getCfMeta = (request: Request): CfRequestMetadata => | 214 | const getCfMeta = (request: Request): CfRequestMetadata => requestWithCf(request).cf ?? {}; |
| 215 | requestWithCf(request).cf ?? {}; | ||
| 216 | 215 | ||
| 217 | const HEADERS_TO_DUMP = [ | 216 | const HEADERS_TO_DUMP = [ |
| 218 | "accept", | 217 | "accept", |
| @@ -285,22 +284,28 @@ const InitializeParams = Schema.Struct({ · 66 unmodified lines | |||
| 285 | const NamedParams = Schema.Struct({ name: Schema.optional(Schema.String) }); | 284 | const NamedParams = Schema.Struct({ name: Schema.optional(Schema.String) }); |
| 286 | const UriParams = Schema.Struct({ uri: Schema.optional(Schema.String) }); | 285 | const UriParams = Schema.Struct({ uri: Schema.optional(Schema.String) }); |
| 287 | 286 | ||
| 288 | const decodeJsonRpcEnvelope = Schema.decodeUnknownOption(JsonRpcEnvelope); | 287 | const decodeJsonRpcEnvelopeString = Schema.decodeUnknownOption( |
| 288 | Schema.fromJsonString(JsonRpcEnvelope), | ||
| 289 | ); | ||
| 289 | const decodeInitializeParams = Schema.decodeUnknownOption(InitializeParams); | 290 | const decodeInitializeParams = Schema.decodeUnknownOption(InitializeParams); |
| 290 | const decodeNamedParams = Schema.decodeUnknownOption(NamedParams); | 291 | const decodeNamedParams = Schema.decodeUnknownOption(NamedParams); |
| 291 | const decodeUriParams = Schema.decodeUnknownOption(UriParams); | 292 | const decodeUriParams = Schema.decodeUnknownOption(UriParams); |
| 292 | const decodeElicitationReplyResult = Schema.decodeUnknownOption(ElicitationReplyResult); | 293 | const decodeElicitationReplyResult = Schema.decodeUnknownOption(ElicitationReplyResult); |
| 293 | 294 | ||
| 295 | const isMcpAuthorized = (value: McpAuthResult): value is McpAuthorizedResult => | ||
| 296 | Predicate.isTagged(value, "Authorized"); | ||
| 297 | const isMcpUnauthorized = (value: McpAuthResult): value is McpUnauthorizedResult => | ||
| 298 | Predicate.isTagged(value, "Unauthorized"); | ||
| 299 | |||
| 294 | const readJsonRpcEnvelope = (request: Request): Effect.Effect<Option.Option<JsonRpcEnvelope>> => | 300 | const 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 | ||
| 305 | const methodAttrs = (envelope: JsonRpcEnvelope): Record<string, unknown> => { | 310 | const 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 | ||
| 412 | const authorizationServerMetadata = Effect.promise(async () => { | 417 | const 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 dispatch | 427 | // 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 DELETE | 679 | // 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; |
| @@ -1,23 +1,57 @@ | |||
| 1 | import * as Sentry from "@sentry/cloudflare"; | 1 | import * as Sentry from "@sentry/cloudflare"; |
| 2 | import { Effect } from "effect"; | 2 | import { Cause, Data, Effect, Exit, Option, Schema } from "effect"; |
| 3 | 3 | ||
| 4 | import { jsonRpcWebResponse } from "./responses"; | 4 | import { jsonRpcWebResponse } from "./responses"; |
| 5 | 5 | ||
| 6 | const SSE_PEEK_TIMEOUT_MS = 10_000; | 6 | const SSE_PEEK_TIMEOUT_MS = 10_000; |
| 7 | 7 | ||
| 8 | type SandboxOutcome = { | 8 | class 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 | 12 | class ResponseBodyReadError extends Data.TaggedError("ResponseBodyReadError") {} | |
| 13 | type JsonRpcResponseBody = { | 13 | |
| 14 | readonly jsonrpc?: string; | 14 | class 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; | 18 | const ResponseBodyTimeoutErrorData = Schema.Struct({ |
| 19 | }; | 19 | _tag: Schema.Literal("ResponseBodyTimeoutError"), |
| 20 | }; | 20 | timeoutMs: Schema.Number, |
| 21 | }); | ||
| 22 | const decodeResponseBodyTimeoutError = Schema.decodeUnknownOption(ResponseBodyTimeoutErrorData); | ||
| 23 | |||
| 24 | const 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 | |||
| 34 | const 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 | |||
| 50 | const decodeJsonRpcResponseBody = Schema.decodeUnknownOption( | ||
| 51 | Schema.fromJsonString(JsonRpcResponseBodySchema), | ||
| 52 | ); | ||
| 53 | |||
| 54 | type JsonRpcResponseBody = typeof JsonRpcResponseBodySchema.Type; | ||
| 21 | 55 | ||
| 22 | const responseBodyShape = (body: string): string => { | 56 | const 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 | ||
| 32 | const parseFirstJsonRpc = (contentType: string, body: string): JsonRpcResponseBody | null => { | 66 | const 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 | ||
| 48 | const jsonRpcResponseAttrs = (payload: JsonRpcResponseBody | null): Record<string, unknown> => { | 82 | const 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 | ||
| 71 | class 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 | |||
| 78 | const readResponseText = async (response: Response, timeoutMs: number | null): Promise<string> => { | 108 | const 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 | ||
| 161 | const isResponseBodyTimeoutError = (error: unknown) => | ||
| 162 | Option.isSome(decodeResponseBodyTimeoutError(error)); | ||
| 163 | |||
| 164 | const responsePeekError = (error: unknown): ResponseBodyTimeoutError | ResponseBodyReadError => | ||
| 165 | Option.match(decodeResponseBodyTimeoutError(error), { | ||
| 166 | onNone: () => new ResponseBodyReadError(), | ||
| 167 | onSome: ({ timeoutMs }) => new ResponseBodyTimeoutError({ timeoutMs }), | ||
| 168 | }); | ||
| 169 | |||
| 128 | const responseReadFailure = (error: unknown) => | 170 | const 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 | |||
| 149 | const reportInternalJsonRpcError = (payload: JsonRpcResponseBody | null) => | 191 | const 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 | ||
| 156 | export const peekAndAnnotate = (response: Response): Effect.Effect<Response> => | 198 | export 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, |