toolcraft 0.0.112 → 0.0.114

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.
Files changed (27) hide show
  1. package/composition.json +2 -2
  2. package/dist/composition.json +2 -2
  3. package/node_modules/tiny-mcp-client/package.json +3 -0
  4. package/node_modules/toolcraft-schema/package.json +1 -1
  5. package/package.json +2 -2
  6. package/node_modules/tiny-mcp-client/.turbo/turbo-build.log +0 -7
  7. package/node_modules/tiny-mcp-client/scripts/build.mjs +0 -63
  8. package/node_modules/tiny-mcp-client/src/http-oauth.integration.test.ts +0 -808
  9. package/node_modules/tiny-mcp-client/src/http-oauth.test.ts +0 -922
  10. package/node_modules/tiny-mcp-client/src/index.ts +0 -94
  11. package/node_modules/tiny-mcp-client/src/internal.ts +0 -3904
  12. package/node_modules/tiny-mcp-client/src/jsonrpc-types.compile-check.ts +0 -66
  13. package/node_modules/tiny-mcp-client/src/mcp-client-http-transport.integration.test.ts +0 -222
  14. package/node_modules/tiny-mcp-client/src/mcp-client-sdk.test.ts +0 -1326
  15. package/node_modules/tiny-mcp-client/src/mcp-client-tiny-stdio-test-server-tools.test.ts +0 -143
  16. package/node_modules/tiny-mcp-client/src/mcp-lifecycle-types.compile-check.ts +0 -65
  17. package/node_modules/tiny-mcp-client/src/mcp-prompt-types.compile-check.ts +0 -66
  18. package/node_modules/tiny-mcp-client/src/mcp-resource-types.compile-check.ts +0 -70
  19. package/node_modules/tiny-mcp-client/src/mcp-tool-types.compile-check.ts +0 -127
  20. package/node_modules/tiny-mcp-client/src/mcp-transport-types.compile-check.ts +0 -75
  21. package/node_modules/tiny-mcp-client/src/mcp-utility-types.compile-check.ts +0 -181
  22. package/node_modules/tiny-mcp-client/src/mock-servers.test.ts +0 -980
  23. package/node_modules/tiny-mcp-client/src/oauth-discovery.ts +0 -578
  24. package/node_modules/tiny-mcp-client/src/package-runtime.test.ts +0 -25
  25. package/node_modules/tiny-mcp-client/src/transports.test.ts +0 -9018
  26. package/node_modules/tiny-mcp-client/src/utilities.test.ts +0 -372
  27. package/node_modules/tiny-mcp-client/tsconfig.json +0 -11
