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,285 @@
1
+ import { beforeEach, describe, expect, it, vi } from "vitest";
2
+ import type { Api, Model } from "@earendil-works/pi-ai";
3
+ import type { CreateMessageRequest, ModelPreferences } from "@modelcontextprotocol/sdk/types.js";
4
+ import type { SamplingHandlerOptions } from "../sampling-handler.ts";
5
+
6
+ const mocks = vi.hoisted(() => ({
7
+ complete: vi.fn(),
8
+ }));
9
+
10
+ vi.mock("@earendil-works/pi-ai", () => ({
11
+ complete: mocks.complete,
12
+ }));
13
+
14
+ const usage = {
15
+ input: 0,
16
+ output: 0,
17
+ cacheRead: 0,
18
+ cacheWrite: 0,
19
+ totalTokens: 0,
20
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
21
+ };
22
+
23
+ const model = {
24
+ provider: "anthropic",
25
+ id: "claude-sonnet",
26
+ api: "anthropic-messages",
27
+ name: "Claude Sonnet",
28
+ baseUrl: "https://api.anthropic.com",
29
+ input: ["text"],
30
+ reasoning: false,
31
+ cost: usage.cost,
32
+ contextWindow: 200000,
33
+ maxTokens: 8192,
34
+ } satisfies Model<"anthropic-messages">;
35
+
36
+ const opus = {
37
+ ...model,
38
+ id: "claude-opus",
39
+ name: "Claude Opus",
40
+ } satisfies Model<"anthropic-messages">;
41
+
42
+ const haiku = {
43
+ ...model,
44
+ id: "claude-haiku",
45
+ name: "Claude Haiku",
46
+ } satisfies Model<"anthropic-messages">;
47
+
48
+ const geminiFlash = {
49
+ ...model,
50
+ provider: "google",
51
+ id: "gemini-2.5-flash",
52
+ api: "google-generative-ai",
53
+ name: "Gemini 2.5 Flash",
54
+ baseUrl: "https://generativelanguage.googleapis.com",
55
+ } satisfies Model<"google-generative-ai">;
56
+
57
+ type SamplingTestOptions = Omit<SamplingHandlerOptions, "modelRegistry"> & {
58
+ modelRegistry: Pick<SamplingHandlerOptions["modelRegistry"], "getAvailable" | "getApiKeyAndHeaders">;
59
+ };
60
+
61
+ function createOptions(overrides: Partial<SamplingTestOptions> = {}): SamplingHandlerOptions {
62
+ const options = {
63
+ serverName: "i18n",
64
+ autoApprove: true,
65
+ modelRegistry: {
66
+ getAvailable: vi.fn(() => [model]),
67
+ getApiKeyAndHeaders: vi.fn(async () => ({ ok: true, apiKey: "key", headers: { "x-test": "1" } })),
68
+ },
69
+ getCurrentModel: vi.fn(() => undefined),
70
+ getSignal: vi.fn(() => undefined),
71
+ ...overrides,
72
+ } satisfies SamplingTestOptions;
73
+ return options as SamplingHandlerOptions;
74
+ }
75
+
76
+ async function runBasicSampling(
77
+ overrides: Partial<SamplingTestOptions>,
78
+ modelPreferences?: ModelPreferences,
79
+ ): Promise<void> {
80
+ const { handleSamplingRequest } = await import("../sampling-handler.ts");
81
+ await handleSamplingRequest(createOptions(overrides), createSamplingRequest({
82
+ ...(modelPreferences ? { modelPreferences } : {}),
83
+ messages: [{ role: "user", content: { type: "text", text: "Hello" } }],
84
+ maxTokens: 50,
85
+ }));
86
+ }
87
+
88
+ function createSamplingRequest(params: CreateMessageRequest["params"]): CreateMessageRequest {
89
+ return { method: "sampling/createMessage", params };
90
+ }
91
+
92
+ describe("sampling handler", () => {
93
+ beforeEach(() => {
94
+ mocks.complete.mockReset().mockResolvedValue({
95
+ role: "assistant",
96
+ content: [{ type: "text", text: "Bonjour" }],
97
+ api: "anthropic-messages",
98
+ provider: "anthropic",
99
+ model: "claude-sonnet",
100
+ usage,
101
+ stopReason: "stop",
102
+ timestamp: 1,
103
+ });
104
+ });
105
+
106
+ it("converts approved MCP sampling requests into pi-ai completions", async () => {
107
+ const { handleSamplingRequest } = await import("../sampling-handler.ts");
108
+ const result = await handleSamplingRequest(createOptions(), createSamplingRequest({
109
+ systemPrompt: "Translate tersely.",
110
+ messages: [{ role: "user", content: { type: "text", text: "Hello" } }],
111
+ maxTokens: 50,
112
+ temperature: 0.2,
113
+ metadata: { locale: "fr" },
114
+ }));
115
+
116
+ expect(mocks.complete).toHaveBeenCalledWith(
117
+ model,
118
+ {
119
+ systemPrompt: "Translate tersely.",
120
+ messages: [
121
+ {
122
+ role: "user",
123
+ content: [{ type: "text", text: "Hello" }],
124
+ timestamp: expect.any(Number),
125
+ },
126
+ ],
127
+ },
128
+ {
129
+ apiKey: "key",
130
+ headers: { "x-test": "1" },
131
+ maxTokens: 50,
132
+ temperature: 0.2,
133
+ metadata: { locale: "fr" },
134
+ signal: undefined,
135
+ },
136
+ );
137
+ expect(result).toEqual({
138
+ role: "assistant",
139
+ content: { type: "text", text: "Bonjour" },
140
+ model: "anthropic/claude-sonnet",
141
+ stopReason: "endTurn",
142
+ });
143
+ });
144
+
145
+ it("requires UI approval unless auto-approve is enabled", async () => {
146
+ const { handleSamplingRequest } = await import("../sampling-handler.ts");
147
+
148
+ await expect(handleSamplingRequest(
149
+ createOptions({ autoApprove: false, ui: undefined }),
150
+ createSamplingRequest({ messages: [], maxTokens: 50 }),
151
+ )).rejects.toThrow("MCP sampling requires interactive approval");
152
+ expect(mocks.complete).not.toHaveBeenCalled();
153
+ });
154
+
155
+ it("asks for approval with inspectable request and response content", async () => {
156
+ const { handleSamplingRequest } = await import("../sampling-handler.ts");
157
+ const ui = { confirm: vi.fn(async () => true) };
158
+
159
+ await handleSamplingRequest(createOptions({ autoApprove: false, ui }), createSamplingRequest({
160
+ systemPrompt: "Translate tersely.",
161
+ messages: [{ role: "user", content: { type: "text", text: "Hello" } }],
162
+ maxTokens: 50,
163
+ }));
164
+
165
+ expect(ui.confirm).toHaveBeenCalledTimes(2);
166
+ expect(ui.confirm.mock.calls[0][0]).toBe("Approve MCP sampling request");
167
+ expect(ui.confirm.mock.calls[0][1]).toContain("System: Translate tersely.");
168
+ expect(ui.confirm.mock.calls[0][1]).toContain("1. user: Hello");
169
+ expect(ui.confirm.mock.calls[1][0]).toBe("Return MCP sampling response");
170
+ expect(ui.confirm.mock.calls[1][1]).toContain("Bonjour");
171
+ });
172
+
173
+ it("uses model preference hints before the current conversation model", async () => {
174
+ await runBasicSampling({
175
+ modelRegistry: {
176
+ getAvailable: vi.fn(() => [haiku, opus]),
177
+ getApiKeyAndHeaders: vi.fn(async () => ({ ok: true, apiKey: "key" })),
178
+ },
179
+ getCurrentModel: vi.fn(() => opus),
180
+ }, { hints: [{ name: "haiku" }] });
181
+
182
+ expect(mocks.complete.mock.calls[0][0]).toBe(haiku);
183
+ });
184
+
185
+ it("matches model preference hints case-insensitively after trimming", async () => {
186
+ await runBasicSampling({
187
+ modelRegistry: {
188
+ getAvailable: vi.fn(() => [haiku, opus]),
189
+ getApiKeyAndHeaders: vi.fn(async () => ({ ok: true, apiKey: "key" })),
190
+ },
191
+ getCurrentModel: vi.fn(() => opus),
192
+ }, { hints: [{ name: " HAIKU " }] });
193
+
194
+ expect(mocks.complete.mock.calls[0][0]).toBe(haiku);
195
+ });
196
+
197
+ it("matches model preference hints against display names", async () => {
198
+ await runBasicSampling({
199
+ modelRegistry: {
200
+ getAvailable: vi.fn(() => [geminiFlash, opus]),
201
+ getApiKeyAndHeaders: vi.fn(async () => ({ ok: true, apiKey: "key" })),
202
+ },
203
+ getCurrentModel: vi.fn(() => opus),
204
+ }, { hints: [{ name: "2.5 Flash" }] });
205
+
206
+ expect(mocks.complete.mock.calls[0][0]).toBe(geminiFlash);
207
+ });
208
+
209
+ it("matches model preference hints against provider/id", async () => {
210
+ await runBasicSampling({
211
+ modelRegistry: {
212
+ getAvailable: vi.fn(() => [geminiFlash, opus]),
213
+ getApiKeyAndHeaders: vi.fn(async () => ({ ok: true, apiKey: "key" })),
214
+ },
215
+ getCurrentModel: vi.fn(() => opus),
216
+ }, { hints: [{ name: "google/gemini" }] });
217
+
218
+ expect(mocks.complete.mock.calls[0][0]).toBe(geminiFlash);
219
+ });
220
+
221
+ it("preserves preference order across multiple model hints", async () => {
222
+ await runBasicSampling({
223
+ modelRegistry: {
224
+ getAvailable: vi.fn(() => [haiku, geminiFlash, opus]),
225
+ getApiKeyAndHeaders: vi.fn(async () => ({ ok: true, apiKey: "key" })),
226
+ },
227
+ getCurrentModel: vi.fn(() => opus),
228
+ }, { hints: [{ name: "gemini" }, { name: "haiku" }] });
229
+
230
+ expect(mocks.complete.mock.calls[0][0]).toBe(geminiFlash);
231
+ });
232
+
233
+ it("falls back when hinted models do not have configured auth", async () => {
234
+ const getApiKeyAndHeaders = vi.fn(async (candidate: Model<Api>) => {
235
+ if (candidate.id === "claude-haiku") return { ok: false, error: "missing key" };
236
+ return { ok: true, apiKey: "key" };
237
+ });
238
+
239
+ await runBasicSampling({
240
+ modelRegistry: {
241
+ getAvailable: vi.fn(() => [haiku, opus]),
242
+ getApiKeyAndHeaders,
243
+ },
244
+ getCurrentModel: vi.fn(() => opus),
245
+ }, { hints: [{ name: "haiku" }] });
246
+
247
+ expect(getApiKeyAndHeaders).toHaveBeenNthCalledWith(1, haiku);
248
+ expect(getApiKeyAndHeaders).toHaveBeenNthCalledWith(2, opus);
249
+ expect(mocks.complete.mock.calls[0][0]).toBe(opus);
250
+ });
251
+
252
+ it("preserves current-model-first selection when no hints are provided", async () => {
253
+ await runBasicSampling({
254
+ modelRegistry: {
255
+ getAvailable: vi.fn(() => [haiku]),
256
+ getApiKeyAndHeaders: vi.fn(async () => ({ ok: true, apiKey: "key" })),
257
+ },
258
+ getCurrentModel: vi.fn(() => opus),
259
+ });
260
+
261
+ expect(mocks.complete.mock.calls[0][0]).toBe(opus);
262
+ });
263
+
264
+ it("rejects unsupported sampling features loudly", async () => {
265
+ const { handleSamplingRequest } = await import("../sampling-handler.ts");
266
+
267
+ await expect(handleSamplingRequest(createOptions(), createSamplingRequest({
268
+ messages: [{ role: "user", content: { type: "image", data: "abc", mimeType: "image/png" } }],
269
+ maxTokens: 50,
270
+ }))).rejects.toThrow("MCP sampling image content is not supported");
271
+
272
+ await expect(handleSamplingRequest(createOptions(), createSamplingRequest({
273
+ messages: [{ role: "user", content: { type: "audio", data: "abc", mimeType: "audio/wav" } }],
274
+ maxTokens: 50,
275
+ }))).rejects.toThrow("MCP sampling audio content is not supported");
276
+
277
+ await expect(handleSamplingRequest(createOptions(), createSamplingRequest({
278
+ messages: [],
279
+ maxTokens: 50,
280
+ includeContext: "thisServer",
281
+ }))).rejects.toThrow("MCP sampling context inclusion is not supported");
282
+
283
+ expect(mocks.complete).not.toHaveBeenCalled();
284
+ });
285
+ });
@@ -0,0 +1,144 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
+
3
+ type OAuthProviderLike = {
4
+ redirectUrl?: string;
5
+ clientMetadata?: {
6
+ redirect_uris?: string[];
7
+ client_name?: string;
8
+ client_uri?: string;
9
+ };
10
+ };
11
+
12
+ type TransportOptions = {
13
+ requestInit?: {
14
+ headers?: Record<string, string>;
15
+ };
16
+ authProvider?: OAuthProviderLike;
17
+ };
18
+
19
+ type HttpTransportMock = {
20
+ url: URL;
21
+ options: TransportOptions;
22
+ close: () => Promise<void>;
23
+ };
24
+
25
+ const mocks = vi.hoisted(() => ({
26
+ httpTransports: [] as HttpTransportMock[],
27
+ }));
28
+
29
+ vi.mock("@modelcontextprotocol/sdk/client/index.js", () => ({
30
+ Client: vi.fn().mockImplementation((info: unknown, options: unknown) => ({
31
+ info,
32
+ options,
33
+ setRequestHandler: vi.fn(),
34
+ setNotificationHandler: vi.fn(),
35
+ connect: vi.fn(async () => undefined),
36
+ listTools: vi.fn(async () => ({ tools: [] })),
37
+ listResources: vi.fn(async () => ({ resources: [] })),
38
+ close: vi.fn(async () => undefined),
39
+ })),
40
+ }));
41
+
42
+ vi.mock("@modelcontextprotocol/sdk/client/stdio.js", () => ({
43
+ StdioClientTransport: vi.fn(),
44
+ }));
45
+
46
+ vi.mock("@modelcontextprotocol/sdk/client/streamableHttp.js", () => ({
47
+ StreamableHTTPClientTransport: vi.fn().mockImplementation((url: URL, options: TransportOptions) => {
48
+ const transport = { url, options, close: vi.fn(async () => undefined) };
49
+ mocks.httpTransports.push(transport);
50
+ return transport;
51
+ }),
52
+ }));
53
+
54
+ vi.mock("@modelcontextprotocol/sdk/client/sse.js", () => ({
55
+ SSEClientTransport: vi.fn(),
56
+ }));
57
+
58
+ vi.mock("../npx-resolver.ts", () => ({
59
+ resolveNpxBinary: vi.fn(async () => null),
60
+ }));
61
+
62
+ describe("McpServerManager HTTP bearer auth", () => {
63
+ const originalEnv = {
64
+ MCP_TEST_BEARER_TOKEN: process.env.MCP_TEST_BEARER_TOKEN,
65
+ MCP_TEST_BEARER_TOKEN_ENV: process.env.MCP_TEST_BEARER_TOKEN_ENV,
66
+ };
67
+
68
+ beforeEach(() => {
69
+ mocks.httpTransports.length = 0;
70
+ });
71
+
72
+ afterEach(() => {
73
+ for (const [key, value] of Object.entries(originalEnv)) {
74
+ if (value === undefined) {
75
+ delete process.env[key];
76
+ } else {
77
+ process.env[key] = value;
78
+ }
79
+ }
80
+ });
81
+
82
+ it("interpolates ${VAR} bearerToken placeholders", async () => {
83
+ const { McpServerManager } = await import("../server-manager.ts");
84
+ process.env.MCP_TEST_BEARER_TOKEN = "placeholder-token";
85
+
86
+ const manager = new McpServerManager();
87
+ await manager.connect("remote", {
88
+ url: "https://example.test/mcp",
89
+ auth: "bearer",
90
+ bearerToken: "${MCP_TEST_BEARER_TOKEN}",
91
+ });
92
+
93
+ expect(mocks.httpTransports.at(-1)!.options.requestInit?.headers?.Authorization).toBe("Bearer placeholder-token");
94
+ });
95
+
96
+ it("interpolates $env:VAR bearerToken placeholders", async () => {
97
+ const { McpServerManager } = await import("../server-manager.ts");
98
+ process.env.MCP_TEST_BEARER_TOKEN = "env-prefix-token";
99
+
100
+ const manager = new McpServerManager();
101
+ await manager.connect("remote", {
102
+ url: "https://example.test/mcp",
103
+ auth: "bearer",
104
+ bearerToken: "$env:MCP_TEST_BEARER_TOKEN",
105
+ });
106
+
107
+ expect(mocks.httpTransports.at(-1)!.options.requestInit?.headers?.Authorization).toBe("Bearer env-prefix-token");
108
+ });
109
+
110
+ it("keeps bearerTokenEnv support", async () => {
111
+ const { McpServerManager } = await import("../server-manager.ts");
112
+ process.env.MCP_TEST_BEARER_TOKEN_ENV = "named-env-token";
113
+
114
+ const manager = new McpServerManager();
115
+ await manager.connect("remote", {
116
+ url: "https://example.test/mcp",
117
+ auth: "bearer",
118
+ bearerTokenEnv: "MCP_TEST_BEARER_TOKEN_ENV",
119
+ });
120
+
121
+ expect(mocks.httpTransports.at(-1)!.options.requestInit?.headers?.Authorization).toBe("Bearer named-env-token");
122
+ });
123
+
124
+ it("preserves OAuth redirect URI and client metadata for HTTP transports", async () => {
125
+ const { McpServerManager } = await import("../server-manager.ts");
126
+
127
+ const manager = new McpServerManager();
128
+ await manager.connect("remote", {
129
+ url: "https://example.test/mcp",
130
+ auth: "oauth",
131
+ oauth: {
132
+ redirectUri: "http://127.0.0.1:3118/callback",
133
+ clientName: "Custom MCP",
134
+ clientUri: "https://example.com/custom-mcp",
135
+ },
136
+ });
137
+
138
+ const authProvider = mocks.httpTransports.at(-1)!.options.authProvider;
139
+ expect(authProvider?.redirectUrl).toBe("http://127.0.0.1:3118/callback");
140
+ expect(authProvider?.clientMetadata?.redirect_uris).toEqual(["http://127.0.0.1:3118/callback"]);
141
+ expect(authProvider?.clientMetadata?.client_name).toBe("Custom MCP");
142
+ expect(authProvider?.clientMetadata?.client_uri).toBe("https://example.com/custom-mcp");
143
+ });
144
+ });
@@ -0,0 +1,236 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+
5
+ const mocks = vi.hoisted(() => ({
6
+ clients: [] as any[],
7
+ transports: [] as any[],
8
+ open: vi.fn(async () => undefined),
9
+ }));
10
+
11
+ vi.mock("open", () => ({ default: mocks.open }));
12
+
13
+ vi.mock("@modelcontextprotocol/sdk/client/index.js", () => ({
14
+ Client: vi.fn().mockImplementation(function (this: any, info: unknown, options: unknown) {
15
+ this.info = info;
16
+ this.options = options;
17
+ this.setRequestHandler = vi.fn();
18
+ this.setNotificationHandler = vi.fn();
19
+ this.connect = vi.fn(async () => undefined);
20
+ this.listTools = vi.fn(async () => ({ tools: [] }));
21
+ this.listResources = vi.fn(async () => ({ resources: [] }));
22
+ this.close = vi.fn(async () => undefined);
23
+ mocks.clients.push(this);
24
+ }),
25
+ }));
26
+
27
+ vi.mock("@modelcontextprotocol/sdk/client/stdio.js", () => ({
28
+ StdioClientTransport: vi.fn().mockImplementation(function (this: any, options: unknown) {
29
+ this.options = options;
30
+ this.close = vi.fn(async () => undefined);
31
+ mocks.transports.push(this);
32
+ }),
33
+ }));
34
+
35
+ vi.mock("@modelcontextprotocol/sdk/client/streamableHttp.js", () => ({
36
+ StreamableHTTPClientTransport: vi.fn(),
37
+ }));
38
+
39
+ vi.mock("@modelcontextprotocol/sdk/client/sse.js", () => ({
40
+ SSEClientTransport: vi.fn(),
41
+ }));
42
+
43
+ vi.mock("../npx-resolver.ts", () => ({
44
+ resolveNpxBinary: vi.fn(async () => null),
45
+ }));
46
+
47
+ describe("McpServerManager sampling", () => {
48
+ const originalMcpTestCwd = process.env.MCP_TEST_CWD;
49
+
50
+ beforeEach(() => {
51
+ mocks.clients.length = 0;
52
+ mocks.transports.length = 0;
53
+ mocks.open.mockClear();
54
+ });
55
+
56
+ afterEach(() => {
57
+ if (originalMcpTestCwd === undefined) {
58
+ delete process.env.MCP_TEST_CWD;
59
+ } else {
60
+ process.env.MCP_TEST_CWD = originalMcpTestCwd;
61
+ }
62
+ });
63
+
64
+ it("advertises sampling and registers the handler before connecting", async () => {
65
+ const { McpServerManager } = await import("../server-manager.ts");
66
+ const manager = new McpServerManager();
67
+ manager.setSamplingConfig({
68
+ autoApprove: true,
69
+ modelRegistry: {} as any,
70
+ getCurrentModel: () => undefined,
71
+ getSignal: () => undefined,
72
+ });
73
+
74
+ await manager.connect("demo", { command: "node", args: ["server.js"] });
75
+
76
+ const client = mocks.clients[0];
77
+ expect(client.options).toEqual({ capabilities: { sampling: {} } });
78
+ expect(client.setRequestHandler).toHaveBeenCalledTimes(1);
79
+ expect(client.setRequestHandler.mock.invocationCallOrder[0]).toBeLessThan(
80
+ client.connect.mock.invocationCallOrder[0],
81
+ );
82
+ });
83
+
84
+ it("advertises elicitation capabilities and registers the handler before connecting", async () => {
85
+ const { McpServerManager } = await import("../server-manager.ts");
86
+ const manager = new McpServerManager();
87
+ manager.setElicitationConfig({
88
+ allowUrl: true,
89
+ ui: {} as any,
90
+ });
91
+
92
+ await manager.connect("demo", { command: "node", args: ["server.js"] });
93
+
94
+ const client = mocks.clients[0];
95
+ expect(client.options).toEqual({
96
+ capabilities: {
97
+ elicitation: {
98
+ form: {},
99
+ url: {},
100
+ },
101
+ },
102
+ });
103
+ expect(client.setRequestHandler).toHaveBeenCalledTimes(1);
104
+ expect(client.setRequestHandler.mock.invocationCallOrder[0]).toBeLessThan(
105
+ client.connect.mock.invocationCallOrder[0],
106
+ );
107
+ });
108
+
109
+ it("advertises form-only elicitation when URL navigation is unavailable", async () => {
110
+ const { McpServerManager } = await import("../server-manager.ts");
111
+ const manager = new McpServerManager();
112
+ manager.setElicitationConfig({ allowUrl: false, ui: {} as any });
113
+
114
+ await manager.connect("demo", { command: "node", args: ["server.js"] });
115
+
116
+ expect(mocks.clients[0].options).toEqual({
117
+ capabilities: { elicitation: { form: {} } },
118
+ });
119
+ });
120
+
121
+ it("notifies only when a known URL elicitation completes", async () => {
122
+ const { McpServerManager } = await import("../server-manager.ts");
123
+ const ui = {
124
+ select: vi.fn().mockResolvedValue("Open"),
125
+ input: vi.fn(),
126
+ notify: vi.fn(),
127
+ };
128
+ const manager = new McpServerManager();
129
+ manager.setElicitationConfig({ allowUrl: true, ui: ui as any });
130
+ await manager.connect("demo", { command: "node", args: ["server.js"] });
131
+
132
+ const client = mocks.clients[0];
133
+ const requestHandler = client.setRequestHandler.mock.calls[0][1];
134
+ await requestHandler({
135
+ method: "elicitation/create",
136
+ params: {
137
+ mode: "url",
138
+ message: "Connect",
139
+ elicitationId: "known-id",
140
+ url: "https://example.com/connect",
141
+ },
142
+ });
143
+ const completionHandler = client.setNotificationHandler.mock.calls[0][1];
144
+ completionHandler({ params: { elicitationId: "unknown-id" } });
145
+ completionHandler({ params: { elicitationId: "known-id" } });
146
+ completionHandler({ params: { elicitationId: "known-id" } });
147
+
148
+ expect(ui.notify).toHaveBeenCalledWith("Opened browser for MCP elicitation.", "info");
149
+ expect(ui.notify).toHaveBeenCalledWith(
150
+ "MCP browser interaction for demo completed. You can retry the tool now.",
151
+ "info",
152
+ );
153
+ expect(ui.notify).toHaveBeenCalledTimes(2);
154
+ });
155
+
156
+ it("handles every URL in a URL-required error", async () => {
157
+ const { UrlElicitationRequiredError } = await import("@modelcontextprotocol/sdk/types.js");
158
+ const { McpServerManager } = await import("../server-manager.ts");
159
+ const ui = {
160
+ select: vi.fn().mockResolvedValue("Open"),
161
+ input: vi.fn(),
162
+ notify: vi.fn(),
163
+ };
164
+ const manager = new McpServerManager();
165
+ manager.setElicitationConfig({ allowUrl: true, ui: ui as any });
166
+ const result = await manager.handleUrlElicitationRequired("demo", new UrlElicitationRequiredError([
167
+ { mode: "url", message: "First", elicitationId: "one", url: "https://example.com/one" },
168
+ { mode: "url", message: "Second", elicitationId: "two", url: "https://example.com/two" },
169
+ ]));
170
+
171
+ expect(result).toBe("accept");
172
+ expect(mocks.open).toHaveBeenNthCalledWith(1, "https://example.com/one");
173
+ expect(mocks.open).toHaveBeenNthCalledWith(2, "https://example.com/two");
174
+ });
175
+
176
+ it("advertises sampling and elicitation together", async () => {
177
+ const { McpServerManager } = await import("../server-manager.ts");
178
+ const manager = new McpServerManager();
179
+ manager.setSamplingConfig({
180
+ autoApprove: true,
181
+ modelRegistry: {} as any,
182
+ getCurrentModel: () => undefined,
183
+ getSignal: () => undefined,
184
+ });
185
+ manager.setElicitationConfig({
186
+ allowUrl: true,
187
+ ui: {} as any,
188
+ });
189
+
190
+ await manager.connect("demo", { command: "node", args: ["server.js"] });
191
+
192
+ expect(mocks.clients[0].options).toEqual({
193
+ capabilities: {
194
+ sampling: {},
195
+ elicitation: {
196
+ form: {},
197
+ url: {},
198
+ },
199
+ },
200
+ });
201
+ expect(mocks.clients[0].setRequestHandler).toHaveBeenCalledTimes(2);
202
+ });
203
+
204
+ it("does not advertise sampling when no sampling config is set", async () => {
205
+ const { McpServerManager } = await import("../server-manager.ts");
206
+ const manager = new McpServerManager();
207
+
208
+ await manager.connect("demo", { command: "node", args: ["server.js"] });
209
+
210
+ const client = mocks.clients[0];
211
+ expect(client.options).toBeUndefined();
212
+ expect(client.setRequestHandler).not.toHaveBeenCalled();
213
+ });
214
+
215
+ it("expands environment variables and tilde in stdio cwd", async () => {
216
+ const { McpServerManager } = await import("../server-manager.ts");
217
+ process.env.MCP_TEST_CWD = "/tmp/pi-mcp-cwd";
218
+
219
+ const envManager = new McpServerManager();
220
+ await envManager.connect("env-cwd", {
221
+ command: "node",
222
+ args: ["server.js"],
223
+ cwd: "${MCP_TEST_CWD}/nested",
224
+ });
225
+
226
+ const homeManager = new McpServerManager();
227
+ await homeManager.connect("home-cwd", {
228
+ command: "node",
229
+ args: ["server.js"],
230
+ cwd: "~/nested",
231
+ });
232
+
233
+ expect(mocks.transports[0].options).toMatchObject({ cwd: "/tmp/pi-mcp-cwd/nested" });
234
+ expect(mocks.transports[1].options).toMatchObject({ cwd: join(homedir(), "nested") });
235
+ });
236
+ });