toolcraft 0.0.113 → 0.0.115

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