@specific.dev/spectest 0.55.0 → 0.56.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/daemon.js +10 -0
- package/dist/harness/raw-fetch.d.ts +7 -0
- package/dist/harness/raw-fetch.js +33 -0
- package/dist/ids.d.ts +9 -0
- package/dist/ids.js +11 -1
- package/dist/index.d.ts +43 -0
- package/dist/index.js +4 -0
- package/dist/mcp-auth.d.ts +176 -0
- package/dist/mcp-auth.js +455 -0
- package/dist/mcp-transport.d.ts +130 -0
- package/dist/mcp-transport.js +337 -0
- package/dist/mcp.d.ts +246 -0
- package/dist/mcp.js +1060 -0
- package/dist/recorder.d.ts +5 -0
- package/package.json +1 -1
- package/src/daemon.ts +10 -0
- package/src/harness/raw-fetch.ts +36 -0
- package/src/ids.ts +12 -1
- package/src/index.ts +69 -0
- package/src/mcp-auth.ts +626 -0
- package/src/mcp-transport.ts +434 -0
- package/src/mcp.test.ts +94 -0
- package/src/mcp.ts +1435 -0
- package/src/recorder.ts +5 -2
|
@@ -0,0 +1,434 @@
|
|
|
1
|
+
// Streamable-HTTP transport for the MCP client (`mcp.ts`).
|
|
2
|
+
//
|
|
3
|
+
// This is a hand-written JSON-RPC client for the MCP streamable-HTTP
|
|
4
|
+
// transport. We do not depend on `@modelcontextprotocol/sdk`, because that
|
|
5
|
+
// package pulls in ~90 transitive packages (express, hono, cors, ajv, zod)
|
|
6
|
+
// and ~25 MB, for its SERVER half. The SDK here ships into every project's
|
|
7
|
+
// VM and is installed on every cold start, so a dependency of that size is
|
|
8
|
+
// paid by projects that never test an MCP server. The client half of the
|
|
9
|
+
// protocol is small, and this file is all of it.
|
|
10
|
+
//
|
|
11
|
+
// What it implements (MCP revision 2025-06-18, backward compatible with
|
|
12
|
+
// 2025-03-26):
|
|
13
|
+
// - POST of one JSON-RPC message, with a reply that is either
|
|
14
|
+
// `application/json` or an `text/event-stream` (SSE) stream.
|
|
15
|
+
// - The `initialize` handshake, the `Mcp-Session-Id` header the server
|
|
16
|
+
// may issue, and the `MCP-Protocol-Version` header that every later
|
|
17
|
+
// request must carry.
|
|
18
|
+
// - Server-to-client requests (sampling, roots) that arrive
|
|
19
|
+
// on the SSE stream of the request that caused them. The answer goes
|
|
20
|
+
// back on its own POST, as the spec requires.
|
|
21
|
+
// - `DELETE` to end the session.
|
|
22
|
+
//
|
|
23
|
+
// What it does NOT implement, deliberately: the standalone GET stream, and
|
|
24
|
+
// resumption by `Last-Event-ID`. Both matter to a long-lived desktop
|
|
25
|
+
// client that must survive a dropped connection. A test makes one request
|
|
26
|
+
// at a time and its VM is snapshotted, not disconnected. Add them when a
|
|
27
|
+
// project needs them, not before.
|
|
28
|
+
//
|
|
29
|
+
// It also does not implement the DEPRECATED HTTP+SSE transport
|
|
30
|
+
// (2024-11-05): a long-lived `GET` stream plus a separate POST endpoint
|
|
31
|
+
// named by its first `endpoint` event. A server that speaks only that one
|
|
32
|
+
// answers our `initialize` POST with a `4xx` and this client gives up
|
|
33
|
+
// there. Adding it is a self-contained change — the fallback trigger is
|
|
34
|
+
// that same `4xx` — but nothing needs it yet.
|
|
35
|
+
//
|
|
36
|
+
// Recording lives one layer up. This file is transport only, so `mcp.ts`
|
|
37
|
+
// can record one step per logical operation instead of one per HTTP call.
|
|
38
|
+
|
|
39
|
+
import { rawFetch } from "./harness/raw-fetch.js";
|
|
40
|
+
|
|
41
|
+
/** A JSON-RPC id. The client only ever mints numbers. */
|
|
42
|
+
export type JsonRpcId = number | string;
|
|
43
|
+
|
|
44
|
+
export interface JsonRpcRequest {
|
|
45
|
+
jsonrpc: "2.0";
|
|
46
|
+
id: JsonRpcId;
|
|
47
|
+
method: string;
|
|
48
|
+
params?: unknown;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface JsonRpcNotification {
|
|
52
|
+
jsonrpc: "2.0";
|
|
53
|
+
method: string;
|
|
54
|
+
params?: unknown;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface JsonRpcError {
|
|
58
|
+
code: number;
|
|
59
|
+
message: string;
|
|
60
|
+
data?: unknown;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface JsonRpcResponse {
|
|
64
|
+
jsonrpc: "2.0";
|
|
65
|
+
id: JsonRpcId;
|
|
66
|
+
result?: unknown;
|
|
67
|
+
error?: JsonRpcError;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
type JsonRpcMessage = JsonRpcRequest | JsonRpcNotification | JsonRpcResponse;
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* The `WWW-Authenticate` challenge an MCP server sends with a `401`.
|
|
74
|
+
*
|
|
75
|
+
* `resourceMetadataUrl` is the entry point of the whole OAuth flow
|
|
76
|
+
* (RFC 9728). A server that omits it forces the client to guess the
|
|
77
|
+
* metadata location from its own URL, which `mcp-auth.ts` does.
|
|
78
|
+
*/
|
|
79
|
+
export interface McpChallenge {
|
|
80
|
+
/** The HTTP status that carried the challenge. */
|
|
81
|
+
status: number;
|
|
82
|
+
/** Almost always `Bearer`. */
|
|
83
|
+
scheme: string;
|
|
84
|
+
/** `resource_metadata` parameter — where the protected-resource
|
|
85
|
+
* metadata lives. */
|
|
86
|
+
resourceMetadataUrl?: string;
|
|
87
|
+
/** `scope` parameter, when the server names what it wants. */
|
|
88
|
+
scope?: string;
|
|
89
|
+
/** `error` parameter, e.g. `invalid_token` for an expired token. */
|
|
90
|
+
error?: string;
|
|
91
|
+
/** The header, verbatim. Recorded so a test can assert on it. */
|
|
92
|
+
raw: string;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** An HTTP-level failure from the MCP endpoint. A `401` carries the
|
|
96
|
+
* parsed {@link McpChallenge}, which is what makes "the server rejected
|
|
97
|
+
* this call" assertable in a test. */
|
|
98
|
+
export class McpHttpError extends Error {
|
|
99
|
+
readonly status: number;
|
|
100
|
+
readonly challenge?: McpChallenge;
|
|
101
|
+
readonly body?: string;
|
|
102
|
+
|
|
103
|
+
constructor(status: number, message: string, challenge?: McpChallenge, body?: string) {
|
|
104
|
+
super(message);
|
|
105
|
+
this.name = "McpHttpError";
|
|
106
|
+
this.status = status;
|
|
107
|
+
this.challenge = challenge;
|
|
108
|
+
this.body = body;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** A JSON-RPC error result. The request reached the server and the server
|
|
113
|
+
* answered with an error object. */
|
|
114
|
+
export class McpRpcError extends Error {
|
|
115
|
+
readonly code: number;
|
|
116
|
+
readonly data?: unknown;
|
|
117
|
+
|
|
118
|
+
constructor(error: JsonRpcError) {
|
|
119
|
+
super(error.message);
|
|
120
|
+
this.name = "McpRpcError";
|
|
121
|
+
this.code = error.code;
|
|
122
|
+
this.data = error.data;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Parse a `WWW-Authenticate` header. Returns `undefined` when there is
|
|
127
|
+
* no header, so a bare `401` still reports a status with no challenge. */
|
|
128
|
+
export function parseChallenge(header: string | null, status: number): McpChallenge | undefined {
|
|
129
|
+
if (!header) return undefined;
|
|
130
|
+
const scheme = header.split(/[\s,]/, 1)[0] ?? "Bearer";
|
|
131
|
+
const params: Record<string, string> = {};
|
|
132
|
+
// `name="value"` pairs, or `name=value` for the unquoted form some
|
|
133
|
+
// servers emit. Values may hold commas, so match on the quotes first.
|
|
134
|
+
const re = /([A-Za-z_-]+)\s*=\s*(?:"([^"]*)"|([^\s,]+))/g;
|
|
135
|
+
for (let m = re.exec(header); m !== null; m = re.exec(header)) {
|
|
136
|
+
params[m[1]!.toLowerCase()] = m[2] ?? m[3] ?? "";
|
|
137
|
+
}
|
|
138
|
+
return {
|
|
139
|
+
status,
|
|
140
|
+
scheme,
|
|
141
|
+
resourceMetadataUrl: params["resource_metadata"],
|
|
142
|
+
scope: params["scope"],
|
|
143
|
+
error: params["error"],
|
|
144
|
+
raw: header,
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** One HTTP exchange, for the step detail panel. Bodies are the parsed
|
|
149
|
+
* JSON-RPC messages, not raw text. */
|
|
150
|
+
export interface HttpExchange {
|
|
151
|
+
method: string;
|
|
152
|
+
url: string;
|
|
153
|
+
status: number;
|
|
154
|
+
durationMs: number;
|
|
155
|
+
sessionId?: string;
|
|
156
|
+
/** Set when the reply was an SSE stream rather than a JSON body. */
|
|
157
|
+
streamed?: boolean;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export interface McpTransportOptions {
|
|
161
|
+
url: string;
|
|
162
|
+
/** Extra headers on every request (a static API key, a tenant id). */
|
|
163
|
+
headers?: Record<string, string>;
|
|
164
|
+
/** Read at call time, so a token minted mid-session applies at once. */
|
|
165
|
+
token?: () => string | undefined;
|
|
166
|
+
/** Default per-request budget. */
|
|
167
|
+
timeoutMs?: number;
|
|
168
|
+
/** Called for every server notification. */
|
|
169
|
+
onNotification?: (n: JsonRpcNotification) => void;
|
|
170
|
+
/** Called for every server-to-client request. Resolve with the result,
|
|
171
|
+
* or throw to answer with a JSON-RPC error. */
|
|
172
|
+
onRequest?: (r: JsonRpcRequest) => Promise<unknown>;
|
|
173
|
+
/** Called after every HTTP exchange, for the recorder. */
|
|
174
|
+
onExchange?: (x: HttpExchange) => void;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
178
|
+
|
|
179
|
+
/** The revision we speak. A server that wants an older one negotiates it
|
|
180
|
+
* in the `initialize` result and we echo whatever it chose. */
|
|
181
|
+
export const PROTOCOL_VERSION = "2025-06-18";
|
|
182
|
+
|
|
183
|
+
export class McpTransport {
|
|
184
|
+
readonly url: string;
|
|
185
|
+
/** Issued by the server on `initialize`, echoed on every later request.
|
|
186
|
+
* Absent for a stateless server, which is legal. */
|
|
187
|
+
sessionId?: string;
|
|
188
|
+
/** Negotiated on `initialize`. Sent on every request after that. */
|
|
189
|
+
protocolVersion?: string;
|
|
190
|
+
|
|
191
|
+
private nextId = 1;
|
|
192
|
+
private readonly opts: McpTransportOptions;
|
|
193
|
+
|
|
194
|
+
constructor(opts: McpTransportOptions) {
|
|
195
|
+
this.opts = opts;
|
|
196
|
+
this.url = opts.url;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** Send a request and resolve with its result. Throws
|
|
200
|
+
* {@link McpHttpError} for a transport failure and {@link McpRpcError}
|
|
201
|
+
* for a JSON-RPC error result. */
|
|
202
|
+
async request<T = unknown>(
|
|
203
|
+
method: string,
|
|
204
|
+
params?: unknown,
|
|
205
|
+
opts?: { timeoutMs?: number },
|
|
206
|
+
): Promise<T> {
|
|
207
|
+
const id = this.nextId++;
|
|
208
|
+
const message: JsonRpcRequest = { jsonrpc: "2.0", id, method, params };
|
|
209
|
+
const res = await this.post(message, opts?.timeoutMs);
|
|
210
|
+
|
|
211
|
+
const response = await this.readResponse(res, id);
|
|
212
|
+
if (response.error) throw new McpRpcError(response.error);
|
|
213
|
+
return response.result as T;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** Send a notification. Nothing comes back. */
|
|
217
|
+
async notify(method: string, params?: unknown, opts?: { timeoutMs?: number }): Promise<void> {
|
|
218
|
+
const message: JsonRpcNotification = { jsonrpc: "2.0", method, params };
|
|
219
|
+
const res = await this.post(message, opts?.timeoutMs);
|
|
220
|
+
// A notification gets `202 Accepted` with no body. Drain anything the
|
|
221
|
+
// server sent anyway so the connection can be reused.
|
|
222
|
+
await res.text().catch(() => "");
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** End the session. Best-effort: a server that does not support
|
|
226
|
+
* `DELETE` answers 405, which is not an error for us. */
|
|
227
|
+
async close(): Promise<void> {
|
|
228
|
+
if (!this.sessionId) return;
|
|
229
|
+
try {
|
|
230
|
+
const res = await rawFetch(this.url, { method: "DELETE", headers: this.headers() });
|
|
231
|
+
await res.text().catch(() => "");
|
|
232
|
+
} catch {
|
|
233
|
+
// The peer is gone. Nothing to end.
|
|
234
|
+
}
|
|
235
|
+
this.sessionId = undefined;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
private headers(extra?: Record<string, string>): Record<string, string> {
|
|
239
|
+
const h: Record<string, string> = {
|
|
240
|
+
accept: "application/json, text/event-stream",
|
|
241
|
+
...this.opts.headers,
|
|
242
|
+
...extra,
|
|
243
|
+
};
|
|
244
|
+
const token = this.opts.token?.();
|
|
245
|
+
if (token) h["authorization"] = `Bearer ${token}`;
|
|
246
|
+
if (this.sessionId) h["mcp-session-id"] = this.sessionId;
|
|
247
|
+
if (this.protocolVersion) h["mcp-protocol-version"] = this.protocolVersion;
|
|
248
|
+
return h;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
private async post(message: JsonRpcMessage, timeoutMs?: number): Promise<Response> {
|
|
252
|
+
const started = Date.now();
|
|
253
|
+
const budget = timeoutMs ?? this.opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
254
|
+
const controller = new AbortController();
|
|
255
|
+
const timer = setTimeout(() => controller.abort(), budget);
|
|
256
|
+
|
|
257
|
+
let res: Response;
|
|
258
|
+
try {
|
|
259
|
+
res = await rawFetch(this.url, {
|
|
260
|
+
method: "POST",
|
|
261
|
+
headers: this.headers({ "content-type": "application/json" }),
|
|
262
|
+
body: JSON.stringify(message),
|
|
263
|
+
signal: controller.signal,
|
|
264
|
+
});
|
|
265
|
+
} catch (err) {
|
|
266
|
+
clearTimeout(timer);
|
|
267
|
+
const method = "method" in message ? message.method : "response";
|
|
268
|
+
if (controller.signal.aborted) {
|
|
269
|
+
throw new Error(`MCP request ${method} to ${this.url} timed out after ${budget} ms`);
|
|
270
|
+
}
|
|
271
|
+
throw err;
|
|
272
|
+
}
|
|
273
|
+
clearTimeout(timer);
|
|
274
|
+
|
|
275
|
+
// The session id is issued on `initialize` and must be echoed from
|
|
276
|
+
// then on. Read it off every reply: a server may rotate it.
|
|
277
|
+
const issued = res.headers.get("mcp-session-id");
|
|
278
|
+
if (issued) this.sessionId = issued;
|
|
279
|
+
|
|
280
|
+
this.opts.onExchange?.({
|
|
281
|
+
method: "POST",
|
|
282
|
+
url: this.url,
|
|
283
|
+
status: res.status,
|
|
284
|
+
durationMs: Date.now() - started,
|
|
285
|
+
sessionId: this.sessionId,
|
|
286
|
+
streamed: (res.headers.get("content-type") ?? "").includes("text/event-stream"),
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
if (!res.ok) {
|
|
290
|
+
const body = await res.text().catch(() => "");
|
|
291
|
+
const challenge = parseChallenge(res.headers.get("www-authenticate"), res.status);
|
|
292
|
+
const method = "method" in message ? message.method : "response";
|
|
293
|
+
throw new McpHttpError(
|
|
294
|
+
res.status,
|
|
295
|
+
`MCP ${method} failed: HTTP ${res.status}${body ? ` — ${truncate(body)}` : ""}`,
|
|
296
|
+
challenge,
|
|
297
|
+
body,
|
|
298
|
+
);
|
|
299
|
+
}
|
|
300
|
+
return res;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/** Read the reply to request `id`. The body is either one JSON-RPC
|
|
304
|
+
* response, or an SSE stream that carries it — possibly after
|
|
305
|
+
* server-to-client traffic that belongs to the same request. */
|
|
306
|
+
private async readResponse(res: Response, id: JsonRpcId): Promise<JsonRpcResponse> {
|
|
307
|
+
const contentType = res.headers.get("content-type") ?? "";
|
|
308
|
+
if (!contentType.includes("text/event-stream")) {
|
|
309
|
+
const text = await res.text();
|
|
310
|
+
const parsed = JSON.parse(text) as JsonRpcMessage | JsonRpcMessage[];
|
|
311
|
+
const messages = Array.isArray(parsed) ? parsed : [parsed];
|
|
312
|
+
for (const m of messages) {
|
|
313
|
+
if (isResponse(m) && m.id === id) return m;
|
|
314
|
+
}
|
|
315
|
+
throw new Error(`MCP reply carried no response for request ${id}`);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
if (!res.body) throw new Error("MCP reply announced an SSE stream but carried no body");
|
|
319
|
+
for await (const event of readSse(res.body)) {
|
|
320
|
+
let message: JsonRpcMessage;
|
|
321
|
+
try {
|
|
322
|
+
message = JSON.parse(event) as JsonRpcMessage;
|
|
323
|
+
} catch {
|
|
324
|
+
// A comment or a keep-alive frame. Not ours to interpret.
|
|
325
|
+
continue;
|
|
326
|
+
}
|
|
327
|
+
if (isResponse(message)) {
|
|
328
|
+
if (message.id === id) return message;
|
|
329
|
+
continue;
|
|
330
|
+
}
|
|
331
|
+
if (isRequest(message)) {
|
|
332
|
+
// Answer on a separate POST, and keep reading. Awaiting the
|
|
333
|
+
// handler here would stall the stream that carries our own reply.
|
|
334
|
+
void this.answer(message);
|
|
335
|
+
continue;
|
|
336
|
+
}
|
|
337
|
+
this.opts.onNotification?.(message);
|
|
338
|
+
}
|
|
339
|
+
throw new Error(`MCP stream ended before the response to request ${id} arrived`);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/** Handle one server-to-client request and post the answer back. */
|
|
343
|
+
private async answer(request: JsonRpcRequest): Promise<void> {
|
|
344
|
+
let response: JsonRpcResponse;
|
|
345
|
+
try {
|
|
346
|
+
if (!this.opts.onRequest) {
|
|
347
|
+
// The server asked for something this client never advertised.
|
|
348
|
+
response = {
|
|
349
|
+
jsonrpc: "2.0",
|
|
350
|
+
id: request.id,
|
|
351
|
+
error: { code: -32601, message: `Method not found: ${request.method}` },
|
|
352
|
+
};
|
|
353
|
+
} else {
|
|
354
|
+
response = { jsonrpc: "2.0", id: request.id, result: await this.opts.onRequest(request) };
|
|
355
|
+
}
|
|
356
|
+
} catch (err) {
|
|
357
|
+
response = {
|
|
358
|
+
jsonrpc: "2.0",
|
|
359
|
+
id: request.id,
|
|
360
|
+
error: { code: -32603, message: (err as Error)?.message ?? String(err) },
|
|
361
|
+
};
|
|
362
|
+
}
|
|
363
|
+
try {
|
|
364
|
+
const res = await this.post(response);
|
|
365
|
+
await res.text().catch(() => "");
|
|
366
|
+
} catch {
|
|
367
|
+
// The server gave up on the request, or the stream is closed. There
|
|
368
|
+
// is nowhere left to report this.
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
function isResponse(m: JsonRpcMessage): m is JsonRpcResponse {
|
|
374
|
+
return "id" in m && !("method" in m);
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
function isRequest(m: JsonRpcMessage): m is JsonRpcRequest {
|
|
378
|
+
return "id" in m && "method" in m;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
function truncate(s: string, max = 300): string {
|
|
382
|
+
return s.length <= max ? s : `${s.slice(0, max)}…`;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* Yield the `data` payload of each SSE event on a byte stream.
|
|
387
|
+
*
|
|
388
|
+
* Frames are separated by a blank line and a frame's data may span
|
|
389
|
+
* several `data:` lines, which are joined with a newline (WHATWG EventSource).
|
|
390
|
+
* `event:` and `id:` are read and dropped: MCP puts the whole JSON-RPC
|
|
391
|
+
* message in `data`, and we do not resume streams.
|
|
392
|
+
*/
|
|
393
|
+
async function* readSse(body: ReadableStream<Uint8Array>): AsyncGenerator<string> {
|
|
394
|
+
const reader = body.getReader();
|
|
395
|
+
const decoder = new TextDecoder();
|
|
396
|
+
let buffer = "";
|
|
397
|
+
try {
|
|
398
|
+
for (;;) {
|
|
399
|
+
const { done, value } = await reader.read();
|
|
400
|
+
if (done) break;
|
|
401
|
+
buffer += decoder.decode(value, { stream: true });
|
|
402
|
+
// Accept both LF and CRLF frame separators.
|
|
403
|
+
for (;;) {
|
|
404
|
+
const match = /\r?\n\r?\n/.exec(buffer);
|
|
405
|
+
if (!match) break;
|
|
406
|
+
const frame = buffer.slice(0, match.index);
|
|
407
|
+
buffer = buffer.slice(match.index + match[0].length);
|
|
408
|
+
const data = sseData(frame);
|
|
409
|
+
if (data !== undefined) yield data;
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
const tail = sseData(buffer);
|
|
413
|
+
if (tail !== undefined) yield tail;
|
|
414
|
+
} finally {
|
|
415
|
+
reader.cancel().catch(() => {});
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/** The `data` of one SSE frame, or `undefined` when it carries none.
|
|
420
|
+
* Exported for tests. */
|
|
421
|
+
export function sseData(frame: string): string | undefined {
|
|
422
|
+
const lines = frame.split(/\r?\n/);
|
|
423
|
+
const data: string[] = [];
|
|
424
|
+
for (const line of lines) {
|
|
425
|
+
if (line.startsWith(":")) continue;
|
|
426
|
+
const colon = line.indexOf(":");
|
|
427
|
+
const field = colon === -1 ? line : line.slice(0, colon);
|
|
428
|
+
if (field !== "data") continue;
|
|
429
|
+
let value = colon === -1 ? "" : line.slice(colon + 1);
|
|
430
|
+
if (value.startsWith(" ")) value = value.slice(1);
|
|
431
|
+
data.push(value);
|
|
432
|
+
}
|
|
433
|
+
return data.length > 0 ? data.join("\n") : undefined;
|
|
434
|
+
}
|
package/src/mcp.test.ts
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import { parseChallenge, sseData } from "./mcp-transport.js";
|
|
4
|
+
import { base64url, canonicalResource, wellKnownUrls } from "./mcp-auth.js";
|
|
5
|
+
|
|
6
|
+
describe("parseChallenge", () => {
|
|
7
|
+
test("reads the resource metadata URL", () => {
|
|
8
|
+
const challenge = parseChallenge(
|
|
9
|
+
'Bearer resource_metadata="https://api.test/.well-known/oauth-protected-resource"',
|
|
10
|
+
401,
|
|
11
|
+
);
|
|
12
|
+
expect(challenge?.scheme).toBe("Bearer");
|
|
13
|
+
expect(challenge?.resourceMetadataUrl).toBe(
|
|
14
|
+
"https://api.test/.well-known/oauth-protected-resource",
|
|
15
|
+
);
|
|
16
|
+
expect(challenge?.status).toBe(401);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test("reads every parameter, quoted or not", () => {
|
|
20
|
+
const challenge = parseChallenge(
|
|
21
|
+
'Bearer realm="mcp", error=invalid_token, scope="a b", resource_metadata="https://x/y"',
|
|
22
|
+
401,
|
|
23
|
+
);
|
|
24
|
+
expect(challenge?.error).toBe("invalid_token");
|
|
25
|
+
expect(challenge?.scope).toBe("a b");
|
|
26
|
+
expect(challenge?.resourceMetadataUrl).toBe("https://x/y");
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("a bare 401 has no challenge", () => {
|
|
30
|
+
expect(parseChallenge(null, 401)).toBeUndefined();
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
describe("wellKnownUrls", () => {
|
|
35
|
+
test("an issuer with no path has one spelling", () => {
|
|
36
|
+
expect(wellKnownUrls("https://api.test", "oauth-authorization-server")).toEqual([
|
|
37
|
+
"https://api.test/.well-known/oauth-authorization-server",
|
|
38
|
+
]);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("a path-carrying issuer inserts the segment first (RFC 8414)", () => {
|
|
42
|
+
// The order matters: RFC 8414 inserts, OpenID Connect appends, and
|
|
43
|
+
// servers differ on which they serve. Supabase serves the inserted
|
|
44
|
+
// form only.
|
|
45
|
+
expect(wellKnownUrls("https://api.test/auth/v1", "oauth-authorization-server")).toEqual([
|
|
46
|
+
"https://api.test/.well-known/oauth-authorization-server/auth/v1",
|
|
47
|
+
"https://api.test/auth/v1/.well-known/oauth-authorization-server",
|
|
48
|
+
"https://api.test/.well-known/oauth-authorization-server",
|
|
49
|
+
]);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("a trailing slash is not a path", () => {
|
|
53
|
+
expect(wellKnownUrls("https://api.test/", "oauth-protected-resource")).toEqual([
|
|
54
|
+
"https://api.test/.well-known/oauth-protected-resource",
|
|
55
|
+
]);
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
describe("canonicalResource", () => {
|
|
60
|
+
test("drops the fragment and lowercases the host", () => {
|
|
61
|
+
expect(canonicalResource("https://API.Test/mcp#x")).toBe("https://api.test/mcp");
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
describe("sseData", () => {
|
|
66
|
+
test("reads one data line", () => {
|
|
67
|
+
expect(sseData('data: {"jsonrpc":"2.0"}')).toBe('{"jsonrpc":"2.0"}');
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("joins several data lines with a newline", () => {
|
|
71
|
+
expect(sseData("data: a\ndata: b")).toBe("a\nb");
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("ignores the other fields and comments", () => {
|
|
75
|
+
expect(sseData("event: message\nid: 7\n: keep-alive\ndata: x")).toBe("x");
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("a frame with no data yields nothing", () => {
|
|
79
|
+
expect(sseData("event: ping")).toBeUndefined();
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("only the first space after the colon is stripped", () => {
|
|
83
|
+
expect(sseData("data: padded")).toBe(" padded");
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
describe("base64url", () => {
|
|
88
|
+
test("has no padding and no unsafe characters", () => {
|
|
89
|
+
const encoded = base64url(new Uint8Array([251, 255, 190, 1]));
|
|
90
|
+
expect(encoded).not.toContain("=");
|
|
91
|
+
expect(encoded).not.toContain("+");
|
|
92
|
+
expect(encoded).not.toContain("/");
|
|
93
|
+
});
|
|
94
|
+
});
|