pi-soly 1.9.3 → 1.11.0

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 (104) hide show
  1. package/ask/index.ts +12 -11
  2. package/ask/picker.ts +356 -76
  3. package/ask/prompt.ts +6 -2
  4. package/ask/tests/picker.test.ts +273 -82
  5. package/codemap.ts +276 -0
  6. package/hotreload.ts +239 -0
  7. package/init.ts +302 -0
  8. package/mcp/CHANGELOG.md +384 -0
  9. package/mcp/LICENSE +21 -0
  10. package/mcp/OAUTH.md +355 -0
  11. package/mcp/README.md +410 -0
  12. package/mcp/__tests__/agent-dir-paths.upstream-test.ts +80 -0
  13. package/mcp/__tests__/cli.upstream-test.ts +97 -0
  14. package/mcp/__tests__/commands-onboarding.upstream-test.ts +157 -0
  15. package/mcp/__tests__/config.upstream-test.ts +303 -0
  16. package/mcp/__tests__/consent-manager.upstream-test.ts +151 -0
  17. package/mcp/__tests__/direct-tools-auto-auth.upstream-test.ts +218 -0
  18. package/mcp/__tests__/direct-tools.upstream-test.ts +299 -0
  19. package/mcp/__tests__/elicitation-handler.upstream-test.ts +346 -0
  20. package/mcp/__tests__/elicitation-sdk-integration.upstream-test.ts +140 -0
  21. package/mcp/__tests__/errors.upstream-test.ts +218 -0
  22. package/mcp/__tests__/fixtures/elicitation-server.mjs +98 -0
  23. package/mcp/__tests__/host-html-template.upstream-test.ts +278 -0
  24. package/mcp/__tests__/index-lifecycle.upstream-test.ts +484 -0
  25. package/mcp/__tests__/init-elicitation.upstream-test.ts +86 -0
  26. package/mcp/__tests__/interactive-visualizer-server.upstream-test.ts +28 -0
  27. package/mcp/__tests__/logger.upstream-test.ts +175 -0
  28. package/mcp/__tests__/mcp-auth-flow-client-credentials.upstream-test.ts +612 -0
  29. package/mcp/__tests__/mcp-auth-storage.upstream-test.ts +47 -0
  30. package/mcp/__tests__/mcp-callback-server-unref.upstream-test.ts +264 -0
  31. package/mcp/__tests__/mcp-oauth-provider.upstream-test.ts +216 -0
  32. package/mcp/__tests__/mcp-panel-auth.upstream-test.ts +206 -0
  33. package/mcp/__tests__/mcp-panel-exclude-tools.upstream-test.ts +69 -0
  34. package/mcp/__tests__/mcp-panel-keybindings.upstream-test.ts +186 -0
  35. package/mcp/__tests__/npx-resolver.upstream-test.ts +54 -0
  36. package/mcp/__tests__/oauth-handler.upstream-test.ts +77 -0
  37. package/mcp/__tests__/onboarding-state.upstream-test.ts +52 -0
  38. package/mcp/__tests__/package-manifest.upstream-test.ts +22 -0
  39. package/mcp/__tests__/proxy-modes-auto-auth.upstream-test.ts +253 -0
  40. package/mcp/__tests__/proxy-modes-discovery.upstream-test.ts +84 -0
  41. package/mcp/__tests__/proxy-modes-manual-auth.upstream-test.ts +91 -0
  42. package/mcp/__tests__/proxy-modes-ui-messages.upstream-test.ts +72 -0
  43. package/mcp/__tests__/sampling-handler.upstream-test.ts +285 -0
  44. package/mcp/__tests__/server-manager-http-auth.upstream-test.ts +144 -0
  45. package/mcp/__tests__/server-manager-sampling.upstream-test.ts +236 -0
  46. package/mcp/__tests__/tool-metadata.upstream-test.ts +111 -0
  47. package/mcp/__tests__/tool-result-renderer.upstream-test.ts +147 -0
  48. package/mcp/__tests__/ui-integration.upstream-test.ts +551 -0
  49. package/mcp/__tests__/ui-resource-handler.upstream-test.ts +303 -0
  50. package/mcp/__tests__/ui-server.upstream-test.ts +967 -0
  51. package/mcp/__tests__/ui-session-messages.upstream-test.ts +72 -0
  52. package/mcp/__tests__/ui-streaming.upstream-test.ts +543 -0
  53. package/mcp/agent-dir.ts +20 -0
  54. package/mcp/app-bridge.bundle.js +67 -0
  55. package/mcp/cli.js +184 -0
  56. package/mcp/commands.ts +422 -0
  57. package/mcp/config.ts +666 -0
  58. package/mcp/consent-manager.ts +64 -0
  59. package/mcp/direct-tools.ts +439 -0
  60. package/mcp/elicitation-handler.ts +347 -0
  61. package/mcp/errors.ts +219 -0
  62. package/mcp/glimpse-ui.ts +80 -0
  63. package/mcp/host-html-template.ts +427 -0
  64. package/mcp/index.ts +362 -0
  65. package/mcp/init.ts +362 -0
  66. package/mcp/lifecycle.ts +93 -0
  67. package/mcp/logger.ts +169 -0
  68. package/mcp/mcp-auth-flow.ts +559 -0
  69. package/mcp/mcp-auth-flow.upstream-test.ts +259 -0
  70. package/mcp/mcp-auth.ts +302 -0
  71. package/mcp/mcp-auth.upstream-test.ts +373 -0
  72. package/mcp/mcp-callback-server.ts +372 -0
  73. package/mcp/mcp-callback-server.upstream-test.ts +416 -0
  74. package/mcp/mcp-oauth-provider.ts +369 -0
  75. package/mcp/mcp-oauth-provider.upstream-test.ts +518 -0
  76. package/mcp/mcp-panel.ts +829 -0
  77. package/mcp/mcp-setup-panel.ts +580 -0
  78. package/mcp/metadata-cache.ts +201 -0
  79. package/mcp/notify.ts +111 -0
  80. package/mcp/npx-resolver.ts +424 -0
  81. package/mcp/oauth-handler.ts +57 -0
  82. package/mcp/onboarding-state.ts +68 -0
  83. package/mcp/package.json +106 -0
  84. package/mcp/panel-keys.ts +37 -0
  85. package/mcp/proxy-modes.ts +949 -0
  86. package/mcp/resource-tools.ts +17 -0
  87. package/mcp/sampling-handler.ts +268 -0
  88. package/mcp/server-manager.ts +545 -0
  89. package/mcp/state.ts +41 -0
  90. package/mcp/tool-metadata.ts +216 -0
  91. package/mcp/tool-registrar.ts +46 -0
  92. package/mcp/tool-result-renderer.ts +161 -0
  93. package/mcp/types.ts +448 -0
  94. package/mcp/ui-resource-handler.ts +146 -0
  95. package/mcp/ui-server.ts +623 -0
  96. package/mcp/ui-session.ts +386 -0
  97. package/mcp/ui-stream-types.ts +89 -0
  98. package/mcp/utils.ts +129 -0
  99. package/mcp/vitest.config.ts +14 -0
  100. package/migrate.ts +258 -0
  101. package/notification.ts +218 -0
  102. package/notifications-log.ts +83 -0
  103. package/package.json +20 -3
  104. package/status.ts +140 -0
