History

Clean MCP probe shape boundaries

Rhys Sullivan committed 6 months ago

Commit 868d4d41 parent b039b6e2 files · +100 −68Browse files

Unified · Split

2 files
  1. packages/plugins/mcp/src/sdk/probe-shape.test.ts +15 −5
  2. packages/plugins/mcp/src/sdk/probe-shape.ts +85 −63
packages/plugins/mcp/src/sdk/probe-shape.test.ts+15−5
@@ -8,16 +8,23 @@ type FetchStub = ( · 7 unmodified lines
88 init?: Parameters<typeof fetch>[1],
99 ) => Promise<Response>;
1010
11+interface FetchFailure {
12+ readonly failure: unknown;
13+}
14+
1115 const asFetch = (stub: FetchStub): typeof fetch => stub as typeof fetch;
1216
1317 /**
1418 * Build a `fetch`-compatible stub that returns the given `Response` (or
15− * throws the given error) regardless of input. `fetch`'s exact signature
19+ * rejects with the given failure) regardless of input. `fetch`'s exact signature
1620 * is a union; a narrow closure is enough for the probe.
1721 */
18−const stubFetch = (result: Response | Error): typeof fetch =>
22+const stubFetch = (result: Response | FetchFailure): typeof fetch =>
1923 asFetch(async (_input, _init) => {
20− if (result instanceof Error) throw result;
24+ if ("failure" in result) {
25+ // oxlint-disable-next-line promise/prefer-await-to-then, executor/no-promise-reject -- boundary: fetch-compatible test stub must reject like fetch
26+ return Promise.reject(result.failure);
27+ }
2128 return result;
2229 });
2330
@@ -25,7 +32,10 @@ const stubFetchSequence = (results: readonly Response[]): typeof fetch => { · 1 unmodified line
2532 let index = 0;
2633 return asFetch(async (_input, _init) => {
2734 const result = results[index++];
28− if (!result) throw new Error("unexpected fetch");
35+ if (!result) {
36+ // oxlint-disable-next-line promise/prefer-await-to-then, executor/no-promise-reject -- boundary: fetch-compatible test stub must reject like fetch
37+ return Promise.reject({ message: "unexpected fetch" });
38+ }
2939 return result;
3040 });
3141 };
@@ -140,7 +150,7 @@ describe("probeMcpEndpointShape", () => { · 108 unmodified lines
140150 it.effect("reports transport failure as unreachable", () =>
141151 Effect.gen(function* () {
142152 const result = yield* probeMcpEndpointShape("https://missing/", {
143− fetch: stubFetch(new TypeError("fetch failed")),
153+ fetch: stubFetch({ failure: { message: "fetch failed" } }),
144154 });
145155 expect(result.kind).toBe("unreachable");
146156 }),
packages/plugins/mcp/src/sdk/probe-shape.ts+85−63
@@ -30,7 +30,7 @@ · 29 unmodified lines
3030 // round-trip, no DCR — every non-MCP endpoint exits here.
3131 // ---------------------------------------------------------------------------
3232
33−import { Effect } from "effect";
33+import { Data, Effect, Option, Schema } from "effect";
3434
3535 /** MCP initialize request body used as the shape probe. Any real MCP
3636 * 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
6262 return null;
6363 };
6464
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+
6586 export type McpShapeProbeResult =
6687 /** Server answered initialize successfully — either a 2xx with a
6788 * JSON-RPC payload, or a 401 + WWW-Authenticate: Bearer (RFC 6750
@@ -99,85 +120,86 @@ export const probeMcpEndpointShape = ( · 31 unmodified lines
99120 Effect.gen(function* () {
100121 const fetchImpl = options.fetch ?? globalThis.fetch;
101122 const timeoutMs = options.timeoutMs ?? 8_000;
123+ const controller = new AbortController();
124+ const timer = setTimeout(() => controller.abort(), timeoutMs);
102125
103126 const outcome = yield* Effect.tryPromise({
104127 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;
119133 }
134+ return {
135+ kind: "not-mcp",
136+ reason:
137+ "401 without Bearer WWW-Authenticate — not an MCP auth challenge",
138+ } as const;
139+ }
120140
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;
130149 }
131− return { kind: "mcp", requiresAuth: false } as const;
132150 }
151+ return { kind: "mcp", requiresAuth: false } as const;
152+ }
133153
134− return null;
135− };
154+ return null;
155+ };
136156
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 ?? {};
153162
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;
166176
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;
173185 }
186+
187+ return {
188+ kind: "not-mcp",
189+ reason: `unexpected status ${postResponse.status} for initialize`,
190+ };
174191 },
175− catch: (cause) => cause,
192+ catch: (cause) =>
193+ new ProbeTransportError({
194+ reason: reasonFromBoundaryCause(cause),
195+ cause,
196+ }),
176197 }).pipe(
198+ Effect.ensuring(Effect.sync(() => clearTimeout(timer))),
177199 Effect.catch((cause) =>
178200 Effect.succeed<McpShapeProbeResult>({
179201 kind: "unreachable",
180− reason: cause instanceof Error ? cause.message : String(cause),
202+ reason: cause.reason,
181203 }),
182204 ),
183205 );