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
8 init?: Parameters<typeof fetch>[1],8 init?: Parameters<typeof fetch>[1],
9) => Promise<Response>;9) => Promise<Response>;
10 10
11interface FetchFailure {
12 readonly failure: unknown;
13}
14
11const asFetch = (stub: FetchStub): typeof fetch => stub as typeof fetch;15const asFetch = (stub: FetchStub): typeof fetch => stub as typeof fetch;
12 16
13/**17/**
14 * Build a `fetch`-compatible stub that returns the given `Response` (or18 * Build a `fetch`-compatible stub that returns the given `Response` (or
15 * throws the given error) regardless of input. `fetch`'s exact signature19 * rejects with the given failure) regardless of input. `fetch`'s exact signature
16 * is a union; a narrow closure is enough for the probe.20 * is a union; a narrow closure is enough for the probe.
17 */21 */
18const stubFetch = (result: Response | Error): typeof fetch =>22const stubFetch = (result: Response | FetchFailure): typeof fetch =>
19 asFetch(async (_input, _init) => {23 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 }
21 return result;28 return result;
22 });29 });
23 30
@@ -25,7 +32,10 @@ const stubFetchSequence = (results: readonly Response[]): typeof fetch => { · 1 unmodified line
25 let index = 0;32 let index = 0;
26 return asFetch(async (_input, _init) => {33 return asFetch(async (_input, _init) => {
27 const result = results[index++];34 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 }
29 return result;39 return result;
30 });40 });
31};41};
@@ -140,7 +150,7 @@ describe("probeMcpEndpointShape", () => { · 108 unmodified lines
140 it.effect("reports transport failure as unreachable", () =>150 it.effect("reports transport failure as unreachable", () =>
141 Effect.gen(function* () {151 Effect.gen(function* () {
142 const result = yield* probeMcpEndpointShape("https://missing/", {152 const result = yield* probeMcpEndpointShape("https://missing/", {
143 fetch: stubFetch(new TypeError("fetch failed")),153 fetch: stubFetch({ failure: { message: "fetch failed" } }),
144 });154 });
145 expect(result.kind).toBe("unreachable");155 expect(result.kind).toBe("unreachable");
146 }),156 }),
packages/plugins/mcp/src/sdk/probe-shape.ts+85−63
@@ -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
33import { Effect } from "effect";33import { Data, Effect, Option, Schema } from "effect";
34 34
35/** MCP initialize request body used as the shape probe. Any real MCP35/** 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
65class ProbeTransportError extends Data.TaggedError("ProbeTransportError")<{
66 readonly reason: string;
67 readonly cause: unknown;
68}> {}
69
70const ErrorMessageShape = Schema.Struct({ message: Schema.String });
71const decodeErrorMessageShape = Schema.decodeUnknownOption(ErrorMessageShape);
72
73const 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
65export type McpShapeProbeResult =86export type McpShapeProbeResult =
66 /** Server answered initialize successfully — either a 2xx with a87 /** Server answered initialize successfully — either a 2xx with a
67 * JSON-RPC payload, or a 401 + WWW-Authenticate: Bearer (RFC 675088 * 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 );