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