toolcraft 0.0.5 → 0.0.7

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