| @@ -30,7 +30,7 @@ · 29 unmodified lines |
| 30 | 30 | // round-trip, no DCR — every non-MCP endpoint exits here. |
| 31 | 31 | // --------------------------------------------------------------------------- |
| 32 | 32 | |
| 33 | | −import { Effect } from "effect"; |
| 33 | +import { Data, Effect, Option, Schema } from "effect"; |
| 34 | 34 | |
| 35 | 35 | /** MCP initialize request body used as the shape probe. Any real MCP |
| 36 | 36 | * server either answers it (unauth-OK server) or returns the spec- |
| @@ -62,6 +62,27 @@ const readHeader = (headers: Headers, name: string): string | null => { · 25 unmodified lines |
| 62 | 62 | return null; |
| 63 | 63 | }; |
| 64 | 64 | |
| 65 | +class ProbeTransportError extends Data.TaggedError("ProbeTransportError")<{ |
| 66 | + readonly reason: string; |
| 67 | + readonly cause: unknown; |
| 68 | +}> {} |
| 69 | + |
| 70 | +const ErrorMessageShape = Schema.Struct({ message: Schema.String }); |
| 71 | +const decodeErrorMessageShape = Schema.decodeUnknownOption(ErrorMessageShape); |
| 72 | + |
| 73 | +const reasonFromBoundaryCause = (cause: unknown): string => { |
| 74 | + const messageShape = decodeErrorMessageShape(cause); |
| 75 | + if (Option.isSome(messageShape)) return messageShape.value.message; |
| 76 | + if (typeof cause === "string") return cause; |
| 77 | + if (typeof cause === "number" || typeof cause === "boolean" || typeof cause === "bigint") { |
| 78 | + return `${cause}`; |
| 79 | + } |
| 80 | + if (typeof cause === "symbol") return cause.description ?? "symbol"; |
| 81 | + if (cause === null) return "null"; |
| 82 | + if (typeof cause === "undefined") return "undefined"; |
| 83 | + return "fetch failed"; |
| 84 | +}; |
| 85 | + |
| 65 | 86 | export type McpShapeProbeResult = |
| 66 | 87 | /** Server answered initialize successfully — either a 2xx with a |
| 67 | 88 | * JSON-RPC payload, or a 401 + WWW-Authenticate: Bearer (RFC 6750 |
| @@ -99,85 +120,86 @@ export const probeMcpEndpointShape = ( · 31 unmodified lines |
| 99 | 120 | Effect.gen(function* () { |
| 100 | 121 | const fetchImpl = options.fetch ?? globalThis.fetch; |
| 101 | 122 | const timeoutMs = options.timeoutMs ?? 8_000; |
| 123 | + const controller = new AbortController(); |
| 124 | + const timer = setTimeout(() => controller.abort(), timeoutMs); |
| 102 | 125 | |
| 103 | 126 | const outcome = yield* Effect.tryPromise({ |
| 104 | 127 | try: async (): Promise<McpShapeProbeResult> => { |
| 105 | | − const controller = new AbortController(); |
| 106 | | − const timer = setTimeout(() => controller.abort(), timeoutMs); |
| 107 | | − try { |
| 108 | | − const classify = (response: Response, method: "GET" | "POST") => { |
| 109 | | − if (response.status === 401) { |
| 110 | | − const wwwAuth = readHeader(response.headers, "www-authenticate"); |
| 111 | | − if (wwwAuth && /^\s*bearer\b/i.test(wwwAuth)) { |
| 112 | | − return { kind: "mcp", requiresAuth: true } as const; |
| 113 | | − } |
| 114 | | − return { |
| 115 | | − kind: "not-mcp", |
| 116 | | − reason: |
| 117 | | − "401 without Bearer WWW-Authenticate — not an MCP auth challenge", |
| 118 | | − } as const; |
| 128 | + const classify = (response: Response, method: "GET" | "POST") => { |
| 129 | + if (response.status === 401) { |
| 130 | + const wwwAuth = readHeader(response.headers, "www-authenticate"); |
| 131 | + if (wwwAuth && /^\s*bearer\b/i.test(wwwAuth)) { |
| 132 | + return { kind: "mcp", requiresAuth: true } as const; |
| 119 | 133 | } |
| 134 | + return { |
| 135 | + kind: "not-mcp", |
| 136 | + reason: |
| 137 | + "401 without Bearer WWW-Authenticate — not an MCP auth challenge", |
| 138 | + } as const; |
| 139 | + } |
| 120 | 140 | |
| 121 | | − if (response.status >= 200 && response.status < 300) { |
| 122 | | − if (method === "GET") { |
| 123 | | − const contentType = readHeader(response.headers, "content-type") ?? ""; |
| 124 | | − if (!/^\s*text\/event-stream\b/i.test(contentType)) { |
| 125 | | − return { |
| 126 | | − kind: "not-mcp", |
| 127 | | − reason: "GET response is not an SSE stream", |
| 128 | | − } as const; |
| 129 | | − } |
| 141 | + if (response.status >= 200 && response.status < 300) { |
| 142 | + if (method === "GET") { |
| 143 | + const contentType = readHeader(response.headers, "content-type") ?? ""; |
| 144 | + if (!/^\s*text\/event-stream\b/i.test(contentType)) { |
| 145 | + return { |
| 146 | + kind: "not-mcp", |
| 147 | + reason: "GET response is not an SSE stream", |
| 148 | + } as const; |
| 130 | 149 | } |
| 131 | | − return { kind: "mcp", requiresAuth: false } as const; |
| 132 | 150 | } |
| 151 | + return { kind: "mcp", requiresAuth: false } as const; |
| 152 | + } |
| 133 | 153 | |
| 134 | | − return null; |
| 135 | | − }; |
| 154 | + return null; |
| 155 | + }; |
| 136 | 156 | |
| 137 | | − const url = new URL(endpoint); |
| 138 | | − for (const [key, value] of Object.entries(options.queryParams ?? {})) { |
| 139 | | − url.searchParams.set(key, value); |
| 140 | | − } |
| 141 | | − const authHeaders = options.headers ?? {}; |
| 142 | | − |
| 143 | | − const postResponse = await fetchImpl(url, { |
| 144 | | − method: "POST", |
| 145 | | − headers: { |
| 146 | | − ...authHeaders, |
| 147 | | − "content-type": "application/json", |
| 148 | | − accept: "application/json, text/event-stream", |
| 149 | | − }, |
| 150 | | − body: INITIALIZE_BODY, |
| 151 | | − signal: controller.signal, |
| 152 | | − }); |
| 157 | + const url = new URL(endpoint); |
| 158 | + for (const [key, value] of Object.entries(options.queryParams ?? {})) { |
| 159 | + url.searchParams.set(key, value); |
| 160 | + } |
| 161 | + const authHeaders = options.headers ?? {}; |
| 153 | 162 | |
| 154 | | − const postResult = classify(postResponse, "POST"); |
| 155 | | − if (postResult) return postResult; |
| 156 | | − |
| 157 | | − if ([404, 405, 406, 415].includes(postResponse.status)) { |
| 158 | | − const getResponse = await fetchImpl(url, { |
| 159 | | − method: "GET", |
| 160 | | − headers: { ...authHeaders, accept: "text/event-stream" }, |
| 161 | | − signal: controller.signal, |
| 162 | | − }); |
| 163 | | − const getResult = classify(getResponse, "GET"); |
| 164 | | − if (getResult) return getResult; |
| 165 | | − } |
| 163 | + const postResponse = await fetchImpl(url, { |
| 164 | + method: "POST", |
| 165 | + headers: { |
| 166 | + ...authHeaders, |
| 167 | + "content-type": "application/json", |
| 168 | + accept: "application/json, text/event-stream", |
| 169 | + }, |
| 170 | + body: INITIALIZE_BODY, |
| 171 | + signal: controller.signal, |
| 172 | + }); |
| 173 | + |
| 174 | + const postResult = classify(postResponse, "POST"); |
| 175 | + if (postResult) return postResult; |
| 166 | 176 | |
| 167 | | − return { |
| 168 | | − kind: "not-mcp", |
| 169 | | − reason: `unexpected status ${postResponse.status} for initialize`, |
| 170 | | − }; |
| 171 | | − } finally { |
| 172 | | − clearTimeout(timer); |
| 177 | + if ([404, 405, 406, 415].includes(postResponse.status)) { |
| 178 | + const getResponse = await fetchImpl(url, { |
| 179 | + method: "GET", |
| 180 | + headers: { ...authHeaders, accept: "text/event-stream" }, |
| 181 | + signal: controller.signal, |
| 182 | + }); |
| 183 | + const getResult = classify(getResponse, "GET"); |
| 184 | + if (getResult) return getResult; |
| 173 | 185 | } |
| 186 | + |
| 187 | + return { |
| 188 | + kind: "not-mcp", |
| 189 | + reason: `unexpected status ${postResponse.status} for initialize`, |
| 190 | + }; |
| 174 | 191 | }, |
| 175 | | − catch: (cause) => cause, |
| 192 | + catch: (cause) => |
| 193 | + new ProbeTransportError({ |
| 194 | + reason: reasonFromBoundaryCause(cause), |
| 195 | + cause, |
| 196 | + }), |
| 176 | 197 | }).pipe( |
| 198 | + Effect.ensuring(Effect.sync(() => clearTimeout(timer))), |
| 177 | 199 | Effect.catch((cause) => |
| 178 | 200 | Effect.succeed<McpShapeProbeResult>({ |
| 179 | 201 | kind: "unreachable", |
| 180 | | − reason: cause instanceof Error ? cause.message : String(cause), |
| 202 | + reason: cause.reason, |
| 181 | 203 | }), |
| 182 | 204 | ), |
| 183 | 205 | ); |