| @@ -30,7 +30,7 @@ · 29 unmodified lines |
| 30 | // round-trip, no DCR — every non-MCP endpoint exits here. | 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 | /** MCP initialize request body used as the shape probe. Any real MCP | 35 | /** MCP initialize request body used as the shape probe. Any real MCP |
| 36 | * server either answers it (unauth-OK server) or returns the spec- | 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 | return null; | 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 | export type McpShapeProbeResult = | 86 | export type McpShapeProbeResult = |
| 66 | /** Server answered initialize successfully — either a 2xx with a | 87 | /** Server answered initialize successfully — either a 2xx with a |
| 67 | * JSON-RPC payload, or a 401 + WWW-Authenticate: Bearer (RFC 6750 | 88 | * JSON-RPC payload, or a 401 + WWW-Authenticate: Bearer (RFC 6750 |
| @@ -99,85 +120,86 @@ export const probeMcpEndpointShape = ( · 31 unmodified lines |
| 99 | Effect.gen(function* () { | 120 | Effect.gen(function* () { |
| 100 | const fetchImpl = options.fetch ?? globalThis.fetch; | 121 | const fetchImpl = options.fetch ?? globalThis.fetch; |
| 101 | const timeoutMs = options.timeoutMs ?? 8_000; | 122 | const timeoutMs = options.timeoutMs ?? 8_000; |
| | 123 | const controller = new AbortController(); |
| | 124 | const timer = setTimeout(() => controller.abort(), timeoutMs); |
| 102 | | 125 | |
| 103 | const outcome = yield* Effect.tryPromise({ | 126 | const outcome = yield* Effect.tryPromise({ |
| 104 | try: async (): Promise<McpShapeProbeResult> => { | 127 | try: async (): Promise<McpShapeProbeResult> => { |
| 105 | const controller = new AbortController(); | 128 | const classify = (response: Response, method: "GET" | "POST") => { |
| 106 | const timer = setTimeout(() => controller.abort(), timeoutMs); | 129 | if (response.status === 401) { |
| 107 | try { | 130 | const wwwAuth = readHeader(response.headers, "www-authenticate"); |
| 108 | const classify = (response: Response, method: "GET" | "POST") => { | 131 | if (wwwAuth && /^\s*bearer\b/i.test(wwwAuth)) { |
| 109 | if (response.status === 401) { | 132 | return { kind: "mcp", requiresAuth: true } as const; |
| 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; | | |
| 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) { | 141 | if (response.status >= 200 && response.status < 300) { |
| 122 | if (method === "GET") { | 142 | if (method === "GET") { |
| 123 | const contentType = readHeader(response.headers, "content-type") ?? ""; | 143 | const contentType = readHeader(response.headers, "content-type") ?? ""; |
| 124 | if (!/^\s*text\/event-stream\b/i.test(contentType)) { | 144 | if (!/^\s*text\/event-stream\b/i.test(contentType)) { |
| 125 | return { | 145 | return { |
| 126 | kind: "not-mcp", | 146 | kind: "not-mcp", |
| 127 | reason: "GET response is not an SSE stream", | 147 | reason: "GET response is not an SSE stream", |
| 128 | } as const; | 148 | } as const; |
| 129 | } | | |
| 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; | 154 | return null; |
| 135 | }; | 155 | }; |
| 136 | | 156 | |
| 137 | const url = new URL(endpoint); | 157 | const url = new URL(endpoint); |
| 138 | for (const [key, value] of Object.entries(options.queryParams ?? {})) { | 158 | for (const [key, value] of Object.entries(options.queryParams ?? {})) { |
| 139 | url.searchParams.set(key, value); | 159 | url.searchParams.set(key, value); |
| 140 | } | 160 | } |
| 141 | const authHeaders = options.headers ?? {}; | 161 | 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 | }); | | |
| 153 | | 162 | |
| 154 | const postResult = classify(postResponse, "POST"); | 163 | const postResponse = await fetchImpl(url, { |
| 155 | if (postResult) return postResult; | 164 | method: "POST", |
| 156 | | 165 | headers: { |
| 157 | if ([404, 405, 406, 415].includes(postResponse.status)) { | 166 | ...authHeaders, |
| 158 | const getResponse = await fetchImpl(url, { | 167 | "content-type": "application/json", |
| 159 | method: "GET", | 168 | accept: "application/json, text/event-stream", |
| 160 | headers: { ...authHeaders, accept: "text/event-stream" }, | 169 | }, |
| 161 | signal: controller.signal, | 170 | body: INITIALIZE_BODY, |
| 162 | }); | 171 | signal: controller.signal, |
| 163 | const getResult = classify(getResponse, "GET"); | 172 | }); |
| 164 | if (getResult) return getResult; | 173 | |
| 165 | } | 174 | const postResult = classify(postResponse, "POST"); |
| | 175 | if (postResult) return postResult; |
| 166 | | 176 | |
| 167 | return { | 177 | if ([404, 405, 406, 415].includes(postResponse.status)) { |
| 168 | kind: "not-mcp", | 178 | const getResponse = await fetchImpl(url, { |
| 169 | reason: `unexpected status ${postResponse.status} for initialize`, | 179 | method: "GET", |
| 170 | }; | 180 | headers: { ...authHeaders, accept: "text/event-stream" }, |
| 171 | } finally { | 181 | signal: controller.signal, |
| 172 | clearTimeout(timer); | 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 | }).pipe( | 197 | }).pipe( |
| | 198 | Effect.ensuring(Effect.sync(() => clearTimeout(timer))), |
| 177 | Effect.catch((cause) => | 199 | Effect.catch((cause) => |
| 178 | Effect.succeed<McpShapeProbeResult>({ | 200 | Effect.succeed<McpShapeProbeResult>({ |
| 179 | kind: "unreachable", | 201 | kind: "unreachable", |
| 180 | reason: cause instanceof Error ? cause.message : String(cause), | 202 | reason: cause.reason, |
| 181 | }), | 203 | }), |
| 182 | ), | 204 | ), |
| 183 | ); | 205 | ); |