@@ -0,0 +1,612 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
+ import { mkdtempSync, rmSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { tmpdir } from "node:os";
5
+
6
+ const mocks = vi.hoisted(() => ({
7
+ ensureCallbackServer: vi.fn(),
8
+ waitForCallback: vi.fn(),
9
+ cancelPendingCallback: vi.fn(),
10
+ stopCallbackServer: vi.fn(),
11
+ reserveCallbackServer: vi.fn(),
12
+ releaseCallbackServer: vi.fn(),
13
+ open: vi.fn(),
14
+ sdkAuth: vi.fn(),
15
+ finishAuth: vi.fn(),
16
+ transportClose: vi.fn(),
17
+ }));
18
+
19
+ class MockUnauthorizedError extends Error {}
20
+
21
+ class MockStreamableHTTPClientTransport {
22
+ constructor(_url: URL, _options: unknown) {}
23
+
24
+ close = mocks.transportClose;
25
+ finishAuth = mocks.finishAuth;
26
+ }
27
+
28
+ vi.mock("@modelcontextprotocol/sdk/client/auth.js", () => ({
29
+ auth: mocks.sdkAuth,
30
+ UnauthorizedError: MockUnauthorizedError,
31
+ }));
32
+
33
+ vi.mock("@modelcontextprotocol/sdk/client/streamableHttp.js", () => ({
34
+ StreamableHTTPClientTransport: MockStreamableHTTPClientTransport,
35
+ }));
36
+
37
+ vi.mock("../mcp-callback-server.ts", () => ({
38
+ ensureCallbackServer: mocks.ensureCallbackServer,
39
+ waitForCallback: mocks.waitForCallback,
40
+ cancelPendingCallback: mocks.cancelPendingCallback,
41
+ stopCallbackServer: mocks.stopCallbackServer,
42
+ reserveCallbackServer: mocks.reserveCallbackServer,
43
+ releaseCallbackServer: mocks.releaseCallbackServer,
44
+ }));
45
+
46
+ vi.mock("open", () => ({
47
+ default: mocks.open,
48
+ }));
49
+
50
+ describe("mcp-auth-flow explicit auth", () => {
51
+ const originalOAuthDir = process.env.MCP_OAUTH_DIR;
52
+ let authDir: string;
53
+
54
+ beforeEach(() => {
55
+ authDir = mkdtempSync(join(tmpdir(), "pi-mcp-auth-flow-"));
56
+ process.env.MCP_OAUTH_DIR = authDir;
57
+ vi.resetModules();
58
+ mocks.ensureCallbackServer.mockReset();
59
+ mocks.waitForCallback.mockReset();
60
+ mocks.cancelPendingCallback.mockReset();
61
+ mocks.stopCallbackServer.mockReset();
62
+ mocks.reserveCallbackServer.mockReset();
63
+ mocks.releaseCallbackServer.mockReset();
64
+ mocks.open.mockReset();
65
+ mocks.sdkAuth.mockReset().mockResolvedValue("AUTHORIZED");
66
+ mocks.finishAuth.mockReset().mockResolvedValue(undefined);
67
+ mocks.transportClose.mockReset().mockResolvedValue(undefined);
68
+ });
69
+
70
+ afterEach(() => {
71
+ rmSync(authDir, { recursive: true, force: true });
72
+ if (originalOAuthDir === undefined) {
73
+ delete process.env.MCP_OAUTH_DIR;
74
+ } else {
75
+ process.env.MCP_OAUTH_DIR = originalOAuthDir;
76
+ }
77
+ });
78
+
79
+ it("parses manual OAuth redirect URL and code input", async () => {
80
+ const { parseAuthorizationCodeInput } = await import("../mcp-auth-flow.ts");
81
+
82
+ expect(parseAuthorizationCodeInput(
83
+ "http://localhost:19876/callback?code=abc123&state=state123",
84
+ "state123",
85
+ )).toBe("abc123");
86
+ expect(parseAuthorizationCodeInput("code=abc123&state=state123", "state123")).toBe("abc123");
87
+ expect(parseAuthorizationCodeInput(
88
+ "http://localhost:19876/callback#code=abc123&state=state123",
89
+ "state123",
90
+ )).toBe("abc123");
91
+ expect(parseAuthorizationCodeInput("abc123")).toBe("abc123");
92
+ });
93
+
94
+ it("rejects invalid manual OAuth redirect input", async () => {
95
+ const { parseAuthorizationCodeInput } = await import("../mcp-auth-flow.ts");
96
+
97
+ expect(() => parseAuthorizationCodeInput(
98
+ "http://localhost:19876/callback?error=access_denied&error_description=Denied&state=state123",
99
+ "state123",
100
+ )).toThrow("access_denied: Denied");
101
+ expect(() => parseAuthorizationCodeInput(
102
+ "http://localhost:19876/callback?code=abc123",
103
+ "state123",
104
+ )).toThrow("state missing");
105
+ expect(() => parseAuthorizationCodeInput(
106
+ "http://localhost:19876/callback?code=abc123&state=wrong",
107
+ "state123",
108
+ )).toThrow("state mismatch");
109
+ });
110
+
111
+ it("does not start the callback server during OAuth initialization", async () => {
112
+ const { initializeOAuth } = await import("../mcp-auth-flow.ts");
113
+
114
+ await initializeOAuth();
115
+
116
+ expect(mocks.ensureCallbackServer).not.toHaveBeenCalled();
117
+ });
118
+
119
+ it("authenticates client_credentials non-interactively without callback server or browser", async () => {
120
+ const { authenticate } = await import("../mcp-auth-flow.ts");
121
+
122
+ const status = await authenticate("svc", "https://api.example.com/mcp", {
123
+ url: "https://api.example.com/mcp",
124
+ auth: "oauth",
125
+ oauth: {
126
+ grantType: "client_credentials",
127
+ clientId: "service-client",
128
+ clientSecret: "service-secret",
129
+ },
130
+ });
131
+
132
+ expect(status).toBe("authenticated");
133
+ expect(mocks.sdkAuth).toHaveBeenCalledTimes(1);
134
+ expect(mocks.transportClose).not.toHaveBeenCalled();
135
+ expect(mocks.ensureCallbackServer).not.toHaveBeenCalled();
136
+ expect(mocks.waitForCallback).not.toHaveBeenCalled();
137
+ expect(mocks.open).not.toHaveBeenCalled();
138
+ });
139
+
140
+ it("clears stale dynamic client info before client_credentials auth", async () => {
141
+ mocks.sdkAuth.mockImplementationOnce(async (provider) => {
142
+ expect(await provider.clientInformation()).toBeUndefined();
143
+ await provider.saveClientInformation({
144
+ client_id: "fresh-service-client",
145
+ client_secret: "fresh-service-secret",
146
+ });
147
+ await provider.saveTokens({
148
+ access_token: "service-access",
149
+ token_type: "Bearer",
150
+ expires_in: 3600,
151
+ });
152
+ return "AUTHORIZED";
153
+ });
154
+ const { authenticate } = await import("../mcp-auth-flow.ts");
155
+ const { getAuthForUrl, updateClientInfo, updateCodeVerifier, updateOAuthState } = await import("../mcp-auth.ts");
156
+
157
+ updateClientInfo("stale-client-credentials", { clientId: "stale-client" }, "https://api.example.com/mcp");
158
+ updateCodeVerifier("stale-client-credentials", "stale-verifier");
159
+ updateOAuthState("stale-client-credentials", "stale-state");
160
+
161
+ const status = await authenticate("stale-client-credentials", "https://api.example.com/mcp", {
162
+ url: "https://api.example.com/mcp",
163
+ auth: "oauth",
164
+ oauth: { grantType: "client_credentials" },
165
+ });
166
+
167
+ expect(status).toBe("authenticated");
168
+ const stored = getAuthForUrl("stale-client-credentials", "https://api.example.com/mcp");
169
+ expect(stored?.clientInfo?.clientId).toBe("fresh-service-client");
170
+ expect(stored?.tokens?.accessToken).toBe("service-access");
171
+ expect(stored?.codeVerifier).toBeUndefined();
172
+ expect(stored?.oauthState).toBeUndefined();
173
+ expect(mocks.ensureCallbackServer).not.toHaveBeenCalled();
174
+ expect(mocks.open).not.toHaveBeenCalled();
175
+ });
176
+
177
+ it("deduplicates concurrent authentication attempts for the same server", async () => {
178
+ const { authenticate } = await import("../mcp-auth-flow.ts");
179
+
180
+ const [first, second] = await Promise.all([
181
+ authenticate("svc", "https://api.example.com/mcp", {
182
+ url: "https://api.example.com/mcp",
183
+ auth: "oauth",
184
+ oauth: {
185
+ grantType: "client_credentials",
186
+ clientId: "service-client",
187
+ clientSecret: "service-secret",
188
+ },
189
+ }),
190
+ authenticate("svc", "https://api.example.com/mcp", {
191
+ url: "https://api.example.com/mcp",
192
+ auth: "oauth",
193
+ oauth: {
194
+ grantType: "client_credentials",
195
+ clientId: "service-client",
196
+ clientSecret: "service-secret",
197
+ },
198
+ }),
199
+ ]);
200
+
201
+ expect(first).toBe("authenticated");
202
+ expect(second).toBe("authenticated");
203
+ expect(mocks.sdkAuth).toHaveBeenCalledTimes(1);
204
+ });
205
+
206
+ it("runs SDK auth before reporting expired tokens as re-authenticated", async () => {
207
+ const { authenticate } = await import("../mcp-auth-flow.ts");
208
+ const { getOAuthState, updateClientInfo, updateTokens } = await import("../mcp-auth.ts");
209
+
210
+ updateClientInfo("expired", { clientId: "client", redirectUris: ["http://localhost:19876/callback"] }, "https://api.example.com/mcp");
211
+ updateTokens("expired", {
212
+ accessToken: "old-access",
213
+ refreshToken: "old-refresh",
214
+ expiresAt: Date.now() / 1000 - 60,
215
+ }, "https://api.example.com/mcp");
216
+
217
+ const status = await authenticate("expired", "https://api.example.com/mcp", {
218
+ url: "https://api.example.com/mcp",
219
+ auth: "oauth",
220
+ });
221
+
222
+ expect(status).toBe("authenticated");
223
+ expect(mocks.sdkAuth).toHaveBeenCalledTimes(1);
224
+ expect(mocks.ensureCallbackServer).toHaveBeenCalledWith(expect.objectContaining({
225
+ strictPort: false,
226
+ reserveState: true,
227
+ oauthState: expect.any(String),
228
+ }));
229
+ expect(getOAuthState("expired")).toBeUndefined();
230
+ });
231
+
232
+ it("refreshes expired tokens through SDK auth before returning them", async () => {
233
+ mocks.sdkAuth.mockImplementationOnce(async (provider) => {
234
+ await provider.saveTokens({
235
+ access_token: "new-access",
236
+ token_type: "Bearer",
237
+ refresh_token: "new-refresh",
238
+ expires_in: 3600,
239
+ });
240
+ return "AUTHORIZED";
241
+ });
242
+ const { getValidToken } = await import("../mcp-auth-flow.ts");
243
+ const { updateClientInfo, updateTokens } = await import("../mcp-auth.ts");
244
+
245
+ updateClientInfo("refresh", { clientId: "client", redirectUris: ["http://localhost:19876/callback"] }, "https://api.example.com/mcp");
246
+ updateTokens("refresh", {
247
+ accessToken: "old-access",
248
+ refreshToken: "old-refresh",
249
+ expiresAt: Date.now() / 1000 - 60,
250
+ }, "https://api.example.com/mcp");
251
+
252
+ const token = await getValidToken("refresh", "https://api.example.com/mcp");
253
+
254
+ expect(token?.accessToken).toBe("new-access");
255
+ expect(mocks.sdkAuth).toHaveBeenCalledTimes(1);
256
+ });
257
+
258
+ it("re-registers dynamic OAuth clients when only stale client info is stored", async () => {
259
+ mocks.sdkAuth.mockImplementationOnce(async (provider) => {
260
+ expect(await provider.clientInformation()).toBeUndefined();
261
+ await provider.saveClientInformation({
262
+ client_id: "fresh-client",
263
+ client_secret: "fresh-secret",
264
+ });
265
+ await provider.redirectToAuthorization(new URL("https://auth.example.com/authorize"));
266
+ return "REDIRECT";
267
+ });
268
+ const { startAuth } = await import("../mcp-auth-flow.ts");
269
+ const { getAuthForUrl, getOAuthState, updateClientInfo, updateCodeVerifier, updateOAuthState } = await import("../mcp-auth.ts");
270
+
271
+ updateClientInfo("stale", { clientId: "stale-client" }, "https://api.example.com/mcp");
272
+ updateCodeVerifier("stale", "old-verifier");
273
+ updateOAuthState("stale", "old-state");
274
+
275
+ const result = await startAuth("stale", "https://api.example.com/mcp", {
276
+ url: "https://api.example.com/mcp",
277
+ auth: "oauth",
278
+ });
279
+
280
+ expect(result.authorizationUrl).toBe("https://auth.example.com/authorize");
281
+ const stored = getAuthForUrl("stale", "https://api.example.com/mcp");
282
+ expect(stored?.clientInfo?.clientId).toBe("fresh-client");
283
+ expect(stored?.codeVerifier).toBeUndefined();
284
+ expect(getOAuthState("stale")).not.toBe("old-state");
285
+ });
286
+
287
+ it("preserves stored dynamic client info when tokens exist", async () => {
288
+ mocks.sdkAuth.mockImplementationOnce(async (provider) => {
289
+ expect(await provider.clientInformation()).toEqual({ client_id: "stored-client", client_secret: "stored-secret" });
290
+ await provider.redirectToAuthorization(new URL("https://auth.example.com/authorize"));
291
+ return "REDIRECT";
292
+ });
293
+ const { startAuth } = await import("../mcp-auth-flow.ts");
294
+ const { getAuthForUrl, updateClientInfo, updateTokens } = await import("../mcp-auth.ts");
295
+
296
+ updateClientInfo("tokened", {
297
+ clientId: "stored-client",
298
+ clientSecret: "stored-secret",
299
+ redirectUris: ["http://localhost:19876/callback"],
300
+ }, "https://api.example.com/mcp");
301
+ updateTokens("tokened", { accessToken: "access", refreshToken: "refresh" }, "https://api.example.com/mcp");
302
+
303
+ await startAuth("tokened", "https://api.example.com/mcp", {
304
+ url: "https://api.example.com/mcp",
305
+ auth: "oauth",
306
+ });
307
+
308
+ expect(getAuthForUrl("tokened", "https://api.example.com/mcp")?.clientInfo?.clientId).toBe("stored-client");
309
+ });
310
+
311
+ it("does not return tokens from the previous URL after dynamic client info is saved for a new URL", async () => {
312
+ const { getValidToken } = await import("../mcp-auth-flow.ts");
313
+ const { getAuthForUrl, updateClientInfo, updateTokens } = await import("../mcp-auth.ts");
314
+
315
+ updateClientInfo("url-change", { clientId: "old-client" }, "https://old.example.com/mcp");
316
+ updateTokens("url-change", { accessToken: "old-access", refreshToken: "old-refresh" }, "https://old.example.com/mcp");
317
+ updateClientInfo("url-change", { clientId: "new-client" }, "https://new.example.com/mcp");
318
+
319
+ await expect(getValidToken("url-change", "https://new.example.com/mcp")).resolves.toBeNull();
320
+ expect(getAuthForUrl("url-change", "https://old.example.com/mcp")).toBeUndefined();
321
+ expect(getAuthForUrl("url-change", "https://new.example.com/mcp")?.tokens).toBeUndefined();
322
+ });
323
+
324
+ it("re-registers dynamic OAuth clients when cached redirect URIs are stale", async () => {
325
+ mocks.sdkAuth.mockImplementationOnce(async (provider) => {
326
+ expect(await provider.clientInformation()).toBeUndefined();
327
+ await provider.saveClientInformation({
328
+ client_id: "fresh-client",
329
+ client_secret: "fresh-secret",
330
+ });
331
+ await provider.redirectToAuthorization(new URL("https://auth.example.com/authorize"));
332
+ return "REDIRECT";
333
+ });
334
+ const { startAuth } = await import("../mcp-auth-flow.ts");
335
+ const { getAuthForUrl, updateClientInfo, updateCodeVerifier, updateOAuthState, updateTokens } = await import("../mcp-auth.ts");
336
+
337
+ updateClientInfo("stale-redirect", {
338
+ clientId: "stale-client",
339
+ clientSecret: "stale-secret",
340
+ redirectUris: ["http://localhost:19876/callback"],
341
+ }, "https://api.example.com/mcp");
342
+ updateTokens("stale-redirect", { accessToken: "old-access", refreshToken: "old-refresh" }, "https://api.example.com/mcp");
343
+ updateCodeVerifier("stale-redirect", "old-verifier");
344
+ updateOAuthState("stale-redirect", "old-state");
345
+
346
+ const result = await startAuth("stale-redirect", "https://api.example.com/mcp", {
347
+ url: "https://api.example.com/mcp",
348
+ auth: "oauth",
349
+ oauth: { redirectUri: "http://localhost:3118/callback" },
350
+ });
351
+
352
+ expect(result.authorizationUrl).toBe("https://auth.example.com/authorize");
353
+ const stored = getAuthForUrl("stale-redirect", "https://api.example.com/mcp");
354
+ expect(stored?.clientInfo?.clientId).toBe("fresh-client");
355
+ expect(stored?.clientInfo?.redirectUris).toEqual(["http://localhost:3118/callback"]);
356
+ expect(stored?.tokens).toBeUndefined();
357
+ expect(stored?.codeVerifier).toBeUndefined();
358
+ expect(stored?.oauthState).not.toBe("old-state");
359
+ expect(mocks.ensureCallbackServer).toHaveBeenCalledWith(expect.objectContaining({
360
+ strictPort: true,
361
+ port: 3118,
362
+ callbackHost: "localhost",
363
+ callbackPath: "/callback",
364
+ reserveState: true,
365
+ oauthState: expect.any(String),
366
+ }));
367
+ });
368
+
369
+ it("re-registers dynamic OAuth clients when cached redirect URI metadata is missing", async () => {
370
+ mocks.sdkAuth.mockImplementationOnce(async (provider) => {
371
+ expect(await provider.clientInformation()).toBeUndefined();
372
+ await provider.saveClientInformation({
373
+ client_id: "fresh-client",
374
+ client_secret: "fresh-secret",
375
+ });
376
+ await provider.redirectToAuthorization(new URL("https://auth.example.com/authorize"));
377
+ return "REDIRECT";
378
+ });
379
+ const { startAuth } = await import("../mcp-auth-flow.ts");
380
+ const { getAuthForUrl, updateClientInfo, updateTokens } = await import("../mcp-auth.ts");
381
+
382
+ updateClientInfo("missing-redirect-metadata", {
383
+ clientId: "legacy-client",
384
+ clientSecret: "legacy-secret",
385
+ }, "https://api.example.com/mcp");
386
+ updateTokens("missing-redirect-metadata", { accessToken: "old-access", refreshToken: "old-refresh" }, "https://api.example.com/mcp");
387
+
388
+ const result = await startAuth("missing-redirect-metadata", "https://api.example.com/mcp", {
389
+ url: "https://api.example.com/mcp",
390
+ auth: "oauth",
391
+ });
392
+
393
+ expect(result.authorizationUrl).toBe("https://auth.example.com/authorize");
394
+ const stored = getAuthForUrl("missing-redirect-metadata", "https://api.example.com/mcp");
395
+ expect(stored?.clientInfo?.clientId).toBe("fresh-client");
396
+ expect(stored?.clientInfo?.redirectUris).toEqual(["http://localhost:19876/callback"]);
397
+ expect(stored?.tokens).toBeUndefined();
398
+ });
399
+
400
+ it("re-registers dynamic OAuth clients when cached redirect URI metadata is malformed", async () => {
401
+ mocks.sdkAuth.mockImplementationOnce(async (provider) => {
402
+ expect(await provider.clientInformation()).toBeUndefined();
403
+ await provider.saveClientInformation({
404
+ client_id: "fresh-client",
405
+ client_secret: "fresh-secret",
406
+ });
407
+ await provider.redirectToAuthorization(new URL("https://auth.example.com/authorize"));
408
+ return "REDIRECT";
409
+ });
410
+ const { startAuth } = await import("../mcp-auth-flow.ts");
411
+ const { getAuthForUrl, saveAuthEntry } = await import("../mcp-auth.ts");
412
+
413
+ saveAuthEntry("malformed-redirect-metadata", {
414
+ clientInfo: {
415
+ clientId: "legacy-client",
416
+ clientSecret: "legacy-secret",
417
+ redirectUris: "http://localhost:19876/callback" as unknown as string[],
418
+ },
419
+ tokens: { accessToken: "old-access", refreshToken: "old-refresh" },
420
+ }, "https://api.example.com/mcp");
421
+
422
+ const result = await startAuth("malformed-redirect-metadata", "https://api.example.com/mcp", {
423
+ url: "https://api.example.com/mcp",
424
+ auth: "oauth",
425
+ });
426
+
427
+ expect(result.authorizationUrl).toBe("https://auth.example.com/authorize");
428
+ const stored = getAuthForUrl("malformed-redirect-metadata", "https://api.example.com/mcp");
429
+ expect(stored?.clientInfo?.clientId).toBe("fresh-client");
430
+ expect(stored?.clientInfo?.redirectUris).toEqual(["http://localhost:19876/callback"]);
431
+ expect(stored?.tokens).toBeUndefined();
432
+ });
433
+
434
+ it("refreshes expired tokens even when cached dynamic redirect URIs are stale", async () => {
435
+ mocks.sdkAuth.mockImplementationOnce(async (provider) => {
436
+ expect(await provider.clientInformation()).toEqual({ client_id: "refresh-client", client_secret: "refresh-secret" });
437
+ await provider.saveTokens({
438
+ access_token: "new-access",
439
+ token_type: "Bearer",
440
+ refresh_token: "new-refresh",
441
+ expires_in: 3600,
442
+ });
443
+ return "AUTHORIZED";
444
+ });
445
+ const { getValidToken } = await import("../mcp-auth-flow.ts");
446
+ const { getAuthForUrl, updateClientInfo, updateTokens } = await import("../mcp-auth.ts");
447
+
448
+ updateClientInfo("refresh-stale-redirect", {
449
+ clientId: "refresh-client",
450
+ clientSecret: "refresh-secret",
451
+ redirectUris: ["http://localhost:19876/callback"],
452
+ }, "https://api.example.com/mcp");
453
+ updateTokens("refresh-stale-redirect", {
454
+ accessToken: "old-access",
455
+ refreshToken: "old-refresh",
456
+ expiresAt: Date.now() / 1000 - 60,
457
+ }, "https://api.example.com/mcp");
458
+
459
+ const token = await getValidToken("refresh-stale-redirect", "https://api.example.com/mcp");
460
+
461
+ expect(token?.accessToken).toBe("new-access");
462
+ expect(getAuthForUrl("refresh-stale-redirect", "https://api.example.com/mcp")?.clientInfo?.clientId).toBe("refresh-client");
463
+ expect(mocks.sdkAuth).toHaveBeenCalledTimes(1);
464
+ });
465
+
466
+ it("preserves pre-registered OAuth client behavior", async () => {
467
+ mocks.sdkAuth.mockImplementationOnce(async (provider) => {
468
+ expect(await provider.clientInformation()).toEqual({ client_id: "registered-client", client_secret: undefined });
469
+ await provider.redirectToAuthorization(new URL("https://auth.example.com/authorize"));
470
+ return "REDIRECT";
471
+ });
472
+ const { startAuth } = await import("../mcp-auth-flow.ts");
473
+ const { getAuthForUrl, updateClientInfo } = await import("../mcp-auth.ts");
474
+
475
+ updateClientInfo("registered", { clientId: "stored-dynamic-client" }, "https://api.example.com/mcp");
476
+
477
+ await startAuth("registered", "https://api.example.com/mcp", {
478
+ url: "https://api.example.com/mcp",
479
+ auth: "oauth",
480
+ oauth: { clientId: "registered-client" },
481
+ });
482
+
483
+ expect(getAuthForUrl("registered", "https://api.example.com/mcp")?.clientInfo?.clientId).toBe("stored-dynamic-client");
484
+ expect(mocks.ensureCallbackServer).toHaveBeenCalledWith(expect.objectContaining({
485
+ strictPort: true,
486
+ reserveState: true,
487
+ oauthState: expect.any(String),
488
+ }));
489
+ });
490
+
491
+ it("continues waiting for the OAuth callback when the browser cannot open", async () => {
492
+ mocks.sdkAuth.mockImplementationOnce(async (provider) => {
493
+ await provider.redirectToAuthorization(new URL("https://auth.example.com/authorize"));
494
+ return "REDIRECT";
495
+ });
496
+ mocks.open.mockRejectedValueOnce(new Error("no browser"));
497
+ mocks.waitForCallback.mockResolvedValueOnce("manual-code");
498
+ const { authenticate } = await import("../mcp-auth-flow.ts");
499
+ const { getOAuthState } = await import("../mcp-auth.ts");
500
+
501
+ await expect(authenticate("browser-fail", "https://api.example.com/mcp", {
502
+ url: "https://api.example.com/mcp",
503
+ auth: "oauth",
504
+ })).resolves.toBe("authenticated");
505
+
506
+ expect(mocks.finishAuth).toHaveBeenCalledWith("manual-code");
507
+ expect(mocks.cancelPendingCallback).not.toHaveBeenCalled();
508
+ expect(mocks.transportClose).toHaveBeenCalledTimes(1);
509
+ expect(getOAuthState("browser-fail")).toBeUndefined();
510
+ });
511
+
512
+ it("releases reserved callback state after direct completeAuth", async () => {
513
+ mocks.sdkAuth.mockImplementationOnce(async (provider) => {
514
+ await provider.redirectToAuthorization(new URL("https://auth.example.com/authorize"));
515
+ return "REDIRECT";
516
+ });
517
+ const { completeAuth, startAuth } = await import("../mcp-auth-flow.ts");
518
+ const { getOAuthState } = await import("../mcp-auth.ts");
519
+
520
+ await startAuth("direct-complete", "https://api.example.com/mcp", {
521
+ url: "https://api.example.com/mcp",
522
+ auth: "oauth",
523
+ });
524
+ const oauthState = getOAuthState("direct-complete");
525
+
526
+ await expect(completeAuth("direct-complete", "auth-code")).resolves.toBe("authenticated");
527
+
528
+ expect(mocks.finishAuth).toHaveBeenCalledWith("auth-code");
529
+ expect(mocks.releaseCallbackServer).toHaveBeenCalledWith(oauthState);
530
+ expect(mocks.transportClose).toHaveBeenCalledTimes(1);
531
+ expect(getOAuthState("direct-complete")).toBeUndefined();
532
+ });
533
+
534
+ it("uses an explicit OAuth redirect URI for callback binding and metadata", async () => {
535
+ mocks.sdkAuth.mockImplementationOnce(async (provider) => {
536
+ expect(provider.redirectUrl).toBe("http://127.0.0.1:3118/callback");
537
+ expect(provider.clientMetadata.redirect_uris).toEqual(["http://127.0.0.1:3118/callback"]);
538
+ expect(provider.clientMetadata.client_name).toBe("Custom MCP");
539
+ expect(provider.clientMetadata.client_uri).toBe("https://example.com/custom-mcp");
540
+ await provider.redirectToAuthorization(new URL("https://auth.example.com/authorize"));
541
+ return "REDIRECT";
542
+ });
543
+ const { startAuth } = await import("../mcp-auth-flow.ts");
544
+
545
+ const result = await startAuth("explicit-redirect", "https://api.example.com/mcp", {
546
+ url: "https://api.example.com/mcp",
547
+ auth: "oauth",
548
+ oauth: {
549
+ redirectUri: "http://127.0.0.1:3118/callback",
550
+ clientName: "Custom MCP",
551
+ clientUri: "https://example.com/custom-mcp",
552
+ },
553
+ });
554
+
555
+ expect(result.authorizationUrl).toBe("https://auth.example.com/authorize");
556
+ expect(mocks.ensureCallbackServer).toHaveBeenCalledWith(expect.objectContaining({
557
+ strictPort: true,
558
+ port: 3118,
559
+ callbackHost: "127.0.0.1",
560
+ callbackPath: "/callback",
561
+ reserveState: true,
562
+ oauthState: expect.any(String),
563
+ }));
564
+ expect(mocks.open).not.toHaveBeenCalled();
565
+ });
566
+
567
+ it("enforces strict callback port for pre-registered OAuth clients", async () => {
568
+ mocks.sdkAuth.mockImplementationOnce(async (provider) => {
569
+ await provider.redirectToAuthorization(new URL("https://auth.example.com/authorize"));
570
+ return "REDIRECT";
571
+ });
572
+ const { startAuth } = await import("../mcp-auth-flow.ts");
573
+
574
+ const result = await startAuth("svc", "https://api.example.com/mcp", {
575
+ url: "https://api.example.com/mcp",
576
+ auth: "oauth",
577
+ oauth: {
578
+ clientId: "registered-client",
579
+ },
580
+ });
581
+
582
+ expect(result.authorizationUrl).toBe("https://auth.example.com/authorize");
583
+ expect(mocks.ensureCallbackServer).toHaveBeenCalledWith(expect.objectContaining({
584
+ strictPort: true,
585
+ reserveState: true,
586
+ oauthState: expect.any(String),
587
+ }));
588
+ expect(mocks.open).not.toHaveBeenCalled();
589
+ });
590
+
591
+ it("allows callback port fallback for dynamic registration", async () => {
592
+ mocks.sdkAuth.mockImplementationOnce(async (provider) => {
593
+ await provider.redirectToAuthorization(new URL("https://auth.example.com/authorize"));
594
+ return "REDIRECT";
595
+ });
596
+ const { startAuth } = await import("../mcp-auth-flow.ts");
597
+
598
+ const result = await startAuth("svc", "https://api.example.com/mcp", {
599
+ url: "https://api.example.com/mcp",
600
+ auth: "oauth",
601
+ });
602
+
603
+ expect(result.authorizationUrl).toBe("https://auth.example.com/authorize");
604
+ expect(mocks.ensureCallbackServer).toHaveBeenCalledWith(expect.objectContaining({
605
+ strictPort: false,
606
+ reserveState: true,
607
+ oauthState: expect.any(String),
608
+ }));
609
+ expect(mocks.reserveCallbackServer).not.toHaveBeenCalled();
610
+ expect(mocks.open).not.toHaveBeenCalled();
611
+ });
612
+ });
@@ -0,0 +1,47 @@
1
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
2
+ import { existsSync, mkdtempSync, rmSync } from "node:fs";
3
+ import { isAbsolute, join, relative } from "node:path";
4
+ import { tmpdir } from "node:os";
5
+ import { getAuthEntry, getAuthEntryFilePath, saveAuthEntry } from "../mcp-auth.ts";
6
+
7
+ describe("mcp-auth storage paths", () => {
8
+ const originalOAuthDir = process.env.MCP_OAUTH_DIR;
9
+ let authDir: string;
10
+
11
+ beforeEach(() => {
12
+ authDir = mkdtempSync(join(tmpdir(), "pi-mcp-auth-storage-"));
13
+ process.env.MCP_OAUTH_DIR = authDir;
14
+ });
15
+
16
+ afterEach(() => {
17
+ if (originalOAuthDir === undefined) {
18
+ delete process.env.MCP_OAUTH_DIR;
19
+ } else {
20
+ process.env.MCP_OAUTH_DIR = originalOAuthDir;
21
+ }
22
+ rmSync(authDir, { recursive: true, force: true });
23
+ });
24
+
25
+ it("stores arbitrary configured server names under safe hashed paths", () => {
26
+ const names = ["Cloudflare Workers", "сервер", "../escape", "@scope/name", ""];
27
+
28
+ for (const [index, name] of names.entries()) {
29
+ const token = `token-${index}`;
30
+ saveAuthEntry(name, { tokens: { accessToken: token } }, "https://example.com/mcp");
31
+
32
+ expect(getAuthEntry(name)?.tokens?.accessToken).toBe(token);
33
+ const filePath = getAuthEntryFilePath(name);
34
+ const rel = relative(authDir, filePath);
35
+ expect(rel.startsWith("..")).toBe(false);
36
+ expect(isAbsolute(rel)).toBe(false);
37
+ expect(rel).toMatch(/^sha256-[a-f0-9]{64}\/tokens\.json$/);
38
+ expect(existsSync(filePath)).toBe(true);
39
+ }
40
+
41
+ expect(existsSync(join(authDir, "..", "escape", "tokens.json"))).toBe(false);
42
+ });
43
+
44
+ it("rejects non-string names at the storage boundary", () => {
45
+ expect(() => getAuthEntryFilePath(undefined as unknown as string)).toThrow(/Invalid MCP server name/);
46
+ });
47
+ });