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,922 +0,0 @@
1
- import { describe, expect, it, vi } from "vitest";
2
- import { installInMemoryHttp, nodeFetch } from "tiny-http-mcp-server/test-support";
3
- import type { OAuthSessionStore, StoredOAuthSession } from "mcp-oauth";
4
- import {
5
- HttpTransport,
6
- type OAuthDiscoveryCache,
7
- type OAuthDiscoveryResult,
8
- parseBearerWwwAuthenticateHeader,
9
- resolveAuthorizationServerMetadataUrl
10
- } from "./internal.js";
11
- import { OAuthMetadataDiscovery, discoverOAuthMetadata } from "./oauth-discovery.js";
12
-
13
- installInMemoryHttp();
14
-
15
- function jsonResponse(body: unknown, init?: ResponseInit): Response {
16
- return new Response(JSON.stringify(body), {
17
- status: 200,
18
- headers: {
19
- "Content-Type": "application/json"
20
- },
21
- ...init
22
- });
23
- }
24
-
25
- describe("discoverOAuthMetadata", () => {
26
- it("resolves protected resource and authorization server metadata, then reuses injected cache", async () => {
27
- const resourceUrl = "https://resource.example.com/tenant/mcp";
28
- const resourceMetadataUrl =
29
- "https://resource.example.com/.well-known/oauth-protected-resource/tenant/mcp";
30
- const failingAuthorizationServerMetadataUrl =
31
- "https://auth.example.com/.well-known/oauth-authorization-server/issuer-a";
32
- const successfulAuthorizationServerMetadataUrl =
33
- "https://auth.example.com/.well-known/oauth-authorization-server/issuer-b";
34
-
35
- const sharedCacheStore = new Map<string, OAuthDiscoveryResult>();
36
- const cache: OAuthDiscoveryCache = {
37
- get: vi.fn(async (key: string) => sharedCacheStore.get(key)),
38
- set: vi.fn(async (key: string, value: OAuthDiscoveryResult) => {
39
- sharedCacheStore.set(key, value);
40
- })
41
- };
42
-
43
- const fetchMock = vi.fn(async (input: string | URL): Promise<Response> => {
44
- const url = input.toString();
45
-
46
- if (url === resourceMetadataUrl) {
47
- return jsonResponse({
48
- resource: resourceUrl,
49
- authorization_servers: [
50
- "https://auth.example.com/issuer-a",
51
- "https://auth.example.com/issuer-b"
52
- ]
53
- });
54
- }
55
-
56
- if (url === failingAuthorizationServerMetadataUrl) {
57
- return new Response("no metadata here", {
58
- status: 404,
59
- statusText: "Not Found"
60
- });
61
- }
62
-
63
- if (url === successfulAuthorizationServerMetadataUrl) {
64
- return jsonResponse({
65
- issuer: "https://auth.example.com/issuer-b",
66
- authorization_endpoint: "https://auth.example.com/issuer-b/authorize",
67
- token_endpoint: "https://auth.example.com/issuer-b/token",
68
- response_types_supported: ["code"],
69
- code_challenge_methods_supported: ["plain", "S256"]
70
- });
71
- }
72
-
73
- throw new Error(`Unexpected fetch URL: ${url}`);
74
- });
75
-
76
- const discoveryClient = new OAuthMetadataDiscovery({
77
- fetch: fetchMock,
78
- cache
79
- });
80
- const firstDiscovery = await discoveryClient.discover(resourceUrl);
81
-
82
- expect(firstDiscovery).toMatchObject({
83
- resource: resourceUrl,
84
- resourceMetadataUrl,
85
- authorizationServerMetadataUrl: successfulAuthorizationServerMetadataUrl,
86
- resourceMetadata: {
87
- authorization_servers: [
88
- "https://auth.example.com/issuer-a",
89
- "https://auth.example.com/issuer-b"
90
- ]
91
- },
92
- authorizationServerMetadata: {
93
- issuer: "https://auth.example.com/issuer-b"
94
- }
95
- });
96
- expect(fetchMock.mock.calls.map(([input]) => input.toString())).toEqual([
97
- resourceMetadataUrl,
98
- failingAuthorizationServerMetadataUrl,
99
- successfulAuthorizationServerMetadataUrl
100
- ]);
101
- expect(cache.set).toHaveBeenCalledWith(resourceUrl, firstDiscovery);
102
-
103
- const secondDiscovery = await discoveryClient.discover(resourceUrl);
104
-
105
- expect(secondDiscovery).toEqual(firstDiscovery);
106
- expect(fetchMock).toHaveBeenCalledTimes(3);
107
- expect(cache.get).toHaveBeenCalledWith(resourceUrl);
108
-
109
- const secondFetch = vi.fn(async (): Promise<Response> => {
110
- throw new Error("Expected cached discovery result");
111
- });
112
- const secondDiscoveryClient = new OAuthMetadataDiscovery({
113
- fetch: secondFetch,
114
- cache
115
- });
116
- const cachedDiscovery = await secondDiscoveryClient.discover(resourceUrl);
117
-
118
- expect(cachedDiscovery).toEqual(firstDiscovery);
119
- expect(secondFetch).not.toHaveBeenCalled();
120
- });
121
-
122
- it("rejects invalid protected resource metadata with a clear error", async () => {
123
- const resourceUrl = "https://resource.example.com/tenant/mcp";
124
-
125
- const fetchMock = vi.fn(
126
- async (): Promise<Response> =>
127
- jsonResponse({
128
- resource: resourceUrl
129
- })
130
- );
131
-
132
- await expect(discoverOAuthMetadata(resourceUrl, { fetch: fetchMock })).rejects.toThrow(
133
- "Protected resource metadata must include a non-empty authorization_servers array"
134
- );
135
- });
136
-
137
- it("rejects authorization server metadata without S256 support with a clear error", async () => {
138
- const resourceUrl = "https://resource.example.com/tenant/mcp";
139
-
140
- const fetchMock = vi.fn(async (input: string | URL): Promise<Response> => {
141
- const url = input.toString();
142
-
143
- if (url.includes("oauth-protected-resource")) {
144
- return jsonResponse({
145
- resource: resourceUrl,
146
- authorization_servers: ["https://auth.example.com/issuer-a"]
147
- });
148
- }
149
-
150
- return jsonResponse({
151
- issuer: "https://auth.example.com/issuer-a",
152
- authorization_endpoint: "https://auth.example.com/issuer-a/authorize",
153
- token_endpoint: "https://auth.example.com/issuer-a/token",
154
- response_types_supported: ["code"],
155
- code_challenge_methods_supported: ["plain"]
156
- });
157
- });
158
-
159
- await expect(discoverOAuthMetadata(resourceUrl, { fetch: fetchMock })).rejects.toThrow(
160
- "code_challenge_methods_supported containing S256"
161
- );
162
- });
163
-
164
- it("normalizes a trailing slash on the authorization server issuer before RFC 8414 lookup", async () => {
165
- const resourceUrl = "https://resource.example.com/tenant/mcp";
166
- const normalizedAuthorizationServer = "https://auth.example.com/issuer-a";
167
- const authorizationServerMetadataUrl =
168
- "https://auth.example.com/.well-known/oauth-authorization-server/issuer-a";
169
-
170
- const fetchMock = vi.fn(async (input: string | URL): Promise<Response> => {
171
- const url = input.toString();
172
-
173
- if (url.includes("oauth-protected-resource")) {
174
- return jsonResponse({
175
- resource: resourceUrl,
176
- authorization_servers: [`${normalizedAuthorizationServer}/`]
177
- });
178
- }
179
-
180
- if (url === authorizationServerMetadataUrl) {
181
- return jsonResponse({
182
- issuer: normalizedAuthorizationServer,
183
- authorization_endpoint: `${normalizedAuthorizationServer}/authorize`,
184
- token_endpoint: `${normalizedAuthorizationServer}/token`,
185
- response_types_supported: ["code"],
186
- code_challenge_methods_supported: ["S256"]
187
- });
188
- }
189
-
190
- throw new Error(`Unexpected fetch URL: ${url}`);
191
- });
192
-
193
- const discovery = await discoverOAuthMetadata(resourceUrl, { fetch: fetchMock });
194
-
195
- expect(discovery.authorizationServer).toBe(normalizedAuthorizationServer);
196
- expect(discovery.authorizationServerMetadataUrl).toBe(authorizationServerMetadataUrl);
197
- expect(fetchMock.mock.calls.map(([input]) => input.toString())).toEqual([
198
- "https://resource.example.com/.well-known/oauth-protected-resource/tenant/mcp",
199
- authorizationServerMetadataUrl
200
- ]);
201
- });
202
-
203
- it("preserves unknown metadata fields while enforcing the RFC 8414 required field set", async () => {
204
- const resourceUrl = "https://resource.example.com/tenant/mcp";
205
- const resourceMetadataUrl =
206
- "https://resource.example.com/.well-known/oauth-protected-resource/tenant/mcp";
207
- const authorizationServer = "https://auth.example.com/issuer-a";
208
- const authorizationServerMetadataUrl =
209
- "https://auth.example.com/.well-known/oauth-authorization-server/issuer-a";
210
-
211
- const discovery = await discoverOAuthMetadata(resourceUrl, {
212
- fetch: vi.fn(async (input: string | URL): Promise<Response> => {
213
- const url = input.toString();
214
-
215
- if (url === resourceMetadataUrl) {
216
- return jsonResponse({
217
- resource: resourceUrl,
218
- authorization_servers: [authorizationServer],
219
- resource_name: "Example MCP"
220
- });
221
- }
222
-
223
- if (url === authorizationServerMetadataUrl) {
224
- return jsonResponse({
225
- issuer: authorizationServer,
226
- authorization_endpoint: `${authorizationServer}/authorize`,
227
- token_endpoint: `${authorizationServer}/token`,
228
- response_types_supported: ["code"],
229
- code_challenge_methods_supported: ["S256"],
230
- service_documentation: `${authorizationServer}/docs`
231
- });
232
- }
233
-
234
- throw new Error(`Unexpected fetch URL: ${url}`);
235
- })
236
- });
237
-
238
- expect(discovery.resourceMetadata.resource_name).toBe("Example MCP");
239
- expect(discovery.authorizationServerMetadata.service_documentation).toBe(
240
- `${authorizationServer}/docs`
241
- );
242
- });
243
-
244
- it("rejects authorization server metadata when response_types_supported is missing", async () => {
245
- const resourceUrl = "https://resource.example.com/tenant/mcp";
246
-
247
- await expect(
248
- discoverOAuthMetadata(resourceUrl, {
249
- fetch: vi.fn(async (input: string | URL): Promise<Response> => {
250
- const url = input.toString();
251
-
252
- if (url.includes("oauth-protected-resource")) {
253
- return jsonResponse({
254
- resource: resourceUrl,
255
- authorization_servers: ["https://auth.example.com/issuer-a"]
256
- });
257
- }
258
-
259
- return jsonResponse({
260
- issuer: "https://auth.example.com/issuer-a",
261
- authorization_endpoint: "https://auth.example.com/issuer-a/authorize",
262
- token_endpoint: "https://auth.example.com/issuer-a/token",
263
- code_challenge_methods_supported: ["S256"]
264
- });
265
- })
266
- })
267
- ).rejects.toThrow("response_types_supported");
268
- });
269
-
270
- it("rejects non-loopback http issuers before attempting RFC 8414 discovery", async () => {
271
- const resourceUrl = "https://resource.example.com/tenant/mcp";
272
- const fetchMock = vi.fn(async (input: string | URL): Promise<Response> => {
273
- const url = input.toString();
274
-
275
- if (url.includes("oauth-protected-resource")) {
276
- return jsonResponse({
277
- resource: resourceUrl,
278
- authorization_servers: ["http://auth.example.com/issuer-a"]
279
- });
280
- }
281
-
282
- throw new Error(`Unexpected fetch URL: ${url}`);
283
- });
284
-
285
- await expect(discoverOAuthMetadata(resourceUrl, { fetch: fetchMock })).rejects.toThrow(
286
- "must use https unless it targets a loopback host"
287
- );
288
- expect(fetchMock.mock.calls.map(([input]) => input.toString())).toEqual([
289
- "https://resource.example.com/.well-known/oauth-protected-resource/tenant/mcp"
290
- ]);
291
- });
292
-
293
- it("rejects non-loopback http protected resources before attempting PRM discovery", async () => {
294
- const fetchMock = vi.fn(async (): Promise<Response> => {
295
- throw new Error("Expected secure URL validation to fail before fetch");
296
- });
297
-
298
- await expect(
299
- discoverOAuthMetadata("http://resource.example.com/tenant/mcp", { fetch: fetchMock })
300
- ).rejects.toThrow("Protected resource URL must use https unless it targets a loopback host");
301
- expect(fetchMock).not.toHaveBeenCalled();
302
- });
303
-
304
- it("rejects authorization server issuers that include query or fragment components", async () => {
305
- const resourceUrl = "https://resource.example.com/tenant/mcp";
306
-
307
- await expect(
308
- discoverOAuthMetadata(resourceUrl, {
309
- fetch: vi.fn(async (input: string | URL): Promise<Response> => {
310
- const url = input.toString();
311
-
312
- if (url.includes("oauth-protected-resource")) {
313
- return jsonResponse({
314
- resource: resourceUrl,
315
- authorization_servers: ["https://auth.example.com/issuer-a?tenant=acme"]
316
- });
317
- }
318
-
319
- throw new Error(`Unexpected fetch URL: ${url}`);
320
- })
321
- })
322
- ).rejects.toThrow("Authorization server issuer must not include query or fragment");
323
- });
324
-
325
- it("allows loopback http protected resources and authorization servers for local testing", async () => {
326
- const resourceUrl = "http://127.0.0.1:43123/tenant/mcp";
327
- const resourceMetadataUrl =
328
- "http://127.0.0.1:43123/.well-known/oauth-protected-resource/tenant/mcp";
329
- const authorizationServer = "http://127.0.0.1:43124/issuer-a";
330
- const authorizationServerMetadataUrl =
331
- "http://127.0.0.1:43124/.well-known/oauth-authorization-server/issuer-a";
332
-
333
- const discovery = await discoverOAuthMetadata(resourceUrl, {
334
- fetch: vi.fn(async (input: string | URL): Promise<Response> => {
335
- const url = input.toString();
336
-
337
- if (url === resourceMetadataUrl) {
338
- return jsonResponse({
339
- resource: resourceUrl,
340
- authorization_servers: [authorizationServer]
341
- });
342
- }
343
-
344
- if (url === authorizationServerMetadataUrl) {
345
- return jsonResponse({
346
- issuer: authorizationServer,
347
- authorization_endpoint: `${authorizationServer}/authorize`,
348
- token_endpoint: `${authorizationServer}/token`,
349
- response_types_supported: ["code"],
350
- code_challenge_methods_supported: ["S256"]
351
- });
352
- }
353
-
354
- throw new Error(`Unexpected fetch URL: ${url}`);
355
- })
356
- });
357
-
358
- expect(discovery.authorizationServerMetadataUrl).toBe(authorizationServerMetadataUrl);
359
- });
360
-
361
- it("honors a fresh resource_metadata hint even when discovery for the request URL is already cached", async () => {
362
- const resourceUrl = "https://resource.example.com/tenant/mcp";
363
- const originalResourceMetadataUrl =
364
- "https://resource.example.com/.well-known/oauth-protected-resource/tenant/mcp";
365
- const hintedResourceMetadataUrl = "https://resource.example.com/metadata/rotated";
366
- const originalAuthorizationServer = "https://auth.example.com/issuer-a";
367
- const hintedAuthorizationServer = "https://auth.example.com/issuer-b";
368
- const originalAuthorizationServerMetadataUrl =
369
- "https://auth.example.com/.well-known/oauth-authorization-server/issuer-a";
370
- const hintedAuthorizationServerMetadataUrl =
371
- "https://auth.example.com/.well-known/oauth-authorization-server/issuer-b";
372
-
373
- const fetchMock = vi.fn(async (input: string | URL): Promise<Response> => {
374
- const url = input.toString();
375
-
376
- if (url === originalResourceMetadataUrl) {
377
- return jsonResponse({
378
- resource: resourceUrl,
379
- authorization_servers: [originalAuthorizationServer]
380
- });
381
- }
382
-
383
- if (url === hintedResourceMetadataUrl) {
384
- return jsonResponse({
385
- resource: resourceUrl,
386
- authorization_servers: [hintedAuthorizationServer]
387
- });
388
- }
389
-
390
- if (url === originalAuthorizationServerMetadataUrl) {
391
- return jsonResponse({
392
- issuer: originalAuthorizationServer,
393
- authorization_endpoint: `${originalAuthorizationServer}/authorize`,
394
- token_endpoint: `${originalAuthorizationServer}/token`,
395
- response_types_supported: ["code"],
396
- code_challenge_methods_supported: ["S256"]
397
- });
398
- }
399
-
400
- if (url === hintedAuthorizationServerMetadataUrl) {
401
- return jsonResponse({
402
- issuer: hintedAuthorizationServer,
403
- authorization_endpoint: `${hintedAuthorizationServer}/authorize`,
404
- token_endpoint: `${hintedAuthorizationServer}/token`,
405
- response_types_supported: ["code"],
406
- code_challenge_methods_supported: ["S256"]
407
- });
408
- }
409
-
410
- throw new Error(`Unexpected fetch URL: ${url}`);
411
- });
412
-
413
- const discoveryClient = new OAuthMetadataDiscovery({
414
- fetch: fetchMock
415
- });
416
- const originalDiscovery = await discoveryClient.discover(resourceUrl);
417
- const hintedDiscovery = await discoveryClient.discover(resourceUrl, {
418
- resourceMetadataUrl: hintedResourceMetadataUrl
419
- });
420
-
421
- expect(originalDiscovery.resourceMetadataUrl).toBe(originalResourceMetadataUrl);
422
- expect(originalDiscovery.authorizationServer).toBe(originalAuthorizationServer);
423
- expect(hintedDiscovery.resourceMetadataUrl).toBe(hintedResourceMetadataUrl);
424
- expect(hintedDiscovery.authorizationServer).toBe(hintedAuthorizationServer);
425
- expect(fetchMock.mock.calls.map(([input]) => input.toString())).toEqual([
426
- originalResourceMetadataUrl,
427
- originalAuthorizationServerMetadataUrl,
428
- hintedResourceMetadataUrl,
429
- hintedAuthorizationServerMetadataUrl
430
- ]);
431
- });
432
-
433
- it("refetches an explicitly repeated resource_metadata hint", async () => {
434
- const resourceUrl = "https://resource.example.com/tenant/mcp";
435
- const resourceMetadataUrl =
436
- "https://resource.example.com/.well-known/oauth-protected-resource/tenant/mcp";
437
- const firstIssuer = "https://auth.example.com/issuer-a";
438
- const secondIssuer = "https://auth.example.com/issuer-b";
439
- let rotated = false;
440
- const fetchMock = vi.fn(async (input: string | URL): Promise<Response> => {
441
- const url = input.toString();
442
- if (url === resourceMetadataUrl) {
443
- return jsonResponse({
444
- resource: resourceUrl,
445
- authorization_servers: [rotated ? secondIssuer : firstIssuer]
446
- });
447
- }
448
- const issuer = url.includes("issuer-a") ? firstIssuer : secondIssuer;
449
- return jsonResponse({
450
- issuer,
451
- authorization_endpoint: `${issuer}/authorize`,
452
- token_endpoint: `${issuer}/token`,
453
- response_types_supported: ["code"],
454
- code_challenge_methods_supported: ["S256"]
455
- });
456
- });
457
- const discoveryClient = new OAuthMetadataDiscovery({ fetch: fetchMock });
458
-
459
- expect((await discoveryClient.discover(resourceUrl)).authorizationServer).toBe(firstIssuer);
460
- rotated = true;
461
- expect(
462
- (await discoveryClient.discover(resourceUrl, { resourceMetadataUrl })).authorizationServer
463
- ).toBe(secondIssuer);
464
- expect(fetchMock.mock.calls.map(([input]) => input.toString())).toEqual([
465
- resourceMetadataUrl,
466
- "https://auth.example.com/.well-known/oauth-authorization-server/issuer-a",
467
- resourceMetadataUrl,
468
- "https://auth.example.com/.well-known/oauth-authorization-server/issuer-b"
469
- ]);
470
- });
471
- });
472
-
473
- describe("parseBearerWwwAuthenticateHeader", () => {
474
- it("selects the Bearer challenge from a combined header and preserves quoted commas", () => {
475
- expect(
476
- parseBearerWwwAuthenticateHeader(
477
- 'Basic realm="legacy", Bearer realm="Example, Inc", resource_metadata="https://resource.example.com/.well-known/oauth-protected-resource/mcp", error="invalid_token"'
478
- )
479
- ).toEqual({
480
- scheme: "Bearer",
481
- params: {
482
- realm: "Example, Inc",
483
- resource_metadata: "https://resource.example.com/.well-known/oauth-protected-resource/mcp",
484
- error: "invalid_token"
485
- },
486
- raw: 'Basic realm="legacy", Bearer realm="Example, Inc", resource_metadata="https://resource.example.com/.well-known/oauth-protected-resource/mcp", error="invalid_token"'
487
- });
488
- });
489
-
490
- it("ignores token68 data and keeps parsing later Bearer auth-params", () => {
491
- expect(
492
- parseBearerWwwAuthenticateHeader(
493
- 'Digest abc123==, Bearer abc123==, Bearer realm="mcp", resource_metadata="https://resource.example.com/.well-known/oauth-protected-resource/mcp"'
494
- )
495
- ).toEqual({
496
- scheme: "Bearer",
497
- params: {
498
- realm: "mcp",
499
- resource_metadata: "https://resource.example.com/.well-known/oauth-protected-resource/mcp"
500
- },
501
- raw: 'Digest abc123==, Bearer abc123==, Bearer realm="mcp", resource_metadata="https://resource.example.com/.well-known/oauth-protected-resource/mcp"'
502
- });
503
- });
504
-
505
- it("preserves a __proto__ auth parameter as parsed data", () => {
506
- const challenge = parseBearerWwwAuthenticateHeader('Bearer __proto__="visible"');
507
-
508
- expect(Object.hasOwn(challenge!.params, "__proto__")).toBe(true);
509
- expect(challenge?.params.__proto__).toBe("visible");
510
- });
511
- });
512
-
513
- describe("resolveAuthorizationServerMetadataUrl", () => {
514
- it("uses the host-based well-known location for root issuers and the path-based form for pathful issuers", () => {
515
- expect(resolveAuthorizationServerMetadataUrl("https://auth.example.com")).toBe(
516
- "https://auth.example.com/.well-known/oauth-authorization-server"
517
- );
518
- expect(resolveAuthorizationServerMetadataUrl("https://auth.example.com/issuer-a/")).toBe(
519
- "https://auth.example.com/.well-known/oauth-authorization-server/issuer-a"
520
- );
521
- });
522
- });
523
-
524
- describe("HttpTransport OAuth authorization", () => {
525
- it("discovers, registers, authorizes, retries once, and reuses the cached token on the next request", async () => {
526
- const requestUrl = "https://resource.example.com/tenant/mcp";
527
- const resourceMetadataUrl =
528
- "https://resource.example.com/.well-known/oauth-protected-resource/tenant/mcp";
529
- const authorizationServer = "https://auth.example.com/issuer-a";
530
- const authorizationServerMetadataUrl =
531
- "https://auth.example.com/.well-known/oauth-authorization-server/issuer-a";
532
- const authorizationEndpoint = "https://auth.example.com/issuer-a/authorize";
533
- const tokenEndpoint = "https://auth.example.com/issuer-a/token";
534
- const registrationEndpoint = "https://auth.example.com/issuer-a/register";
535
-
536
- const resourceAuthorizations: Array<string | null> = [];
537
- const authorizationRequests: URL[] = [];
538
- const registrationBodies: Array<Record<string, unknown>> = [];
539
- const tokenBodies: URLSearchParams[] = [];
540
- const registeredClientIds = new Set<string>();
541
- const storedSessions = new Map<string, StoredOAuthSession>();
542
- const authorizationCodes = new Map<
543
- string,
544
- { clientId: string; redirectUri: string; codeChallenge: string }
545
- >();
546
- const issuedAccessTokens = new Set<string>();
547
- let nextClientId = 0;
548
- let nextAuthorizationCode = 0;
549
- let nextAccessToken = 0;
550
-
551
- const issueAccessToken = (): string => {
552
- nextAccessToken += 1;
553
- const accessToken = `access-${nextAccessToken}`;
554
- issuedAccessTokens.add(accessToken);
555
- return accessToken;
556
- };
557
-
558
- const fetchMock = vi.fn(async (input: string | URL, init?: RequestInit): Promise<Response> => {
559
- const method = init?.method ?? "GET";
560
- const url = input.toString();
561
-
562
- if (url === resourceMetadataUrl) {
563
- return jsonResponse({
564
- resource: requestUrl,
565
- authorization_servers: [authorizationServer]
566
- });
567
- }
568
-
569
- if (url === authorizationServerMetadataUrl) {
570
- return jsonResponse({
571
- issuer: authorizationServer,
572
- authorization_endpoint: authorizationEndpoint,
573
- token_endpoint: tokenEndpoint,
574
- registration_endpoint: registrationEndpoint,
575
- response_types_supported: ["code"],
576
- code_challenge_methods_supported: ["S256"]
577
- });
578
- }
579
-
580
- if (url === registrationEndpoint) {
581
- nextClientId += 1;
582
- const clientId = `client-${nextClientId}`;
583
- registeredClientIds.add(clientId);
584
- registrationBodies.push(JSON.parse(String(init?.body)) as Record<string, unknown>);
585
-
586
- return jsonResponse(
587
- {
588
- client_id: clientId,
589
- token_endpoint_auth_method: "none"
590
- },
591
- { status: 201 }
592
- );
593
- }
594
-
595
- if (url === tokenEndpoint) {
596
- const body = new URLSearchParams(String(init?.body ?? ""));
597
- tokenBodies.push(body);
598
-
599
- const code = body.get("code");
600
- if (code === null) {
601
- throw new Error("Missing authorization code");
602
- }
603
-
604
- const authorizationCode = authorizationCodes.get(code);
605
- if (authorizationCode === undefined) {
606
- throw new Error(`Unknown authorization code: ${code}`);
607
- }
608
-
609
- authorizationCodes.delete(code);
610
- expect(body.get("grant_type")).toBe("authorization_code");
611
- expect(body.get("client_id")).toBe(authorizationCode.clientId);
612
- expect(body.get("redirect_uri")).toBe(authorizationCode.redirectUri);
613
- expect(body.get("resource")).toBe(requestUrl);
614
-
615
- return jsonResponse({
616
- access_token: issueAccessToken(),
617
- token_type: "Bearer",
618
- expires_in: 3600,
619
- refresh_token: "refresh-1"
620
- });
621
- }
622
-
623
- if (url === requestUrl && method === "POST") {
624
- const authorization = new Headers(init?.headers).get("Authorization");
625
- resourceAuthorizations.push(authorization);
626
-
627
- if (authorization === null) {
628
- return new Response(null, {
629
- status: 401,
630
- headers: {
631
- "WWW-Authenticate":
632
- `Bearer realm="Example, Inc", error="invalid_token", ` +
633
- `resource_metadata="${resourceMetadataUrl}"`
634
- }
635
- });
636
- }
637
-
638
- const accessToken = authorization.startsWith("Bearer ")
639
- ? authorization.slice("Bearer ".length)
640
- : authorization;
641
- if (!issuedAccessTokens.has(accessToken)) {
642
- throw new Error(`Unexpected bearer token: ${authorization}`);
643
- }
644
-
645
- return jsonResponse({
646
- jsonrpc: "2.0",
647
- id: resourceAuthorizations.length,
648
- result: {
649
- ok: true
650
- }
651
- });
652
- }
653
-
654
- throw new Error(`Unexpected fetch URL: ${method} ${url}`);
655
- });
656
-
657
- const openBrowser = vi.fn(async (authorizationUrl: string) => {
658
- const url = new URL(authorizationUrl);
659
- authorizationRequests.push(url);
660
-
661
- const clientId = url.searchParams.get("client_id");
662
- if (clientId === null || !registeredClientIds.has(clientId)) {
663
- throw new Error(`Unknown client ID: ${clientId}`);
664
- }
665
-
666
- nextAuthorizationCode += 1;
667
- const code = `code-${nextAuthorizationCode}`;
668
- authorizationCodes.set(code, {
669
- clientId,
670
- redirectUri: url.searchParams.get("redirect_uri") ?? "",
671
- codeChallenge: url.searchParams.get("code_challenge") ?? ""
672
- });
673
-
674
- expect(url.searchParams.get("resource")).toBe(requestUrl);
675
-
676
- const callbackUrl = new URL(url.searchParams.get("redirect_uri") ?? "");
677
- callbackUrl.searchParams.set("code", code);
678
- callbackUrl.searchParams.set("state", url.searchParams.get("state") ?? "");
679
- await requestLoopbackCallback(callbackUrl.toString());
680
- });
681
-
682
- const sessionStore: OAuthSessionStore = {
683
- async load(resource: string): Promise<StoredOAuthSession | null> {
684
- return storedSessions.get(resource) ?? null;
685
- },
686
- async save(resource: string, session: StoredOAuthSession): Promise<void> {
687
- storedSessions.set(resource, session);
688
- },
689
- async clear(resource: string): Promise<void> {
690
- storedSessions.delete(resource);
691
- }
692
- };
693
-
694
- const transport = new HttpTransport({
695
- url: requestUrl,
696
- fetch: fetchMock,
697
- oauth: {
698
- client: {
699
- mode: "dynamic",
700
- metadata: {
701
- clientName: "tiny-mcp-client test"
702
- }
703
- },
704
- browser: {
705
- openBrowser
706
- },
707
- sessionStore
708
- }
709
- });
710
-
711
- transport.writable.write('{"jsonrpc":"2.0","id":1,"method":"ping"}\n');
712
- expect(JSON.parse(await readTransportLine(transport))).toEqual({
713
- jsonrpc: "2.0",
714
- id: 2,
715
- result: {
716
- ok: true
717
- }
718
- });
719
-
720
- transport.writable.write('{"jsonrpc":"2.0","id":2,"method":"ping"}\n');
721
- expect(JSON.parse(await readTransportLine(transport))).toEqual({
722
- jsonrpc: "2.0",
723
- id: 3,
724
- result: {
725
- ok: true
726
- }
727
- });
728
-
729
- expect(registrationBodies).toHaveLength(1);
730
- expect(tokenBodies).toHaveLength(1);
731
- expect(resourceAuthorizations).toEqual([null, "Bearer access-1", "Bearer access-1"]);
732
- expect(authorizationRequests).toHaveLength(1);
733
- expect(
734
- fetchMock.mock.calls
735
- .map(([input]) => input.toString())
736
- .filter((url) => url === resourceMetadataUrl)
737
- ).toHaveLength(1);
738
- expect(
739
- fetchMock.mock.calls
740
- .map(([input]) => input.toString())
741
- .filter((url) => url === authorizationServerMetadataUrl)
742
- ).toHaveLength(1);
743
- });
744
-
745
- it("falls back to the derived protected-resource metadata URL when the 401 challenge omits resource_metadata", async () => {
746
- const requestUrl = "https://resource.example.com/tenant/mcp";
747
- const resourceMetadataUrl =
748
- "https://resource.example.com/.well-known/oauth-protected-resource/tenant/mcp";
749
- const authorizationServer = "https://auth.example.com/issuer-a";
750
- const authorizationServerMetadataUrl =
751
- "https://auth.example.com/.well-known/oauth-authorization-server/issuer-a";
752
- const authorizationEndpoint = "https://auth.example.com/issuer-a/authorize";
753
- const tokenEndpoint = "https://auth.example.com/issuer-a/token";
754
- const registrationEndpoint = "https://auth.example.com/issuer-a/register";
755
- const resourceAuthorizations: Array<string | null> = [];
756
- const authorizationCodes = new Map<
757
- string,
758
- { clientId: string; redirectUri: string; codeChallenge: string }
759
- >();
760
- const storedSessions = new Map<string, StoredOAuthSession>();
761
- let nextAuthorizationCode = 0;
762
-
763
- const fetchMock = vi.fn(async (input: string | URL, init?: RequestInit): Promise<Response> => {
764
- const method = init?.method ?? "GET";
765
- const url = input.toString();
766
-
767
- if (url === resourceMetadataUrl) {
768
- return jsonResponse({
769
- resource: requestUrl,
770
- authorization_servers: [authorizationServer]
771
- });
772
- }
773
-
774
- if (url === authorizationServerMetadataUrl) {
775
- return jsonResponse({
776
- issuer: authorizationServer,
777
- authorization_endpoint: authorizationEndpoint,
778
- token_endpoint: tokenEndpoint,
779
- registration_endpoint: registrationEndpoint,
780
- response_types_supported: ["code"],
781
- code_challenge_methods_supported: ["S256"]
782
- });
783
- }
784
-
785
- if (url === registrationEndpoint) {
786
- return jsonResponse(
787
- {
788
- client_id: "client-1",
789
- token_endpoint_auth_method: "none"
790
- },
791
- { status: 201 }
792
- );
793
- }
794
-
795
- if (url === tokenEndpoint) {
796
- const body = new URLSearchParams(String(init?.body ?? ""));
797
- const code = body.get("code");
798
- if (code === null) {
799
- throw new Error("Missing authorization code");
800
- }
801
-
802
- const authorizationCode = authorizationCodes.get(code);
803
- if (authorizationCode === undefined) {
804
- throw new Error(`Unknown authorization code: ${code}`);
805
- }
806
-
807
- expect(body.get("client_id")).toBe(authorizationCode.clientId);
808
- expect(body.get("redirect_uri")).toBe(authorizationCode.redirectUri);
809
- expect(body.get("resource")).toBe(requestUrl);
810
-
811
- return jsonResponse({
812
- access_token: "access-1",
813
- token_type: "Bearer",
814
- expires_in: 3600,
815
- refresh_token: "refresh-1"
816
- });
817
- }
818
-
819
- if (url === requestUrl && method === "POST") {
820
- const authorization = new Headers(init?.headers).get("Authorization");
821
- resourceAuthorizations.push(authorization);
822
-
823
- if (authorization === null) {
824
- return new Response(null, {
825
- status: 401,
826
- headers: {
827
- "WWW-Authenticate": 'Bearer realm="Example, Inc", error="invalid_token"'
828
- }
829
- });
830
- }
831
-
832
- return jsonResponse({
833
- jsonrpc: "2.0",
834
- id: 1,
835
- result: {
836
- ok: true
837
- }
838
- });
839
- }
840
-
841
- throw new Error(`Unexpected fetch URL: ${method} ${url}`);
842
- });
843
- const openBrowser = vi.fn(async (authorizationUrl: string) => {
844
- const url = new URL(authorizationUrl);
845
- nextAuthorizationCode += 1;
846
-
847
- const code = `code-${nextAuthorizationCode}`;
848
- authorizationCodes.set(code, {
849
- clientId: url.searchParams.get("client_id") ?? "",
850
- redirectUri: url.searchParams.get("redirect_uri") ?? "",
851
- codeChallenge: url.searchParams.get("code_challenge") ?? ""
852
- });
853
-
854
- const callbackUrl = new URL(url.searchParams.get("redirect_uri") ?? "");
855
- callbackUrl.searchParams.set("code", code);
856
- callbackUrl.searchParams.set("state", url.searchParams.get("state") ?? "");
857
- await requestLoopbackCallback(callbackUrl.toString());
858
- });
859
-
860
- const sessionStore: OAuthSessionStore = {
861
- async load(resource: string): Promise<StoredOAuthSession | null> {
862
- return storedSessions.get(resource) ?? null;
863
- },
864
- async save(resource: string, session: StoredOAuthSession): Promise<void> {
865
- storedSessions.set(resource, session);
866
- },
867
- async clear(resource: string): Promise<void> {
868
- storedSessions.delete(resource);
869
- }
870
- };
871
-
872
- const transport = new HttpTransport({
873
- url: requestUrl,
874
- fetch: fetchMock,
875
- oauth: {
876
- client: {
877
- mode: "dynamic",
878
- metadata: {
879
- clientName: "tiny-mcp-client test"
880
- }
881
- },
882
- browser: {
883
- openBrowser
884
- },
885
- sessionStore
886
- }
887
- });
888
-
889
- transport.writable.write('{"jsonrpc":"2.0","id":1,"method":"ping"}\n');
890
- expect(JSON.parse(await readTransportLine(transport))).toEqual({
891
- jsonrpc: "2.0",
892
- id: 1,
893
- result: {
894
- ok: true
895
- }
896
- });
897
-
898
- expect(resourceAuthorizations).toEqual([null, "Bearer access-1"]);
899
- expect(
900
- fetchMock.mock.calls
901
- .map(([input]) => input.toString())
902
- .filter((url) => url === resourceMetadataUrl)
903
- ).toHaveLength(1);
904
- });
905
- });
906
-
907
- function readTransportLine(transport: HttpTransport): Promise<string> {
908
- return new Promise((resolve, reject) => {
909
- transport.readable.once("data", (chunk: Buffer | string) => {
910
- resolve(chunk.toString("utf8").trim());
911
- });
912
- transport.closed
913
- .then((event) => {
914
- reject(event.reason);
915
- })
916
- .catch(reject);
917
- });
918
- }
919
-
920
- async function requestLoopbackCallback(url: string): Promise<void> {
921
- await nodeFetch(url);
922
- }