@@ -1,808 +0,0 @@
1
- import http from "node:http";
2
- import { afterEach, describe, expect, it } from "vitest";
3
- import {
4
- OAuthError,
5
- type OAuthSessionStore,
6
- type StoredOAuthSession,
7
- } from "mcp-oauth";
8
- import { nodeFetch } from "tiny-http-mcp-server/testing";
9
- import {
10
- createMcpOAuthTestServer,
11
- type McpOAuthTestServerHandle,
12
- type McpOAuthTestServerOptions,
13
- } from "tiny-http-mcp-oauth-test-server";
14
- import {
15
- HttpTransport,
16
- McpClient,
17
- resolveAuthorizationServerMetadataUrl,
18
- } from "./internal.js";
19
-
20
- interface RequestRecord {
21
- url: string;
22
- method: string;
23
- authorization: string | null;
24
- sessionId: string | null;
25
- body: string | undefined;
26
- }
27
-
28
- interface SessionStoreWithMap {
29
- sessions: Map<string, StoredOAuthSession>;
30
- store: OAuthSessionStore;
31
- }
32
-
33
- interface OAuthClientHarness {
34
- client: McpClient;
35
- close(): Promise<void>;
36
- handle: McpOAuthTestServerHandle;
37
- requests: RequestRecord[];
38
- sessionStore: SessionStoreWithMap;
39
- setNow(value: number): void;
40
- transport: HttpTransport;
41
- }
42
-
43
- interface TrafficSummary {
44
- authorize: number;
45
- asMetadata: number;
46
- mcpPost: number;
47
- prm: number;
48
- register: number;
49
- tokenAuthorizationCode: number;
50
- tokenRefresh: number;
51
- }
52
-
53
- interface AuthorizationServerTrafficSummary {
54
- authorize: number;
55
- metadata: number;
56
- register: number;
57
- tokenAuthorizationCode: number;
58
- tokenRefresh: number;
59
- }
60
-
61
- function createSessionStore(): SessionStoreWithMap {
62
- const sessions = new Map<string, StoredOAuthSession>();
63
-
64
- return {
65
- sessions,
66
- store: {
67
- async load(resource: string): Promise<StoredOAuthSession | null> {
68
- return sessions.get(resource) ?? null;
69
- },
70
- async save(resource: string, session: StoredOAuthSession): Promise<void> {
71
- sessions.set(resource, session);
72
- },
73
- async clear(resource: string): Promise<void> {
74
- sessions.delete(resource);
75
- },
76
- },
77
- };
78
- }
79
-
80
- function cloneResponse(response: Response, body: string): Response {
81
- const headers = new Headers(response.headers);
82
- return new Response(body, {
83
- status: response.status,
84
- statusText: response.statusText,
85
- headers,
86
- });
87
- }
88
-
89
- interface BoundLoopbackServerFactory {
90
- createServer(): http.Server;
91
- port: number;
92
- }
93
-
94
- async function createBoundLoopbackServerFactory(hostname: string): Promise<BoundLoopbackServerFactory> {
95
- const server = http.createServer();
96
-
97
- await new Promise<void>((resolve, reject) => {
98
- server.once("error", reject);
99
- server.listen(0, hostname, () => resolve());
100
- });
101
-
102
- const address = server.address();
103
- if (address === null || typeof address === "string") {
104
- await new Promise<void>((resolve, reject) => {
105
- server.close((error) => {
106
- if (error !== undefined) {
107
- reject(error);
108
- return;
109
- }
110
-
111
- resolve();
112
- });
113
- });
114
- throw new Error("Expected loopback test server to bind to a TCP port");
115
- }
116
-
117
- server.listen = ((...args: Parameters<http.Server["listen"]>) => {
118
- const callback = [...args].reverse().find((value) => typeof value === "function");
119
- if (typeof callback === "function") {
120
- queueMicrotask(() => callback());
121
- }
122
-
123
- return server;
124
- }) as typeof server.listen;
125
-
126
- return {
127
- createServer(): http.Server {
128
- return server;
129
- },
130
- port: address.port,
131
- };
132
- }
133
-
134
- function parseJsonBody(record: RequestRecord): Record<string, unknown> {
135
- if (record.body === undefined) {
136
- throw new Error(`Expected JSON body for ${record.method} ${record.url}`);
137
- }
138
-
139
- return JSON.parse(record.body) as Record<string, unknown>;
140
- }
141
-
142
- function parseFormBody(record: RequestRecord): URLSearchParams {
143
- return new URLSearchParams(record.body ?? "");
144
- }
145
-
146
- function requireRequest(
147
- request: RequestRecord | undefined,
148
- description: string
149
- ): RequestRecord {
150
- if (request === undefined) {
151
- throw new Error(`Expected ${description}`);
152
- }
153
-
154
- return request;
155
- }
156
-
157
- function findLastRequest(
158
- requests: readonly RequestRecord[],
159
- predicate: (request: RequestRecord) => boolean,
160
- description: string
161
- ): RequestRecord {
162
- for (let index = requests.length - 1; index >= 0; index -= 1) {
163
- const request = requests[index];
164
- if (request !== undefined && predicate(request)) {
165
- return request;
166
- }
167
- }
168
-
169
- throw new Error(`Expected ${description}`);
170
- }
171
-
172
- function summarizeTraffic(
173
- requests: readonly RequestRecord[],
174
- handle: McpOAuthTestServerHandle
175
- ): TrafficSummary {
176
- const authorizationServerMetadataUrl = resolveAuthorizationServerMetadataUrl(handle.oauth.issuer);
177
- const authorizeUrl = `${handle.oauth.issuer}/authorize`;
178
- const registerUrl = `${handle.oauth.issuer}/register`;
179
- const tokenUrl = `${handle.oauth.issuer}/token`;
180
-
181
- return {
182
- prm: requests.filter(
183
- (request) => request.method === "GET" && request.url === handle.prmUrl
184
- ).length,
185
- asMetadata: requests.filter(
186
- (request) =>
187
- request.method === "GET"
188
- && request.url === authorizationServerMetadataUrl.toString()
189
- ).length,
190
- register: requests.filter(
191
- (request) => request.method === "POST" && request.url === registerUrl
192
- ).length,
193
- authorize: requests.filter(
194
- (request) => request.method === "GET" && request.url.startsWith(authorizeUrl)
195
- ).length,
196
- tokenAuthorizationCode: requests.filter((request) => {
197
- if (request.method !== "POST" || request.url !== tokenUrl) {
198
- return false;
199
- }
200
-
201
- return parseFormBody(request).get("grant_type") === "authorization_code";
202
- }).length,
203
- tokenRefresh: requests.filter((request) => {
204
- if (request.method !== "POST" || request.url !== tokenUrl) {
205
- return false;
206
- }
207
-
208
- return parseFormBody(request).get("grant_type") === "refresh_token";
209
- }).length,
210
- mcpPost: requests.filter(
211
- (request) => request.method === "POST" && request.url === handle.mcpUrl
212
- ).length,
213
- };
214
- }
215
-
216
- function summarizeAuthorizationServerTraffic(
217
- handle: McpOAuthTestServerHandle
218
- ): AuthorizationServerTrafficSummary {
219
- const requestLog = handle.oauth.requestLog;
220
- const authorizationServerMetadataUrl = resolveAuthorizationServerMetadataUrl(handle.oauth.issuer);
221
- const authorizeUrl = `${handle.oauth.issuer}/authorize`;
222
- const registerUrl = `${handle.oauth.issuer}/register`;
223
- const tokenUrl = `${handle.oauth.issuer}/token`;
224
-
225
- return {
226
- metadata: requestLog.filter(
227
- (request) =>
228
- request.method === "GET"
229
- && request.url === authorizationServerMetadataUrl.toString()
230
- ).length,
231
- register: requestLog.filter(
232
- (request) => request.method === "POST" && request.url === registerUrl
233
- ).length,
234
- authorize: requestLog.filter(
235
- (request) => request.method === "GET" && request.url.startsWith(authorizeUrl)
236
- ).length,
237
- tokenAuthorizationCode: requestLog.filter((request) => {
238
- if (request.method !== "POST" || request.url !== tokenUrl) {
239
- return false;
240
- }
241
-
242
- return new URLSearchParams(request.body ?? "").get("grant_type") === "authorization_code";
243
- }).length,
244
- tokenRefresh: requestLog.filter((request) => {
245
- if (request.method !== "POST" || request.url !== tokenUrl) {
246
- return false;
247
- }
248
-
249
- return new URLSearchParams(request.body ?? "").get("grant_type") === "refresh_token";
250
- }).length,
251
- };
252
- }
253
-
254
- function getJsonRpcMethod(record: RequestRecord): string | undefined {
255
- if (record.body === undefined) {
256
- return undefined;
257
- }
258
-
259
- try {
260
- const parsed = JSON.parse(record.body) as { method?: unknown };
261
- return typeof parsed.method === "string" ? parsed.method : undefined;
262
- } catch {
263
- return undefined;
264
- }
265
- }
266
-
267
- function getTextContent(result: unknown): string | undefined {
268
- if (
269
- typeof result !== "object"
270
- || result === null
271
- || !("content" in result)
272
- || !Array.isArray(result.content)
273
- ) {
274
- return undefined;
275
- }
276
-
277
- const [firstItem] = result.content;
278
- if (
279
- typeof firstItem !== "object"
280
- || firstItem === null
281
- || !("text" in firstItem)
282
- || typeof firstItem.text !== "string"
283
- ) {
284
- return undefined;
285
- }
286
-
287
- return firstItem.text;
288
- }
289
-
290
- function getStoredSession(harness: OAuthClientHarness): StoredOAuthSession {
291
- const session = harness.sessionStore.sessions.get(harness.handle.mcpUrl);
292
- if (session === undefined) {
293
- throw new Error("Expected OAuth session to be stored");
294
- }
295
-
296
- return session;
297
- }
298
-
299
- async function createHarness(options: {
300
- now?: () => number;
301
- oauthClient?:
302
- | {
303
- mode: "dynamic";
304
- metadata?: {
305
- clientName?: string;
306
- scope?: string;
307
- };
308
- }
309
- | {
310
- mode: "static";
311
- clientId: string;
312
- clientSecret?: string;
313
- metadata?: {
314
- clientName?: string;
315
- scope?: string;
316
- };
317
- };
318
- responseTransform?: (input: {
319
- handle: McpOAuthTestServerHandle;
320
- record: RequestRecord;
321
- response: Response;
322
- }) => Promise<Response | undefined>;
323
- serverOptions?: McpOAuthTestServerOptions;
324
- createServer?: () => http.Server;
325
- } = {}): Promise<OAuthClientHarness> {
326
- const server = createMcpOAuthTestServer({
327
- autoApprove: true,
328
- scopes: ["mcp.read"],
329
- ...(options.serverOptions ?? {}),
330
- });
331
- const handle = await server.listen({
332
- port: 0,
333
- hostname: "127.0.0.1",
334
- });
335
- const requests: RequestRecord[] = [];
336
- const sessionStore = createSessionStore();
337
- let currentNow = options.now?.() ?? 10_000;
338
- const fetchImpl = async (input: string | URL, init: RequestInit = {}): Promise<Response> => {
339
- const record: RequestRecord = {
340
- url: input.toString(),
341
- method: init.method ?? "GET",
342
- authorization: new Headers(init.headers).get("authorization"),
343
- sessionId: new Headers(init.headers).get("mcp-session-id"),
344
- body: typeof init.body === "string" ? init.body : undefined,
345
- };
346
- const response = await nodeFetch(record.url, init);
347
- const transformed = options.responseTransform === undefined
348
- ? undefined
349
- : await options.responseTransform({
350
- handle,
351
- record,
352
- response,
353
- });
354
-
355
- requests.push(record);
356
- return transformed ?? response;
357
- };
358
- const client = new McpClient({
359
- clientInfo: {
360
- name: "tiny-mcp-client-http-oauth-integration-test",
361
- version: "1.0.0",
362
- },
363
- });
364
- const transport = new HttpTransport({
365
- url: handle.mcpUrl,
366
- fetch: fetchImpl,
367
- oauth: {
368
- client: options.oauthClient ?? {
369
- mode: "dynamic",
370
- metadata: {
371
- clientName: "tiny-mcp-client integration test",
372
- },
373
- },
374
- browser: {
375
- async openBrowser(authorizationUrl) {
376
- const authorizationResponse = await fetchImpl(authorizationUrl, {
377
- method: "GET",
378
- });
379
- if (authorizationResponse.status !== 302) {
380
- throw new Error(
381
- `Expected authorization redirect, received ${authorizationResponse.status}: ${await authorizationResponse.text()}`
382
- );
383
- }
384
-
385
- const callbackUrl = authorizationResponse.headers.get("location");
386
- expect(callbackUrl).toBeTruthy();
387
-
388
- const callbackResponse = await fetchImpl(callbackUrl ?? "", {
389
- method: "GET",
390
- });
391
- expect(callbackResponse.ok).toBe(true);
392
- await callbackResponse.text();
393
- },
394
- ...(options.createServer === undefined
395
- ? {}
396
- : {
397
- createServer: options.createServer,
398
- }),
399
- },
400
- now: () => currentNow,
401
- sessionStore: sessionStore.store,
402
- },
403
- });
404
-
405
- return {
406
- client,
407
- close: async () => {
408
- await client.close().catch(() => undefined);
409
- await handle.close();
410
- },
411
- handle,
412
- requests,
413
- sessionStore,
414
- setNow(value: number): void {
415
- currentNow = value;
416
- },
417
- transport,
418
- };
419
- }
420
-
421
- describe("HttpTransport OAuth integration", () => {
422
- const cleanups = new Set<() => Promise<void>>();
423
-
424
- afterEach(async () => {
425
- for (const cleanup of [...cleanups].reverse()) {
426
- await cleanup();
427
- }
428
-
429
- cleanups.clear();
430
- });
431
-
432
- it("runs discovery, DCR, PKCE authorization, attaches the bearer, and reuses the cached token", async () => {
433
- const harness = await createHarness();
434
- cleanups.add(harness.close);
435
-
436
- await harness.client.connect(harness.transport);
437
-
438
- const firstResult = await harness.client.callTool({
439
- name: "echo",
440
- arguments: {
441
- text: "first-call",
442
- },
443
- });
444
-
445
- expect(getTextContent(firstResult)).toBe("first-call");
446
-
447
- const summaryAfterFirstCall = summarizeTraffic(harness.requests, harness.handle);
448
- expect(summaryAfterFirstCall).toEqual({
449
- authorize: 1,
450
- asMetadata: 1,
451
- mcpPost: 4,
452
- prm: 1,
453
- register: 1,
454
- tokenAuthorizationCode: 1,
455
- tokenRefresh: 0,
456
- });
457
- expect(summarizeAuthorizationServerTraffic(harness.handle)).toEqual({
458
- authorize: 1,
459
- metadata: 1,
460
- register: 1,
461
- tokenAuthorizationCode: 1,
462
- tokenRefresh: 0,
463
- });
464
-
465
- const initializePosts = harness.requests.filter(
466
- (request) =>
467
- request.method === "POST"
468
- && request.url === harness.handle.mcpUrl
469
- && getJsonRpcMethod(request) === "initialize"
470
- );
471
- expect(initializePosts).toHaveLength(2);
472
- expect(initializePosts[0]?.authorization).toBeNull();
473
- expect(initializePosts[1]?.authorization).toMatch(/^Bearer /);
474
-
475
- const callToolPosts = harness.requests.filter(
476
- (request) =>
477
- request.method === "POST"
478
- && request.url === harness.handle.mcpUrl
479
- && getJsonRpcMethod(request) === "tools/call"
480
- );
481
- expect(callToolPosts).toHaveLength(1);
482
- expect(callToolPosts[0]?.authorization).toMatch(/^Bearer /);
483
-
484
- const registrationRequest = harness.requests.find(
485
- (request) => request.method === "POST" && request.url === `${harness.handle.oauth.issuer}/register`
486
- );
487
- expect(parseJsonBody(requireRequest(registrationRequest, "registration request"))).toMatchObject({
488
- client_name: "tiny-mcp-client integration test",
489
- grant_types: ["authorization_code", "refresh_token"],
490
- response_types: ["code"],
491
- token_endpoint_auth_method: "none",
492
- });
493
-
494
- const authorizationRequest = harness.requests.find(
495
- (request) =>
496
- request.method === "GET"
497
- && request.url.startsWith(`${harness.handle.oauth.issuer}/authorize`)
498
- );
499
- expect(new URL(requireRequest(authorizationRequest, "authorization request").url).searchParams.get("resource")).toBe(
500
- harness.handle.mcpUrl
501
- );
502
-
503
- const tokenRequest = harness.requests.find((request) => {
504
- if (
505
- request.method !== "POST"
506
- || request.url !== `${harness.handle.oauth.issuer}/token`
507
- ) {
508
- return false;
509
- }
510
-
511
- return parseFormBody(request).get("grant_type") === "authorization_code";
512
- });
513
- expect(parseFormBody(requireRequest(tokenRequest, "authorization-code token request")).get("resource")).toBe(
514
- harness.handle.mcpUrl
515
- );
516
-
517
- const secondCallBaseline = summarizeTraffic(harness.requests, harness.handle);
518
- const secondResult = await harness.client.callTool({
519
- name: "echo",
520
- arguments: {
521
- text: "second-call",
522
- },
523
- });
524
-
525
- expect(getTextContent(secondResult)).toBe("second-call");
526
-
527
- const summaryAfterSecondCall = summarizeTraffic(harness.requests, harness.handle);
528
- const authorizationServerSummaryAfterSecondCall = summarizeAuthorizationServerTraffic(
529
- harness.handle
530
- );
531
- expect(summaryAfterSecondCall.authorize - secondCallBaseline.authorize).toBe(0);
532
- expect(summaryAfterSecondCall.tokenAuthorizationCode - secondCallBaseline.tokenAuthorizationCode).toBe(0);
533
- expect(summaryAfterSecondCall.tokenRefresh - secondCallBaseline.tokenRefresh).toBe(0);
534
- expect(summaryAfterSecondCall.mcpPost - secondCallBaseline.mcpPost).toBe(1);
535
- expect(authorizationServerSummaryAfterSecondCall).toEqual({
536
- authorize: 1,
537
- metadata: 1,
538
- register: 1,
539
- tokenAuthorizationCode: 1,
540
- tokenRefresh: 0,
541
- });
542
- });
543
-
544
- it("skips DCR for a configured static client and still completes the PKCE flow", async () => {
545
- const loopbackServer = await createBoundLoopbackServerFactory("127.0.0.1");
546
- const redirectUri = `http://127.0.0.1:${loopbackServer.port}/callback`;
547
- const harness = await createHarness({
548
- createServer: loopbackServer.createServer,
549
- oauthClient: {
550
- mode: "static",
551
- clientId: "static-client",
552
- },
553
- serverOptions: {
554
- staticClients: [
555
- {
556
- clientId: "static-client",
557
- redirectUris: [redirectUri],
558
- scopes: ["mcp.read"],
559
- },
560
- ],
561
- },
562
- });
563
- cleanups.add(harness.close);
564
-
565
- await harness.client.connect(harness.transport);
566
-
567
- const result = await harness.client.callTool({
568
- name: "echo",
569
- arguments: {
570
- text: "static-client",
571
- },
572
- });
573
-
574
- expect(getTextContent(result)).toBe("static-client");
575
-
576
- const summary = summarizeTraffic(harness.requests, harness.handle);
577
- const authorizationServerSummary = summarizeAuthorizationServerTraffic(
578
- harness.handle
579
- );
580
- expect(summary.register).toBe(0);
581
- expect(summary.authorize).toBe(1);
582
- expect(summary.tokenAuthorizationCode).toBe(1);
583
- expect(authorizationServerSummary).toEqual({
584
- authorize: 1,
585
- metadata: 1,
586
- register: 0,
587
- tokenAuthorizationCode: 1,
588
- tokenRefresh: 0,
589
- });
590
-
591
- const authorizationRequest = harness.requests.find(
592
- (request) =>
593
- request.method === "GET"
594
- && request.url.startsWith(`${harness.handle.oauth.issuer}/authorize`)
595
- );
596
- expect(new URL(requireRequest(authorizationRequest, "authorization request").url).searchParams.get("client_id")).toBe(
597
- "static-client"
598
- );
599
-
600
- const tokenRequest = harness.requests.find((request) => {
601
- if (
602
- request.method !== "POST"
603
- || request.url !== `${harness.handle.oauth.issuer}/token`
604
- ) {
605
- return false;
606
- }
607
-
608
- return parseFormBody(request).get("grant_type") === "authorization_code";
609
- });
610
- expect(parseFormBody(requireRequest(tokenRequest, "authorization-code token request")).get("client_id")).toBe(
611
- "static-client"
612
- );
613
- });
614
-
615
- it("refreshes once after the current access token is revoked and the MCP server returns invalid_token", async () => {
616
- const harness = await createHarness();
617
- cleanups.add(harness.close);
618
-
619
- await harness.client.connect(harness.transport);
620
- await harness.client.callTool({
621
- name: "echo",
622
- arguments: {
623
- text: "before-revoke",
624
- },
625
- });
626
-
627
- const initialSession = getStoredSession(harness);
628
- expect(initialSession.tokens?.accessToken).toBeTruthy();
629
- const revokedAccessToken = initialSession.tokens?.accessToken ?? "";
630
- harness.handle.oauth.revoke(revokedAccessToken);
631
-
632
- const baseline = summarizeTraffic(harness.requests, harness.handle);
633
- const authorizationServerBaseline = summarizeAuthorizationServerTraffic(
634
- harness.handle
635
- );
636
- const refreshedResult = await harness.client.callTool({
637
- name: "echo",
638
- arguments: {
639
- text: "after-revoke",
640
- },
641
- });
642
-
643
- expect(getTextContent(refreshedResult)).toBe("after-revoke");
644
-
645
- const summary = summarizeTraffic(harness.requests, harness.handle);
646
- const authorizationServerSummary = summarizeAuthorizationServerTraffic(
647
- harness.handle
648
- );
649
- expect(summary.authorize - baseline.authorize).toBe(0);
650
- expect(summary.tokenRefresh - baseline.tokenRefresh).toBe(1);
651
- expect(summary.mcpPost - baseline.mcpPost).toBe(2);
652
- expect(
653
- authorizationServerSummary.tokenRefresh - authorizationServerBaseline.tokenRefresh
654
- ).toBe(1);
655
-
656
- const refreshRequest = findLastRequest(harness.requests, (request) => {
657
- if (
658
- request.method !== "POST"
659
- || request.url !== `${harness.handle.oauth.issuer}/token`
660
- ) {
661
- return false;
662
- }
663
-
664
- return parseFormBody(request).get("grant_type") === "refresh_token";
665
- }, "refresh token request");
666
- expect(parseFormBody(refreshRequest).get("resource")).toBe(
667
- harness.handle.mcpUrl
668
- );
669
-
670
- const updatedSession = getStoredSession(harness);
671
- expect(updatedSession.tokens?.accessToken).toBeTruthy();
672
- expect(updatedSession.tokens?.accessToken).not.toBe(revokedAccessToken);
673
- });
674
-
675
- it("deduplicates concurrent refreshes when multiple calls race on an expired token", async () => {
676
- const harness = await createHarness();
677
- cleanups.add(harness.close);
678
-
679
- await harness.client.connect(harness.transport);
680
- await harness.client.callTool({
681
- name: "echo",
682
- arguments: {
683
- text: "seed-token",
684
- },
685
- });
686
-
687
- harness.setNow(80_000);
688
- const baseline = summarizeTraffic(harness.requests, harness.handle);
689
- const authorizationServerBaseline = summarizeAuthorizationServerTraffic(
690
- harness.handle
691
- );
692
- const results = await Promise.all(
693
- Array.from({ length: 5 }, (_, index) =>
694
- harness.client.callTool({
695
- name: "echo",
696
- arguments: {
697
- text: `parallel-${index}`,
698
- },
699
- })
700
- )
701
- );
702
-
703
- expect(results.map(getTextContent)).toEqual([
704
- "parallel-0",
705
- "parallel-1",
706
- "parallel-2",
707
- "parallel-3",
708
- "parallel-4",
709
- ]);
710
-
711
- const summary = summarizeTraffic(harness.requests, harness.handle);
712
- const authorizationServerSummary = summarizeAuthorizationServerTraffic(
713
- harness.handle
714
- );
715
- expect(summary.authorize - baseline.authorize).toBe(0);
716
- expect(summary.tokenRefresh - baseline.tokenRefresh).toBe(1);
717
- expect(summary.mcpPost - baseline.mcpPost).toBe(5);
718
- expect(
719
- authorizationServerSummary.tokenRefresh - authorizationServerBaseline.tokenRefresh
720
- ).toBe(1);
721
- });
722
-
723
- it("maps verifier audience mismatches to a typed OAuthError instead of a generic transport error", async () => {
724
- const harness = await createHarness({
725
- responseTransform: async ({ handle, record, response }) => {
726
- if (
727
- record.method !== "POST"
728
- || record.url !== `${handle.oauth.issuer}/token`
729
- || parseFormBody(record).get("grant_type") !== "authorization_code"
730
- ) {
731
- return undefined;
732
- }
733
-
734
- const payload = await response.clone().json() as Record<string, unknown>;
735
- const wrongAudienceToken = await handle.oauth.issueTokenFor({
736
- clientId: parseFormBody(record).get("client_id") ?? "unknown-client",
737
- resource: `${handle.mcpUrl}/wrong-audience`,
738
- scopes: ["mcp.read"],
739
- });
740
-
741
- payload.access_token = wrongAudienceToken;
742
- delete payload.refresh_token;
743
-
744
- return cloneResponse(response, JSON.stringify(payload));
745
- },
746
- });
747
- cleanups.add(harness.close);
748
-
749
- let caughtError: unknown;
750
-
751
- try {
752
- await harness.client.connect(harness.transport);
753
- } catch (error) {
754
- caughtError = error;
755
- }
756
-
757
- expect(caughtError).toBeInstanceOf(OAuthError);
758
- expect(caughtError).toMatchObject({
759
- error: "invalid_token",
760
- errorDescription: "audience mismatch",
761
- status: 401,
762
- });
763
- expect((caughtError as Error).message).toBe("audience mismatch");
764
- });
765
-
766
- it("maps insufficient_scope bearer challenges to a typed OAuthError", async () => {
767
- const harness = await createHarness({
768
- responseTransform: async ({ handle, record, response }) => {
769
- if (
770
- record.method !== "POST"
771
- || record.url !== `${handle.oauth.issuer}/token`
772
- || parseFormBody(record).get("grant_type") !== "authorization_code"
773
- ) {
774
- return undefined;
775
- }
776
-
777
- const payload = await response.clone().json() as Record<string, unknown>;
778
- const insufficientScopeToken = await handle.oauth.issueTokenFor({
779
- clientId: parseFormBody(record).get("client_id") ?? "unknown-client",
780
- resource: handle.mcpUrl,
781
- scopes: ["mcp.write"],
782
- });
783
-
784
- payload.access_token = insufficientScopeToken;
785
- delete payload.refresh_token;
786
-
787
- return cloneResponse(response, JSON.stringify(payload));
788
- },
789
- });
790
- cleanups.add(harness.close);
791
-
792
- let caughtError: unknown;
793
-
794
- try {
795
- await harness.client.connect(harness.transport);
796
- } catch (error) {
797
- caughtError = error;
798
- }
799
-
800
- expect(caughtError).toBeInstanceOf(OAuthError);
801
- expect(caughtError).toMatchObject({
802
- error: "insufficient_scope",
803
- errorDescription: "insufficient scope",
804
- status: 403,
805
- });
806
- expect((caughtError as Error).message).toBe("insufficient scope");
807
- });
808
- });