428 lines · 18.9 KB · TypeScript
RawDownload
// ---------------------------------------------------------------------------// MCP endpoint shape probe — decide whether an unknown HTTP endpoint is// actually speaking MCP before we try to classify it as such.//// Background:////   `discoverTools` (via the MCP SDK's StreamableHTTP / SSE transport)//   fails for every non-MCP endpoint too — 200-with-HTML, 400-GraphQL,//   404, etc. all surface as the same opaque transport error. We need//   our own classifier that distinguishes "real MCP" from//   "OAuth-protected non-MCP service" without relying on RFC 9728/8414//   metadata, since (a) plenty of non-MCP APIs publish that metadata,//   and (b) plenty of real MCP servers authenticate with static API//   keys and publish no OAuth metadata at all (e.g. cubic.dev).//// The probe issues an unauth JSON-RPC `initialize` POST and accepts// only the wire shapes a real MCP server can return:////   - 2xx with `Content-Type: text/event-stream` — streamable HTTP//     transport, body is an SSE stream we don't consume.//   - 2xx with `Content-Type: application/json` whose body parses as a//     JSON-RPC 2.0 envelope (`{jsonrpc:"2.0", result|error|method,...}`).//   - 401 with `WWW-Authenticate: Bearer` AND a JSON-RPC error envelope//     in the body. The body shape is what separates a real MCP server//     from an unrelated OAuth-protected API: GraphQL/REST/HTML 401s//     don't shape themselves as JSON-RPC.//// When POST returns 404/405/406/415 we retry with GET + `Accept:// text/event-stream` to support legacy SSE-only servers; that path// only accepts 2xx with `text/event-stream` or the same 401+Bearer// shape.//// One `fetch` (occasionally two), no MCP-SDK session state, no OAuth// round-trip, no DCR — every non-MCP endpoint exits here.// --------------------------------------------------------------------------- import { Data, Duration, Effect, Layer, Option, Schema } from "effect";import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http"; /** MCP initialize request body used as the shape probe. Any real MCP *  server either answers it (unauth-OK server) or returns the spec- *  mandated 401 + WWW-Authenticate pair. A non-MCP endpoint hit with *  this body will respond with whatever it does for unknown JSON *  payloads — 400, 404, HTML, a GraphQL error envelope, etc. — none of *  which match the gate below. */const INITIALIZE_BODY = JSON.stringify({  jsonrpc: "2.0",  id: 1,  method: "initialize",  params: {    protocolVersion: "2025-06-18",    capabilities: {},    clientInfo: { name: "executor-probe", version: "0" },  },}); /** Header-name lookup is case-insensitive per RFC 7230. `fetch`'s *  `Response.headers` already lower-cases, but we normalise explicitly *  to stay robust against test mocks that construct `Headers` loosely. */const readHeader = (headers: Readonly<Record<string, string>>, name: string): string | null => {  const direct = headers[name];  if (direct !== undefined) return direct;  const lower = name.toLowerCase();  for (const [k, v] of Object.entries(headers)) {    if (k.toLowerCase() === lower) return v;  }  return null;}; class ProbeTransportError extends Data.TaggedError("ProbeTransportError")<{  readonly reason: string;  readonly cause: unknown;}> {} const decodeJsonString = Schema.decodeUnknownOption(Schema.fromJsonString(Schema.Unknown)); const asObject = (body: string): Record<string, unknown> | null => {  if (!body) return null;  const parsed = decodeJsonString(body);  if (Option.isNone(parsed)) return null;  const value = parsed.value;  if (typeof value !== "object" || value === null || Array.isArray(value)) return null;  return value as Record<string, unknown>;}; /** Quick check that a body parses as a JSON-RPC 2.0 envelope. The MCP wire *  protocol is JSON-RPC 2.0, so a real MCP server's response to `initialize` *  (whether 2xx with the result, or a 401 error envelope) carries this *  shape. Non-MCP services don't — GraphQL APIs return `{errors:[...]}`, *  REST APIs return their own envelope, marketing pages return HTML. */const isJsonRpcEnvelope = (body: string): boolean => {  const obj = asObject(body);  if (!obj) return false;  if (obj.jsonrpc !== "2.0") return false;  return "result" in obj || "error" in obj || "method" in obj;}; /** Quick check that a body parses as an RFC 6750 OAuth Bearer error *  envelope (`{error: "invalid_token", error_description?: ..., ...}`). *  Real MCP servers like Atlassian return this shape on unauth requests *  even when their WWW-Authenticate omits `resource_metadata=`. The *  GraphQL `{errors: [...]}` envelope, which a non-MCP OAuth-protected *  GraphQL API would return, is explicitly excluded. */const isOAuthErrorBody = (body: string): boolean => {  const obj = asObject(body);  if (!obj) return false;  if (Array.isArray(obj.errors)) return false;  return typeof obj.error === "string";}; /** RFC 9728 protected-resource-metadata document. We only need the two *  fields that prove the document genuinely describes an OAuth-protected *  resource: `resource` (the resource identifier) and a non-empty *  `authorization_servers` list. */const ProtectedResourceMetadata = Schema.Struct({  resource: Schema.String,  authorization_servers: Schema.Array(Schema.String),});const decodeProtectedResourceMetadata = Schema.decodeUnknownOption(  Schema.fromJsonString(ProtectedResourceMetadata),); /** RFC 9728 §3.1 path-scoped well-known URL: insert *  `/.well-known/oauth-protected-resource` before the resource's path *  component. `https://host/api/mcp` → `https://host/.well-known/oauth- *  protected-resource/api/mcp`. This is exactly the URL the MCP *  authorization spec tells clients to construct. */const protectedResourceMetadataUrl = (endpoint: URL): string => {  const path = endpoint.pathname === "/" ? "" : endpoint.pathname;  return `${endpoint.origin}/.well-known/oauth-protected-resource${path}`;}; /** The RFC 9728 `resource` value must actually describe this endpoint *  before we trust the document — an exact URL match, or a same-origin *  parent whose path is a prefix of the endpoint's. Guards against a *  shared host serving protected-resource metadata for some unrelated *  resource. */const resourceMatchesEndpoint = (resource: string, endpoint: URL): boolean => {  if (!URL.canParse(resource)) return false;  const parsed = new URL(resource);  if (parsed.origin !== endpoint.origin) return false;  const resourcePath = parsed.pathname.replace(/\/+$/, "");  const endpointPath = endpoint.pathname.replace(/\/+$/, "");  return endpointPath === resourcePath || endpointPath.startsWith(`${resourcePath}/`);}; /** Workaround for MCP servers that omit (or under-specify) the *  `WWW-Authenticate` challenge on their 401 — e.g. Datadog's *  `mcp.datadoghq.com` returns a bare `401 {"errors":["Unauthorized"]}` *  with no header at all, so the wire-shape gate above can't tell it *  apart from an unrelated OAuth-protected API and the user lands on the *  manual-credentials prompt instead of an OAuth sign-in. * *  The MCP authorization spec still requires such servers to publish *  RFC 9728 metadata at the path-scoped well-known URL. A document there *  whose `resource` matches this endpoint is a deliberate, MCP-spec- *  specific signal a generic OAuth API would not emit — strong enough to *  classify the endpoint as MCP so the OAuth flow can start. */const probeProtectedResourceMetadata = (  client: HttpClient.HttpClient,  endpoint: URL,  timeoutMs: number,): Effect.Effect<boolean> =>  Effect.gen(function* () {    const response = yield* client      .execute(        HttpClientRequest.get(protectedResourceMetadataUrl(endpoint)).pipe(          HttpClientRequest.setHeader("accept", "application/json"),        ),      )      .pipe(Effect.timeout(Duration.millis(timeoutMs)));    if (response.status < 200 || response.status >= 300) return false;    const body = yield* response.text.pipe(      Effect.timeout(Duration.millis(timeoutMs)),      Effect.catch(() => Effect.succeed("")),    );    const metadata = decodeProtectedResourceMetadata(body);    if (Option.isNone(metadata)) return false;    if (metadata.value.authorization_servers.length === 0) return false;    return resourceMatchesEndpoint(metadata.value.resource, endpoint);  }).pipe(Effect.catch(() => Effect.succeed(false))); const ErrorMessageShape = Schema.Struct({ message: Schema.String });const decodeErrorMessageShape = Schema.decodeUnknownOption(ErrorMessageShape); const reasonFromBoundaryCause = (cause: unknown): string => {  const messageShape = decodeErrorMessageShape(cause);  if (Option.isSome(messageShape)) return messageShape.value.message;  if (typeof cause === "string") return cause;  if (typeof cause === "number" || typeof cause === "boolean" || typeof cause === "bigint") {    return `${cause}`;  }  if (typeof cause === "symbol") return cause.description ?? "symbol";  if (cause === null) return "null";  if (typeof cause === "undefined") return "undefined";  return "fetch failed";}; /** Why the probe rejected an endpoint as not-MCP. * *  - `auth-required` — server returned 401. We don't know for sure it's *    an MCP server (no spec-compliant Bearer challenge or the body *    isn't JSON-RPC), but the right next step for the user is the same *    either way: provide credentials and retry. This is what *    misclassifies real MCP servers like cubic.dev (no *    resource_metadata) or ref.tools (no WWW-Authenticate at all) *    without the URL-token fallback at the detect layer. *  - `wrong-shape` — endpoint responded but with a body or status that *    doesn't match any MCP shape (200 HTML, 400 GraphQL, 404 from a *    static host, etc.). User action: this URL probably isn't MCP. */export type McpProbeRejectCategory = "auth-required" | "wrong-shape"; export type McpShapeProbeResult =  /** Server answered initialize successfully — either a 2xx with a   *  JSON-RPC payload, or a 401 + WWW-Authenticate: Bearer (RFC 6750   *  challenge) that the MCP auth spec requires. */  | { readonly kind: "mcp"; readonly requiresAuth: boolean }  /** Endpoint is reachable but the response does not look like MCP. */  | {      readonly kind: "not-mcp";      readonly reason: string;      readonly category: McpProbeRejectCategory;    }  /** Transport-level failure (DNS, TLS, timeout, abort, ...). */  | { readonly kind: "unreachable"; readonly reason: string }; export interface ProbeOptions {  /** Abort the request after this many ms. Default 8000. */  readonly timeoutMs?: number;  readonly httpClientLayer?: Layer.Layer<HttpClient.HttpClient>;  readonly headers?: Record<string, string>;  readonly queryParams?: Record<string, string>;} /** * Hit `endpoint` with a JSON-RPC `initialize` POST and classify the * response according to the MCP authorization spec. * * Returns `{kind: "mcp"}` only when the endpoint either: *   - answers with 2xx (unauth-OK MCP server), or *   - responds 401 with a `Bearer` WWW-Authenticate challenge. * * Anything else (400, 404, 200-with-HTML, 200-with-GraphQL-errors, ...) * is classified `not-mcp`. Transport errors surface as `unreachable`. */export const probeMcpEndpointShape = (  endpoint: string,  options: ProbeOptions = {},): Effect.Effect<McpShapeProbeResult> =>  Effect.gen(function* () {    const timeoutMs = options.timeoutMs ?? 8_000;    const outcome = yield* Effect.gen(function* () {      const client = yield* HttpClient.HttpClient;       const readBody = (response: {        readonly text: Effect.Effect<string, unknown>;      }): Effect.Effect<string> =>        response.text.pipe(          Effect.timeout(Duration.millis(timeoutMs)),          Effect.catch(() => Effect.succeed("")),        );       const classify = (        response: {          readonly status: number;          readonly headers: Readonly<Record<string, string>>;          readonly text: Effect.Effect<string, unknown>;        },        method: "GET" | "POST",      ): Effect.Effect<McpShapeProbeResult | null> =>        Effect.gen(function* () {          const contentType = readHeader(response.headers, "content-type") ?? "";          const isSse = /^\s*text\/event-stream\b/i.test(contentType);           if (response.status === 401) {            const wwwAuth = readHeader(response.headers, "www-authenticate");            if (!wwwAuth || !/^\s*bearer\b/i.test(wwwAuth)) {              // Spec-non-compliant 401 (no `Bearer` challenge). Before              // giving up, check whether the server still publishes              // RFC 9728 protected-resource metadata for this path —              // some real MCP servers (Datadog) do exactly this.              if (yield* probeProtectedResourceMetadata(client, url, timeoutMs)) {                return { kind: "mcp", requiresAuth: true } as const;              }              return {                kind: "not-mcp",                category: "auth-required",                reason: "401 without Bearer WWW-Authenticate — not an MCP auth challenge",              } as const;            }            // Spec-compliant MCP signal: the auth spec mandates a            // `resource_metadata=` attribute pointing at the server's            // RFC 9728 document. Real OAuth-protected MCP servers            // (sentry.dev, etc.) include it. This attribute is rare on            // unrelated OAuth services and is the cleanest accept signal            // we have when the 401 body is RFC 6750 OAuth-shape rather            // than JSON-RPC.            if (/(?:^|[\s,])resource_metadata\s*=/i.test(wwwAuth)) {              return { kind: "mcp", requiresAuth: true } as const;            }            // Looser RFC 6750 §3.1 signal: the Bearer challenge carries            // `error=` / `error_description=` auth-params. Real MCP            // servers (Supabase, GitHub Copilot, Vercel, Neon, Tavily,            // Replicate, ...) include this even when they omit            // `resource_metadata=`. The body alone isn't enough for            // those — Supabase, e.g., returns `{"message":"Unauthorized"}`            // which is neither JSON-RPC nor RFC 6750. The `error=`            // auth-param is the tiebreaker.            if (/(?:^|[\s,])error\s*=/i.test(wwwAuth)) {              return { kind: "mcp", requiresAuth: true } as const;            }            // SSE responses can't carry a JSON-RPC error envelope; accept the            // Bearer challenge alone in that case (rare but spec-permissible).            if (isSse) return { kind: "mcp", requiresAuth: true } as const;            // Fallback for MCP servers whose 401 omits            // `resource_metadata=`. Two body shapes count:            //   - JSON-RPC error (cubic.dev: API-key auth, JSON-RPC            //     errors end-to-end).            //   - RFC 6750 OAuth Bearer error envelope `{error:            //     "invalid_token", ...}` without GraphQL `{errors:[...]}`            //     (Atlassian).            // Non-MCP OAuth-protected services that issue bare Bearer            // challenges (Railway-style GraphQL, etc.) return `errors`            // arrays or other shapes that fail both checks.            const body = yield* readBody(response);            if (!isJsonRpcEnvelope(body) && !isOAuthErrorBody(body)) {              // Bearer challenge with no usable accept signal. Same              // RFC 9728 fallback as the no-`Bearer` case above.              if (yield* probeProtectedResourceMetadata(client, url, timeoutMs)) {                return { kind: "mcp", requiresAuth: true } as const;              }              return {                kind: "not-mcp",                category: "auth-required",                reason:                  "401 + Bearer without resource_metadata, JSON-RPC body, or OAuth error body",              } as const;            }            return { kind: "mcp", requiresAuth: true } as const;          }           if (response.status >= 200 && response.status < 300) {            if (method === "GET") {              if (!isSse) {                return {                  kind: "not-mcp",                  category: "wrong-shape",                  reason: "GET response is not an SSE stream",                } as const;              }              return { kind: "mcp", requiresAuth: false } as const;            }            // POST 2xx: SSE body is opaque to us; otherwise require a            // JSON-RPC envelope so we don't accept HTML/REST 200 responses.            if (isSse) return { kind: "mcp", requiresAuth: false } as const;            const body = yield* readBody(response);            if (!isJsonRpcEnvelope(body)) {              return {                kind: "not-mcp",                category: "wrong-shape",                reason: "2xx POST body is not a JSON-RPC envelope",              } as const;            }            return { kind: "mcp", requiresAuth: false } as const;          }           return null;        });       const url = new URL(endpoint);      for (const [key, value] of Object.entries(options.queryParams ?? {})) {        url.searchParams.set(key, value);      }       let postRequest = HttpClientRequest.post(url.toString()).pipe(        HttpClientRequest.setHeader("content-type", "application/json"),        HttpClientRequest.setHeader("accept", "application/json, text/event-stream"),        HttpClientRequest.bodyText(INITIALIZE_BODY, "application/json"),      );      for (const [name, value] of Object.entries(options.headers ?? {})) {        postRequest = HttpClientRequest.setHeader(postRequest, name, value);      }       const postResponse = yield* client        .execute(postRequest)        .pipe(Effect.timeout(Duration.millis(timeoutMs)));       const postResult = yield* classify(postResponse, "POST");      if (postResult) return postResult;       if ([404, 405, 406, 415].includes(postResponse.status)) {        let getRequest = HttpClientRequest.get(url.toString()).pipe(          HttpClientRequest.setHeader("accept", "text/event-stream"),        );        for (const [name, value] of Object.entries(options.headers ?? {})) {          getRequest = HttpClientRequest.setHeader(getRequest, name, value);        }        const getResponse = yield* client          .execute(getRequest)          .pipe(Effect.timeout(Duration.millis(timeoutMs)));        const getResult = yield* classify(getResponse, "GET");        if (getResult) return getResult;      }       return {        kind: "not-mcp",        category: "wrong-shape",        reason: `unexpected status ${postResponse.status} for initialize`,      } as const;    }).pipe(      Effect.provide(options.httpClientLayer ?? FetchHttpClient.layer),      Effect.mapError(        (cause) =>          new ProbeTransportError({            reason: reasonFromBoundaryCause(cause),            cause,          }),      ),      Effect.catch((cause) =>        Effect.succeed<McpShapeProbeResult>({          kind: "unreachable",          reason: cause.reason,        }),      ),    );     return outcome;  }).pipe(Effect.withSpan("mcp.plugin.probe_shape"));