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.
- package/ask/index.ts +12 -11
- package/ask/picker.ts +356 -76
- package/ask/prompt.ts +6 -2
- package/ask/tests/picker.test.ts +273 -82
- package/codemap.ts +276 -0
- package/hotreload.ts +239 -0
- package/init.ts +302 -0
- package/mcp/CHANGELOG.md +384 -0
- package/mcp/LICENSE +21 -0
- package/mcp/OAUTH.md +355 -0
- package/mcp/README.md +410 -0
- package/mcp/__tests__/agent-dir-paths.upstream-test.ts +80 -0
- package/mcp/__tests__/cli.upstream-test.ts +97 -0
- package/mcp/__tests__/commands-onboarding.upstream-test.ts +157 -0
- package/mcp/__tests__/config.upstream-test.ts +303 -0
- package/mcp/__tests__/consent-manager.upstream-test.ts +151 -0
- package/mcp/__tests__/direct-tools-auto-auth.upstream-test.ts +218 -0
- package/mcp/__tests__/direct-tools.upstream-test.ts +299 -0
- package/mcp/__tests__/elicitation-handler.upstream-test.ts +346 -0
- package/mcp/__tests__/elicitation-sdk-integration.upstream-test.ts +140 -0
- package/mcp/__tests__/errors.upstream-test.ts +218 -0
- package/mcp/__tests__/fixtures/elicitation-server.mjs +98 -0
- package/mcp/__tests__/host-html-template.upstream-test.ts +278 -0
- package/mcp/__tests__/index-lifecycle.upstream-test.ts +484 -0
- package/mcp/__tests__/init-elicitation.upstream-test.ts +86 -0
- package/mcp/__tests__/interactive-visualizer-server.upstream-test.ts +28 -0
- package/mcp/__tests__/logger.upstream-test.ts +175 -0
- package/mcp/__tests__/mcp-auth-flow-client-credentials.upstream-test.ts +612 -0
- package/mcp/__tests__/mcp-auth-storage.upstream-test.ts +47 -0
- package/mcp/__tests__/mcp-callback-server-unref.upstream-test.ts +264 -0
- package/mcp/__tests__/mcp-oauth-provider.upstream-test.ts +216 -0
- package/mcp/__tests__/mcp-panel-auth.upstream-test.ts +206 -0
- package/mcp/__tests__/mcp-panel-exclude-tools.upstream-test.ts +69 -0
- package/mcp/__tests__/mcp-panel-keybindings.upstream-test.ts +186 -0
- package/mcp/__tests__/npx-resolver.upstream-test.ts +54 -0
- package/mcp/__tests__/oauth-handler.upstream-test.ts +77 -0
- package/mcp/__tests__/onboarding-state.upstream-test.ts +52 -0
- package/mcp/__tests__/package-manifest.upstream-test.ts +22 -0
- package/mcp/__tests__/proxy-modes-auto-auth.upstream-test.ts +253 -0
- package/mcp/__tests__/proxy-modes-discovery.upstream-test.ts +84 -0
- package/mcp/__tests__/proxy-modes-manual-auth.upstream-test.ts +91 -0
- package/mcp/__tests__/proxy-modes-ui-messages.upstream-test.ts +72 -0
- package/mcp/__tests__/sampling-handler.upstream-test.ts +285 -0
- package/mcp/__tests__/server-manager-http-auth.upstream-test.ts +144 -0
- package/mcp/__tests__/server-manager-sampling.upstream-test.ts +236 -0
- package/mcp/__tests__/tool-metadata.upstream-test.ts +111 -0
- package/mcp/__tests__/tool-result-renderer.upstream-test.ts +147 -0
- package/mcp/__tests__/ui-integration.upstream-test.ts +551 -0
- package/mcp/__tests__/ui-resource-handler.upstream-test.ts +303 -0
- package/mcp/__tests__/ui-server.upstream-test.ts +967 -0
- package/mcp/__tests__/ui-session-messages.upstream-test.ts +72 -0
- package/mcp/__tests__/ui-streaming.upstream-test.ts +543 -0
- package/mcp/agent-dir.ts +20 -0
- package/mcp/app-bridge.bundle.js +67 -0
- package/mcp/cli.js +184 -0
- package/mcp/commands.ts +422 -0
- package/mcp/config.ts +666 -0
- package/mcp/consent-manager.ts +64 -0
- package/mcp/direct-tools.ts +439 -0
- package/mcp/elicitation-handler.ts +347 -0
- package/mcp/errors.ts +219 -0
- package/mcp/glimpse-ui.ts +80 -0
- package/mcp/host-html-template.ts +427 -0
- package/mcp/index.ts +362 -0
- package/mcp/init.ts +362 -0
- package/mcp/lifecycle.ts +93 -0
- package/mcp/logger.ts +169 -0
- package/mcp/mcp-auth-flow.ts +559 -0
- package/mcp/mcp-auth-flow.upstream-test.ts +259 -0
- package/mcp/mcp-auth.ts +302 -0
- package/mcp/mcp-auth.upstream-test.ts +373 -0
- package/mcp/mcp-callback-server.ts +372 -0
- package/mcp/mcp-callback-server.upstream-test.ts +416 -0
- package/mcp/mcp-oauth-provider.ts +369 -0
- package/mcp/mcp-oauth-provider.upstream-test.ts +518 -0
- package/mcp/mcp-panel.ts +829 -0
- package/mcp/mcp-setup-panel.ts +580 -0
- package/mcp/metadata-cache.ts +201 -0
- package/mcp/notify.ts +111 -0
- package/mcp/npx-resolver.ts +424 -0
- package/mcp/oauth-handler.ts +57 -0
- package/mcp/onboarding-state.ts +68 -0
- package/mcp/package.json +106 -0
- package/mcp/panel-keys.ts +37 -0
- package/mcp/proxy-modes.ts +949 -0
- package/mcp/resource-tools.ts +17 -0
- package/mcp/sampling-handler.ts +268 -0
- package/mcp/server-manager.ts +545 -0
- package/mcp/state.ts +41 -0
- package/mcp/tool-metadata.ts +216 -0
- package/mcp/tool-registrar.ts +46 -0
- package/mcp/tool-result-renderer.ts +161 -0
- package/mcp/types.ts +448 -0
- package/mcp/ui-resource-handler.ts +146 -0
- package/mcp/ui-server.ts +623 -0
- package/mcp/ui-session.ts +386 -0
- package/mcp/ui-stream-types.ts +89 -0
- package/mcp/utils.ts +129 -0
- package/mcp/vitest.config.ts +14 -0
- package/migrate.ts +258 -0
- package/notification.ts +218 -0
- package/notifications-log.ts +83 -0
- package/package.json +20 -3
- package/status.ts +140 -0
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import type { ElicitRequest } from "@modelcontextprotocol/sdk/types.js";
|
|
3
|
+
|
|
4
|
+
const mocks = vi.hoisted(() => ({
|
|
5
|
+
open: vi.fn(async () => undefined),
|
|
6
|
+
}));
|
|
7
|
+
|
|
8
|
+
vi.mock("open", () => ({ default: mocks.open }));
|
|
9
|
+
|
|
10
|
+
function request(params: ElicitRequest["params"]): ElicitRequest {
|
|
11
|
+
return { method: "elicitation/create", params } as ElicitRequest;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
describe("MCP elicitation", () => {
|
|
15
|
+
beforeEach(() => {
|
|
16
|
+
mocks.open.mockReset();
|
|
17
|
+
mocks.open.mockResolvedValue(undefined);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("collects a form with stock Pi dialogs and lets the user review it before sending", async () => {
|
|
21
|
+
const { handleElicitationRequest } = await import("../elicitation-handler.ts");
|
|
22
|
+
const ui = {
|
|
23
|
+
select: vi.fn()
|
|
24
|
+
.mockResolvedValueOnce("Continue")
|
|
25
|
+
.mockResolvedValueOnce("Enter value")
|
|
26
|
+
.mockResolvedValueOnce("Submit"),
|
|
27
|
+
input: vi.fn().mockResolvedValueOnce("octocat"),
|
|
28
|
+
notify: vi.fn(),
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const result = await handleElicitationRequest(
|
|
32
|
+
{ serverName: "github", ui: ui as any, allowUrl: true },
|
|
33
|
+
request({
|
|
34
|
+
mode: "form",
|
|
35
|
+
message: "Please provide your GitHub username",
|
|
36
|
+
requestedSchema: {
|
|
37
|
+
type: "object",
|
|
38
|
+
properties: {
|
|
39
|
+
username: { type: "string", title: "GitHub username", minLength: 1 },
|
|
40
|
+
},
|
|
41
|
+
required: ["username"],
|
|
42
|
+
},
|
|
43
|
+
}),
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
expect(ui.select.mock.calls[0]).toEqual([
|
|
47
|
+
"MCP Input Request\nServer: github\n\nPlease provide your GitHub username",
|
|
48
|
+
["Continue", "Decline"],
|
|
49
|
+
]);
|
|
50
|
+
expect(ui.input).toHaveBeenCalledWith("GitHub username (required)", undefined);
|
|
51
|
+
expect(ui.select.mock.calls[2][0]).toContain("GitHub username: octocat");
|
|
52
|
+
expect(result).toEqual({ action: "accept", content: { username: "octocat" } });
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("lets the user edit a value from the review screen", async () => {
|
|
56
|
+
const { handleElicitationRequest } = await import("../elicitation-handler.ts");
|
|
57
|
+
const ui = {
|
|
58
|
+
select: vi.fn()
|
|
59
|
+
.mockResolvedValueOnce("Continue")
|
|
60
|
+
.mockResolvedValueOnce("Enter value")
|
|
61
|
+
.mockResolvedValueOnce("Edit")
|
|
62
|
+
.mockResolvedValueOnce("Name (name)")
|
|
63
|
+
.mockResolvedValueOnce("Enter value")
|
|
64
|
+
.mockResolvedValueOnce("Submit"),
|
|
65
|
+
input: vi.fn().mockResolvedValueOnce("Old").mockResolvedValueOnce("New"),
|
|
66
|
+
notify: vi.fn(),
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const result = await handleElicitationRequest(
|
|
70
|
+
{ serverName: "demo", ui: ui as any, allowUrl: true },
|
|
71
|
+
request({
|
|
72
|
+
mode: "form",
|
|
73
|
+
message: "Choose a name",
|
|
74
|
+
requestedSchema: {
|
|
75
|
+
type: "object",
|
|
76
|
+
properties: { name: { type: "string", title: "Name" } },
|
|
77
|
+
},
|
|
78
|
+
}),
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
expect(ui.input.mock.calls[1]).toEqual(["Name", "Old"]);
|
|
82
|
+
expect(result).toEqual({ action: "accept", content: { name: "New" } });
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("validates form values and lets the user correct invalid input", async () => {
|
|
86
|
+
const { handleElicitationRequest } = await import("../elicitation-handler.ts");
|
|
87
|
+
const ui = {
|
|
88
|
+
select: vi.fn()
|
|
89
|
+
.mockResolvedValueOnce("Continue")
|
|
90
|
+
.mockResolvedValueOnce("Enter value")
|
|
91
|
+
.mockResolvedValueOnce("Enter value")
|
|
92
|
+
.mockResolvedValueOnce("Submit"),
|
|
93
|
+
input: vi.fn()
|
|
94
|
+
.mockResolvedValueOnce("not-an-email")
|
|
95
|
+
.mockResolvedValueOnce("octocat@example.com"),
|
|
96
|
+
notify: vi.fn(),
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
const result = await handleElicitationRequest(
|
|
100
|
+
{ serverName: "demo", ui: ui as any, allowUrl: true },
|
|
101
|
+
request({
|
|
102
|
+
mode: "form",
|
|
103
|
+
message: "Contact details",
|
|
104
|
+
requestedSchema: {
|
|
105
|
+
type: "object",
|
|
106
|
+
properties: {
|
|
107
|
+
email: { type: "string", format: "email" },
|
|
108
|
+
},
|
|
109
|
+
required: ["email"],
|
|
110
|
+
},
|
|
111
|
+
}),
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
expect(ui.notify).toHaveBeenCalledWith(expect.stringContaining("email"), "error");
|
|
115
|
+
expect(result).toEqual({ action: "accept", content: { email: "octocat@example.com" } });
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it.each([
|
|
119
|
+
["number", false],
|
|
120
|
+
["number", true],
|
|
121
|
+
["integer", false],
|
|
122
|
+
["integer", true],
|
|
123
|
+
] as const)("rejects blank %s input and reprompts when required=%s", async (type, required) => {
|
|
124
|
+
const { handleElicitationRequest } = await import("../elicitation-handler.ts");
|
|
125
|
+
const ui = {
|
|
126
|
+
select: vi.fn()
|
|
127
|
+
.mockResolvedValueOnce("Continue")
|
|
128
|
+
.mockResolvedValueOnce("Enter value")
|
|
129
|
+
.mockResolvedValueOnce("Enter value")
|
|
130
|
+
.mockResolvedValueOnce("Submit"),
|
|
131
|
+
input: vi.fn().mockResolvedValueOnce(" ").mockResolvedValueOnce("7"),
|
|
132
|
+
notify: vi.fn(),
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
const result = await handleElicitationRequest(
|
|
136
|
+
{ serverName: "demo", ui: ui as any, allowUrl: false },
|
|
137
|
+
request({
|
|
138
|
+
mode: "form",
|
|
139
|
+
message: "Choose a quantity",
|
|
140
|
+
requestedSchema: {
|
|
141
|
+
type: "object",
|
|
142
|
+
properties: { quantity: { type } },
|
|
143
|
+
...(required ? { required: ["quantity"] } : {}),
|
|
144
|
+
},
|
|
145
|
+
}),
|
|
146
|
+
);
|
|
147
|
+
|
|
148
|
+
expect(ui.notify).toHaveBeenCalledWith("Elicitation field quantity must be a number", "error");
|
|
149
|
+
expect(ui.input).toHaveBeenCalledTimes(2);
|
|
150
|
+
expect(result).toEqual({ action: "accept", content: { quantity: 7 } });
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("maps explicit refusal and dialog dismissal to decline and cancel", async () => {
|
|
154
|
+
const { handleElicitationRequest } = await import("../elicitation-handler.ts");
|
|
155
|
+
const params = request({
|
|
156
|
+
mode: "form",
|
|
157
|
+
message: "Provide a value",
|
|
158
|
+
requestedSchema: { type: "object", properties: {} },
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
await expect(handleElicitationRequest({
|
|
162
|
+
serverName: "demo",
|
|
163
|
+
ui: { select: vi.fn().mockResolvedValue("Decline") } as any,
|
|
164
|
+
allowUrl: true,
|
|
165
|
+
}, params)).resolves.toEqual({ action: "decline" });
|
|
166
|
+
await expect(handleElicitationRequest({
|
|
167
|
+
serverName: "demo",
|
|
168
|
+
ui: { select: vi.fn().mockResolvedValue(undefined) } as any,
|
|
169
|
+
allowUrl: true,
|
|
170
|
+
}, params)).resolves.toEqual({ action: "cancel" });
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it("does not open URL elicitations that are declined or dismissed", async () => {
|
|
174
|
+
const { handleElicitationRequest } = await import("../elicitation-handler.ts");
|
|
175
|
+
const params = request({
|
|
176
|
+
mode: "url",
|
|
177
|
+
message: "Authorize",
|
|
178
|
+
elicitationId: "auth-1",
|
|
179
|
+
url: "https://example.com/authorize",
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
await expect(handleElicitationRequest({
|
|
183
|
+
serverName: "demo",
|
|
184
|
+
ui: { select: vi.fn().mockResolvedValue("Decline") } as any,
|
|
185
|
+
allowUrl: true,
|
|
186
|
+
}, params)).resolves.toEqual({ action: "decline" });
|
|
187
|
+
await expect(handleElicitationRequest({
|
|
188
|
+
serverName: "demo",
|
|
189
|
+
ui: { select: vi.fn().mockResolvedValue(undefined) } as any,
|
|
190
|
+
allowUrl: true,
|
|
191
|
+
}, params)).resolves.toEqual({ action: "cancel" });
|
|
192
|
+
expect(mocks.open).not.toHaveBeenCalled();
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it("shows the server, host, and full URL before opening an accepted URL elicitation", async () => {
|
|
196
|
+
const { handleElicitationRequest } = await import("../elicitation-handler.ts");
|
|
197
|
+
const onUrlAccepted = vi.fn();
|
|
198
|
+
const ui = {
|
|
199
|
+
select: vi.fn().mockResolvedValueOnce("Open"),
|
|
200
|
+
input: vi.fn(),
|
|
201
|
+
notify: vi.fn(),
|
|
202
|
+
};
|
|
203
|
+
const url = "https://checkout.example.com/authorize?state=a%2Fb";
|
|
204
|
+
|
|
205
|
+
const result = await handleElicitationRequest(
|
|
206
|
+
{ serverName: "payments", ui: ui as any, allowUrl: true, onUrlAccepted },
|
|
207
|
+
request({
|
|
208
|
+
mode: "url",
|
|
209
|
+
message: "Authorize the payment provider",
|
|
210
|
+
elicitationId: "payment-1",
|
|
211
|
+
url,
|
|
212
|
+
}),
|
|
213
|
+
);
|
|
214
|
+
|
|
215
|
+
expect(ui.select).toHaveBeenCalledWith([
|
|
216
|
+
"MCP Browser Request",
|
|
217
|
+
"Server: payments",
|
|
218
|
+
"",
|
|
219
|
+
"Authorize the payment provider",
|
|
220
|
+
"",
|
|
221
|
+
"Host: checkout.example.com",
|
|
222
|
+
`Full URL: ${url}`,
|
|
223
|
+
"",
|
|
224
|
+
"Open this URL in your browser?",
|
|
225
|
+
].join("\n"), ["Open", "Decline"]);
|
|
226
|
+
expect(mocks.open).toHaveBeenCalledWith(url);
|
|
227
|
+
expect(onUrlAccepted).toHaveBeenCalledWith("payment-1");
|
|
228
|
+
expect(result).toEqual({ action: "accept" });
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it("rejects URL mode when the client advertised form-only support", async () => {
|
|
232
|
+
const { handleElicitationRequest } = await import("../elicitation-handler.ts");
|
|
233
|
+
const ui = { select: vi.fn(), input: vi.fn(), notify: vi.fn() };
|
|
234
|
+
|
|
235
|
+
await expect(handleElicitationRequest(
|
|
236
|
+
{ serverName: "demo", ui: ui as any, allowUrl: false },
|
|
237
|
+
request({
|
|
238
|
+
mode: "url",
|
|
239
|
+
message: "Authorize",
|
|
240
|
+
elicitationId: "auth-1",
|
|
241
|
+
url: "https://example.com/authorize",
|
|
242
|
+
}),
|
|
243
|
+
)).rejects.toMatchObject({ code: -32602 });
|
|
244
|
+
expect(ui.select).not.toHaveBeenCalled();
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
it("rejects URL schemes that cannot be opened safely in a browser", async () => {
|
|
248
|
+
const { handleElicitationRequest } = await import("../elicitation-handler.ts");
|
|
249
|
+
const ui = { select: vi.fn(), input: vi.fn(), notify: vi.fn() };
|
|
250
|
+
|
|
251
|
+
await expect(handleElicitationRequest(
|
|
252
|
+
{ serverName: "demo", ui: ui as any, allowUrl: true },
|
|
253
|
+
request({
|
|
254
|
+
mode: "url",
|
|
255
|
+
message: "Open a file",
|
|
256
|
+
elicitationId: "file-1",
|
|
257
|
+
url: "file:///etc/passwd",
|
|
258
|
+
}),
|
|
259
|
+
)).rejects.toMatchObject({ code: -32602 });
|
|
260
|
+
expect(ui.select).not.toHaveBeenCalled();
|
|
261
|
+
expect(mocks.open).not.toHaveBeenCalled();
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
it("cancels URL elicitation when the browser cannot be opened", async () => {
|
|
265
|
+
const { handleElicitationRequest } = await import("../elicitation-handler.ts");
|
|
266
|
+
mocks.open.mockRejectedValueOnce(new Error("no browser"));
|
|
267
|
+
const ui = {
|
|
268
|
+
select: vi.fn().mockResolvedValueOnce("Open"),
|
|
269
|
+
input: vi.fn(),
|
|
270
|
+
notify: vi.fn(),
|
|
271
|
+
};
|
|
272
|
+
|
|
273
|
+
const result = await handleElicitationRequest(
|
|
274
|
+
{ serverName: "demo", ui: ui as any, allowUrl: true },
|
|
275
|
+
request({
|
|
276
|
+
mode: "url",
|
|
277
|
+
message: "Authorize",
|
|
278
|
+
elicitationId: "auth-1",
|
|
279
|
+
url: "https://example.com/authorize",
|
|
280
|
+
}),
|
|
281
|
+
);
|
|
282
|
+
|
|
283
|
+
expect(result).toEqual({ action: "cancel" });
|
|
284
|
+
expect(ui.notify).toHaveBeenCalledWith(expect.stringContaining("no browser"), "error");
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
it("supports every primitive form field, defaults, and omission", async () => {
|
|
288
|
+
const { handleElicitationRequest } = await import("../elicitation-handler.ts");
|
|
289
|
+
const ui = {
|
|
290
|
+
select: vi.fn()
|
|
291
|
+
.mockResolvedValueOnce("Continue")
|
|
292
|
+
.mockResolvedValueOnce("Use default")
|
|
293
|
+
.mockResolvedValueOnce("Medium (medium)")
|
|
294
|
+
.mockResolvedValueOnce("No")
|
|
295
|
+
.mockResolvedValueOnce("Enter value")
|
|
296
|
+
.mockResolvedValueOnce("Choose values")
|
|
297
|
+
.mockResolvedValueOnce("Red")
|
|
298
|
+
.mockResolvedValueOnce("Done")
|
|
299
|
+
.mockResolvedValueOnce("Omit")
|
|
300
|
+
.mockResolvedValueOnce("Submit"),
|
|
301
|
+
input: vi.fn().mockResolvedValueOnce("42"),
|
|
302
|
+
notify: vi.fn(),
|
|
303
|
+
};
|
|
304
|
+
|
|
305
|
+
const result = await handleElicitationRequest(
|
|
306
|
+
{ serverName: "demo", ui: ui as any, allowUrl: true },
|
|
307
|
+
request({
|
|
308
|
+
mode: "form",
|
|
309
|
+
message: "Configure the operation",
|
|
310
|
+
requestedSchema: {
|
|
311
|
+
type: "object",
|
|
312
|
+
properties: {
|
|
313
|
+
title: { type: "string", default: "Untitled" },
|
|
314
|
+
priority: {
|
|
315
|
+
type: "string",
|
|
316
|
+
oneOf: [
|
|
317
|
+
{ const: "low", title: "Low" },
|
|
318
|
+
{ const: "medium", title: "Medium" },
|
|
319
|
+
],
|
|
320
|
+
},
|
|
321
|
+
enabled: { type: "boolean" },
|
|
322
|
+
count: { type: "integer", minimum: 1, maximum: 100 },
|
|
323
|
+
colors: {
|
|
324
|
+
type: "array",
|
|
325
|
+
items: { type: "string", enum: ["Red", "Blue"] },
|
|
326
|
+
minItems: 1,
|
|
327
|
+
},
|
|
328
|
+
note: { type: "string" },
|
|
329
|
+
},
|
|
330
|
+
required: ["priority", "enabled", "count", "colors"],
|
|
331
|
+
},
|
|
332
|
+
}),
|
|
333
|
+
);
|
|
334
|
+
|
|
335
|
+
expect(result).toEqual({
|
|
336
|
+
action: "accept",
|
|
337
|
+
content: {
|
|
338
|
+
title: "Untitled",
|
|
339
|
+
priority: "medium",
|
|
340
|
+
enabled: false,
|
|
341
|
+
count: 42,
|
|
342
|
+
colors: ["Red"],
|
|
343
|
+
},
|
|
344
|
+
});
|
|
345
|
+
});
|
|
346
|
+
});
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { fileURLToPath } from "node:url";
|
|
3
|
+
import type { ExtensionMode, ExtensionUIContext } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
import { createDirectToolExecutor } from "../direct-tools.ts";
|
|
5
|
+
import { isTuiMode } from "../init.ts";
|
|
6
|
+
import { executeCall } from "../proxy-modes.ts";
|
|
7
|
+
import { McpServerManager } from "../server-manager.ts";
|
|
8
|
+
import type { McpExtensionState } from "../state.ts";
|
|
9
|
+
import type { DirectToolSpec, ToolMetadata } from "../types.ts";
|
|
10
|
+
import { UiResourceHandler } from "../ui-resource-handler.ts";
|
|
11
|
+
|
|
12
|
+
const mocks = vi.hoisted(() => ({ open: vi.fn(async () => undefined) }));
|
|
13
|
+
vi.mock("open", () => ({ default: mocks.open }));
|
|
14
|
+
|
|
15
|
+
const fixture = fileURLToPath(new URL("./fixtures/elicitation-server.mjs", import.meta.url));
|
|
16
|
+
const definition = { command: process.execPath, args: [fixture] };
|
|
17
|
+
const managers: McpServerManager[] = [];
|
|
18
|
+
|
|
19
|
+
function createUi(answers: string[] = []): ExtensionUIContext {
|
|
20
|
+
return {
|
|
21
|
+
select: vi.fn(async () => answers.shift()),
|
|
22
|
+
input: vi.fn(async () => "stock-pi-user"),
|
|
23
|
+
notify: vi.fn(),
|
|
24
|
+
} as unknown as ExtensionUIContext;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async function createConnectedManager(mode: ExtensionMode, answers: string[] = []) {
|
|
28
|
+
const ui = createUi(answers);
|
|
29
|
+
const manager = new McpServerManager();
|
|
30
|
+
manager.setElicitationConfig({
|
|
31
|
+
ui,
|
|
32
|
+
allowUrl: isTuiMode({ hasUI: true, mode }),
|
|
33
|
+
});
|
|
34
|
+
await manager.connect("real", definition);
|
|
35
|
+
managers.push(manager);
|
|
36
|
+
return { manager, ui };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function createState(manager: McpServerManager, metadata: ToolMetadata[]): McpExtensionState {
|
|
40
|
+
return {
|
|
41
|
+
manager,
|
|
42
|
+
config: { settings: {}, mcpServers: { real: definition } },
|
|
43
|
+
toolMetadata: new Map([["real", metadata]]),
|
|
44
|
+
failureTracker: new Map(),
|
|
45
|
+
uiResourceHandler: new UiResourceHandler(manager),
|
|
46
|
+
completedUiSessions: [],
|
|
47
|
+
uiServer: null,
|
|
48
|
+
} as McpExtensionState;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function resultText(result: { content?: Array<{ type: string; text?: string }> }): string {
|
|
52
|
+
return result.content?.find(item => item.type === "text")?.text ?? "";
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
describe("elicitation with the real MCP SDK", () => {
|
|
56
|
+
beforeEach(() => mocks.open.mockClear());
|
|
57
|
+
|
|
58
|
+
afterEach(async () => {
|
|
59
|
+
await Promise.all(managers.splice(0).map(manager => manager.closeAll()));
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it.each([
|
|
63
|
+
["tui", { form: {}, url: {} }],
|
|
64
|
+
["rpc", { form: {} }],
|
|
65
|
+
] as const)("advertises stock Pi %s capabilities and completes form elicitation", async (mode, capabilities) => {
|
|
66
|
+
const { manager } = await createConnectedManager(mode, ["Continue", "Enter value", "Submit"]);
|
|
67
|
+
const connection = manager.getConnection("real")!;
|
|
68
|
+
|
|
69
|
+
const capabilityResult = await connection.client.callTool({ name: "capabilities", arguments: {} });
|
|
70
|
+
expect(JSON.parse(resultText(capabilityResult))).toEqual(capabilities);
|
|
71
|
+
|
|
72
|
+
const formResult = await connection.client.callTool({ name: "form", arguments: {} });
|
|
73
|
+
expect(JSON.parse(resultText(formResult))).toEqual({
|
|
74
|
+
action: "accept",
|
|
75
|
+
content: { name: "stock-pi-user" },
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("rejects URL elicitation over real SDK dispatch in stock Pi RPC mode", async () => {
|
|
80
|
+
const { manager } = await createConnectedManager("rpc");
|
|
81
|
+
const connection = manager.getConnection("real")!;
|
|
82
|
+
|
|
83
|
+
await expect(connection.client.callTool({ name: "url", arguments: {} })).rejects.toThrow(
|
|
84
|
+
/does not support URL-mode elicitation requests/,
|
|
85
|
+
);
|
|
86
|
+
expect(mocks.open).not.toHaveBeenCalled();
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("dispatches URL elicitation and its completion notification over the real SDK", async () => {
|
|
90
|
+
const { manager, ui } = await createConnectedManager("tui", ["Open"]);
|
|
91
|
+
const connection = manager.getConnection("real")!;
|
|
92
|
+
|
|
93
|
+
const result = await connection.client.callTool({ name: "url", arguments: {} });
|
|
94
|
+
|
|
95
|
+
expect(JSON.parse(resultText(result))).toEqual({ action: "accept" });
|
|
96
|
+
expect(mocks.open).toHaveBeenCalledWith("https://example.com/authorize");
|
|
97
|
+
expect(ui.notify).toHaveBeenCalledWith(
|
|
98
|
+
"MCP browser interaction for real completed. You can retry the tool now.",
|
|
99
|
+
"info",
|
|
100
|
+
);
|
|
101
|
+
expect(ui.notify).toHaveBeenCalledTimes(2);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it.each([
|
|
105
|
+
["proxy tool", { originalName: "url-required" }, "proxy"],
|
|
106
|
+
["direct tool", { originalName: "url-required" }, "direct"],
|
|
107
|
+
["proxy resource", { originalName: "resource", resourceUri: "test://url-required" }, "proxy"],
|
|
108
|
+
["direct resource", { originalName: "resource", resourceUri: "test://url-required" }, "direct"],
|
|
109
|
+
["proxy UI resource", { originalName: "app", uiResourceUri: "ui://url-required" }, "proxy"],
|
|
110
|
+
["direct UI resource", { originalName: "app", uiResourceUri: "ui://url-required" }, "direct"],
|
|
111
|
+
] as const)("handles real -32042 errors from a %s", async (_label, spec, adapter) => {
|
|
112
|
+
const { manager } = await createConnectedManager("tui", ["Open"]);
|
|
113
|
+
const metadata: ToolMetadata = {
|
|
114
|
+
name: `real_${spec.originalName}`,
|
|
115
|
+
description: "integration test",
|
|
116
|
+
...spec,
|
|
117
|
+
};
|
|
118
|
+
const state = createState(manager, [metadata]);
|
|
119
|
+
|
|
120
|
+
const result = adapter === "proxy"
|
|
121
|
+
? await executeCall(state, metadata.name, {}, "real")
|
|
122
|
+
: await createDirectToolExecutor(
|
|
123
|
+
() => state,
|
|
124
|
+
() => null,
|
|
125
|
+
{
|
|
126
|
+
serverName: "real",
|
|
127
|
+
prefixedName: metadata.name,
|
|
128
|
+
description: metadata.description,
|
|
129
|
+
...spec,
|
|
130
|
+
} as DirectToolSpec,
|
|
131
|
+
)("id", {});
|
|
132
|
+
|
|
133
|
+
expect(result.details).toMatchObject({ error: "url_elicitation_required", action: "accept" });
|
|
134
|
+
expect(result.content[0]).toMatchObject({
|
|
135
|
+
type: "text",
|
|
136
|
+
text: expect.stringContaining("retry the tool"),
|
|
137
|
+
});
|
|
138
|
+
expect(mocks.open).toHaveBeenCalledWith("https://example.com/connect");
|
|
139
|
+
});
|
|
140
|
+
});
|