pi-mcp-adapter 2.8.0 → 2.9.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/CHANGELOG.md CHANGED
@@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [2.9.0] - 2026-06-04
11
+
12
+ ### Added
13
+ - Added MCP elicitation support with Pi form prompts and browser-opening URL requests.
14
+
15
+ ### Fixed
16
+ - Rejected non-http/https MCP URL elicitations before prompting or opening a browser.
17
+ - Preserved empty string form values for MCP string elicitations unless schema constraints reject them.
18
+
10
19
  ## [2.8.0] - 2026-05-25
11
20
 
12
21
  ### Added
package/README.md CHANGED
@@ -169,9 +169,15 @@ For pre-registered browser OAuth clients, set `oauth.redirectUri` to the exact c
169
169
  | `autoAuth` | Auto-run OAuth on `connect`/tool calls when a server needs auth, then retry once (default: false). |
170
170
  | `sampling` | Allow MCP servers to sample through Pi models, honoring `modelPreferences.hints` before current/default fallback (default: true when UI approval is available). |
171
171
  | `samplingAutoApprove` | Skip sampling confirmation prompts. Required for sampling in non-UI sessions (default: false). |
172
+ | `elicitation` | Allow MCP servers to request user input through Pi UI forms/URL prompts (default: true when Pi UI form support is available). |
173
+ | `elicitationAutoOpenUrls` | Automatically open URL elicitations without prompting first (default: false). |
172
174
 
173
175
  Per-server `idleTimeout` overrides the global setting.
174
176
 
177
+ ### MCP Elicitation
178
+
179
+ When Pi exposes UI, the adapter advertises MCP elicitation support. Form elicitations are rendered with `ctx.ui.form()` and map Pi actions to MCP actions: submit → `accept`, secondary → `decline`, cancel → `cancel`. URL elicitations prompt before opening a browser unless `elicitationAutoOpenUrls` is enabled.
180
+
175
181
  ### Direct Tools
176
182
 
177
183
  By default, all MCP tools are accessed through the single `mcp` proxy tool. This keeps context small but means the LLM has to discover MCP tools via proxy search. If you want specific tools to show up directly in the agent's tool list — alongside `read`, `bash`, `edit`, etc. — add `directTools` to your config.
@@ -0,0 +1,353 @@
1
+ import type { ExtensionUIContext } from "@earendil-works/pi-coding-agent";
2
+ import type { Client } from "@modelcontextprotocol/sdk/client/index.js";
3
+ import {
4
+ ElicitRequestSchema,
5
+ type ElicitRequest,
6
+ type ElicitRequestFormParams,
7
+ type ElicitRequestURLParams,
8
+ type ElicitResult,
9
+ } from "@modelcontextprotocol/sdk/types.js";
10
+ import open from "open";
11
+
12
+ export type ExtensionUIFormValue = string | number | boolean | string[] | undefined;
13
+
14
+ export interface ExtensionUIFormSelectOption {
15
+ value: string;
16
+ label?: string;
17
+ description?: string;
18
+ }
19
+
20
+ export type ExtensionUIFormField =
21
+ | {
22
+ type: "text";
23
+ name: string;
24
+ label: string;
25
+ description?: string;
26
+ placeholder?: string;
27
+ required?: boolean;
28
+ defaultValue?: string;
29
+ minLength?: number;
30
+ maxLength?: number;
31
+ pattern?: string;
32
+ }
33
+ | {
34
+ type: "number" | "integer";
35
+ name: string;
36
+ label: string;
37
+ description?: string;
38
+ required?: boolean;
39
+ defaultValue?: number;
40
+ minimum?: number;
41
+ maximum?: number;
42
+ }
43
+ | {
44
+ type: "boolean";
45
+ name: string;
46
+ label: string;
47
+ description?: string;
48
+ defaultValue?: boolean;
49
+ }
50
+ | {
51
+ type: "select";
52
+ name: string;
53
+ label: string;
54
+ description?: string;
55
+ required?: boolean;
56
+ options: ExtensionUIFormSelectOption[];
57
+ defaultValue?: string;
58
+ }
59
+ | {
60
+ type: "multiSelect";
61
+ name: string;
62
+ label: string;
63
+ description?: string;
64
+ required?: boolean;
65
+ options: ExtensionUIFormSelectOption[];
66
+ defaultValue?: string[];
67
+ };
68
+
69
+ export interface ExtensionUIFormRequest {
70
+ title: string;
71
+ message?: string;
72
+ fields: ExtensionUIFormField[];
73
+ submitLabel?: string;
74
+ secondaryLabel?: string;
75
+ cancelLabel?: string;
76
+ }
77
+
78
+ export type ExtensionUIFormResult =
79
+ | { action: "submit"; values: Record<string, ExtensionUIFormValue> }
80
+ | { action: "secondary" }
81
+ | { action: "cancel" };
82
+
83
+ export interface ElicitationUIContext extends ExtensionUIContext {
84
+ form(request: ExtensionUIFormRequest): Promise<ExtensionUIFormResult>;
85
+ }
86
+
87
+ export interface ElicitationHandlerOptions {
88
+ serverName: string;
89
+ ui: ElicitationUIContext;
90
+ autoOpenUrls: boolean;
91
+ }
92
+
93
+ export type ServerElicitationConfig = Omit<ElicitationHandlerOptions, "serverName">;
94
+
95
+ export function registerElicitationHandler(client: Client, options: ElicitationHandlerOptions): void {
96
+ client.setRequestHandler(ElicitRequestSchema, (request) => {
97
+ return handleElicitationRequest(options, request as ElicitRequest);
98
+ });
99
+ }
100
+
101
+ export async function handleElicitationRequest(
102
+ options: ElicitationHandlerOptions,
103
+ request: ElicitRequest,
104
+ ): Promise<ElicitResult> {
105
+ const params = request.params;
106
+ if (params.mode === "url") {
107
+ return handleUrlElicitation(options, params);
108
+ }
109
+ return handleFormElicitation(options, params);
110
+ }
111
+
112
+ export async function handleFormElicitation(
113
+ options: ElicitationHandlerOptions,
114
+ params: ElicitRequestFormParams,
115
+ ): Promise<ElicitResult> {
116
+ const form = convertMcpSchemaToPiForm(options.serverName, params);
117
+ const result = await options.ui.form(form);
118
+ if (result.action !== "submit") {
119
+ return convertPiFormResultToMcpResult(result);
120
+ }
121
+ return {
122
+ action: "accept",
123
+ content: coerceAndValidateFormValues(params, result.values),
124
+ };
125
+ }
126
+
127
+ export async function handleUrlElicitation(
128
+ options: ElicitationHandlerOptions,
129
+ params: ElicitRequestURLParams,
130
+ ): Promise<ElicitResult> {
131
+ const browserUrl = getBrowserElicitationUrl(params.url);
132
+ if (!options.autoOpenUrls) {
133
+ const result = await options.ui.form({
134
+ title: "MCP Browser Request",
135
+ message: [
136
+ `Server: ${options.serverName}`,
137
+ "",
138
+ params.message,
139
+ "",
140
+ `Domain: ${browserUrl.host}`,
141
+ `URL: ${browserUrl.toString()}`,
142
+ "",
143
+ "Open this URL in your browser?",
144
+ ].join("\n"),
145
+ fields: [],
146
+ submitLabel: "Open",
147
+ secondaryLabel: "Decline",
148
+ cancelLabel: "Cancel",
149
+ });
150
+ if (result.action === "secondary") return { action: "decline" };
151
+ if (result.action === "cancel") return { action: "cancel" };
152
+ }
153
+
154
+ await open(browserUrl.toString());
155
+ options.ui.notify("Opened browser for MCP elicitation.", "info");
156
+ return { action: "accept" };
157
+ }
158
+
159
+ export function convertMcpSchemaToPiForm(
160
+ serverName: string,
161
+ params: ElicitRequestFormParams,
162
+ ): ExtensionUIFormRequest {
163
+ const required = new Set(params.requestedSchema.required ?? []);
164
+ return {
165
+ title: "MCP Input Request",
166
+ message: `Server: ${serverName}\n\n${params.message}`,
167
+ submitLabel: "Submit",
168
+ secondaryLabel: "Decline",
169
+ cancelLabel: "Cancel",
170
+ fields: Object.entries(params.requestedSchema.properties).map(([name, schema]): ExtensionUIFormField => {
171
+ const label = schema.title ?? humanizeName(name);
172
+ const base = {
173
+ name,
174
+ label,
175
+ description: schema.description,
176
+ required: required.has(name),
177
+ };
178
+
179
+ if (schema.type === "string" && "oneOf" in schema && Array.isArray(schema.oneOf)) {
180
+ return omitUndefined({
181
+ ...base,
182
+ type: "select" as const,
183
+ options: schema.oneOf.map((option) => ({ value: option.const, label: option.title })),
184
+ defaultValue: schema.default,
185
+ });
186
+ }
187
+
188
+ if (schema.type === "string" && "enum" in schema && Array.isArray(schema.enum)) {
189
+ const enumNames = "enumNames" in schema && Array.isArray(schema.enumNames) ? schema.enumNames : undefined;
190
+ return omitUndefined({
191
+ ...base,
192
+ type: "select" as const,
193
+ options: schema.enum.map((value, index) => omitUndefined({ value, label: enumNames?.[index] })),
194
+ defaultValue: schema.default,
195
+ });
196
+ }
197
+
198
+ if (schema.type === "array") {
199
+ return omitUndefined({
200
+ ...base,
201
+ type: "multiSelect" as const,
202
+ options: extractMultiSelectOptions(schema),
203
+ defaultValue: schema.default,
204
+ });
205
+ }
206
+
207
+ if (schema.type === "number" || schema.type === "integer") {
208
+ return omitUndefined({
209
+ ...base,
210
+ type: schema.type,
211
+ defaultValue: schema.default,
212
+ minimum: schema.minimum,
213
+ maximum: schema.maximum,
214
+ });
215
+ }
216
+
217
+ if (schema.type === "boolean") {
218
+ return omitUndefined({
219
+ type: "boolean" as const,
220
+ name,
221
+ label,
222
+ description: schema.description,
223
+ defaultValue: schema.default,
224
+ });
225
+ }
226
+
227
+ const stringSchema = schema as { default?: string; minLength?: number; maxLength?: number };
228
+ return omitUndefined({
229
+ ...base,
230
+ type: "text" as const,
231
+ defaultValue: stringSchema.default,
232
+ minLength: stringSchema.minLength,
233
+ maxLength: stringSchema.maxLength,
234
+ });
235
+ }),
236
+ };
237
+ }
238
+
239
+ export function convertPiFormResultToMcpResult(result: ExtensionUIFormResult): ElicitResult {
240
+ if (result.action === "secondary") return { action: "decline" };
241
+ if (result.action === "cancel") return { action: "cancel" };
242
+ return { action: "accept", content: stripUndefined(result.values) as ElicitResult["content"] };
243
+ }
244
+
245
+ export function coerceAndValidateFormValues(
246
+ params: ElicitRequestFormParams,
247
+ values: Record<string, ExtensionUIFormValue>,
248
+ ): Record<string, string | number | boolean | string[]> {
249
+ const output: Record<string, string | number | boolean | string[]> = {};
250
+ const required = new Set(params.requestedSchema.required ?? []);
251
+
252
+ for (const [name, schema] of Object.entries(params.requestedSchema.properties)) {
253
+ const raw = values[name] ?? schema.default;
254
+ if (raw === undefined || (raw === "" && schema.type !== "string")) {
255
+ if (required.has(name)) throw new Error(`Missing required elicitation field: ${name}`);
256
+ continue;
257
+ }
258
+
259
+ if (schema.type === "string") {
260
+ const stringSchema = schema as { minLength?: number; maxLength?: number };
261
+ const value = String(raw);
262
+ if (stringSchema.minLength !== undefined && value.length < stringSchema.minLength) {
263
+ throw new Error(`Elicitation field ${name} is shorter than minimum length ${stringSchema.minLength}`);
264
+ }
265
+ if (stringSchema.maxLength !== undefined && value.length > stringSchema.maxLength) {
266
+ throw new Error(`Elicitation field ${name} is longer than maximum length ${stringSchema.maxLength}`);
267
+ }
268
+ if ("enum" in schema && Array.isArray(schema.enum) && !schema.enum.includes(value)) {
269
+ throw new Error(`Elicitation field ${name} is not an allowed value`);
270
+ }
271
+ if ("oneOf" in schema && Array.isArray(schema.oneOf) && !schema.oneOf.some((option) => option.const === value)) {
272
+ throw new Error(`Elicitation field ${name} is not an allowed value`);
273
+ }
274
+ output[name] = value;
275
+ continue;
276
+ }
277
+
278
+ if (schema.type === "number" || schema.type === "integer") {
279
+ const value = typeof raw === "number" ? raw : Number(raw);
280
+ if (!Number.isFinite(value)) throw new Error(`Elicitation field ${name} must be a number`);
281
+ if (schema.type === "integer" && !Number.isInteger(value)) throw new Error(`Elicitation field ${name} must be an integer`);
282
+ if (schema.minimum !== undefined && value < schema.minimum) {
283
+ throw new Error(`Elicitation field ${name} is below minimum ${schema.minimum}`);
284
+ }
285
+ if (schema.maximum !== undefined && value > schema.maximum) {
286
+ throw new Error(`Elicitation field ${name} is above maximum ${schema.maximum}`);
287
+ }
288
+ output[name] = value;
289
+ continue;
290
+ }
291
+
292
+ if (schema.type === "boolean") {
293
+ output[name] = typeof raw === "boolean" ? raw : raw === "true";
294
+ continue;
295
+ }
296
+
297
+ if (schema.type === "array") {
298
+ if (!Array.isArray(raw)) throw new Error(`Elicitation field ${name} must be a list`);
299
+ const allowed = new Set(extractMultiSelectOptions(schema).map((option) => option.value));
300
+ const value = raw.map(String);
301
+ if (schema.minItems !== undefined && value.length < schema.minItems) {
302
+ throw new Error(`Elicitation field ${name} has fewer than ${schema.minItems} selections`);
303
+ }
304
+ if (schema.maxItems !== undefined && value.length > schema.maxItems) {
305
+ throw new Error(`Elicitation field ${name} has more than ${schema.maxItems} selections`);
306
+ }
307
+ for (const item of value) {
308
+ if (!allowed.has(item)) throw new Error(`Elicitation field ${name} contains an invalid selection`);
309
+ }
310
+ output[name] = value;
311
+ }
312
+ }
313
+
314
+ return output;
315
+ }
316
+
317
+ function extractMultiSelectOptions(schema: Extract<ElicitRequestFormParams["requestedSchema"]["properties"][string], { type: "array" }>): ExtensionUIFormSelectOption[] {
318
+ const items = schema.items as { enum?: string[]; anyOf?: Array<{ const: string; title: string }> };
319
+ if (Array.isArray(items.anyOf)) {
320
+ return items.anyOf.map((option) => ({ value: option.const, label: option.title }));
321
+ }
322
+ return (items.enum ?? []).map((value) => ({ value }));
323
+ }
324
+
325
+ function humanizeName(name: string): string {
326
+ return name
327
+ .replace(/[_-]+/g, " ")
328
+ .replace(/([a-z0-9])([A-Z])/g, "$1 $2")
329
+ .replace(/^./, (char) => char.toUpperCase());
330
+ }
331
+
332
+ function getBrowserElicitationUrl(url: string): URL {
333
+ const parsed = new URL(url);
334
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
335
+ throw new Error(`MCP URL elicitation only supports http/https URLs: ${parsed.protocol}`);
336
+ }
337
+ return parsed;
338
+ }
339
+
340
+ function stripUndefined(values: Record<string, ExtensionUIFormValue>): Record<string, string | number | boolean | string[]> {
341
+ const output: Record<string, string | number | boolean | string[]> = {};
342
+ for (const [key, value] of Object.entries(values)) {
343
+ if (value !== undefined) output[key] = value;
344
+ }
345
+ return output;
346
+ }
347
+
348
+ function omitUndefined<T extends Record<string, unknown>>(value: T): T {
349
+ for (const key of Object.keys(value)) {
350
+ if (value[key] === undefined) delete value[key];
351
+ }
352
+ return value;
353
+ }
package/init.ts CHANGED
@@ -43,6 +43,16 @@ export async function initializeMcp(
43
43
  getSignal: () => ctx.signal,
44
44
  });
45
45
  }
46
+ const elicitationEnabled =
47
+ config.settings?.elicitation !== false &&
48
+ ctx.hasUI &&
49
+ typeof (ctx.ui as { form?: unknown }).form === "function";
50
+ if (elicitationEnabled) {
51
+ manager.setElicitationConfig({
52
+ ui: ctx.ui as any,
53
+ autoOpenUrls: config.settings?.elicitationAutoOpenUrls === true,
54
+ });
55
+ }
46
56
  const lifecycle = new McpLifecycleManager(manager);
47
57
  const toolMetadata = new Map<string, ToolMetadata[]>();
48
58
  const failureTracker = new Map<string, number>();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-mcp-adapter",
3
- "version": "2.8.0",
3
+ "version": "2.9.0",
4
4
  "description": "MCP (Model Context Protocol) adapter extension for Pi coding agent",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -54,6 +54,7 @@
54
54
  "config.ts",
55
55
  "server-manager.ts",
56
56
  "sampling-handler.ts",
57
+ "elicitation-handler.ts",
57
58
  "tool-registrar.ts",
58
59
  "tool-result-renderer.ts",
59
60
  "resource-tools.ts",
package/server-manager.ts CHANGED
@@ -17,6 +17,7 @@ import { logger } from "./logger.ts";
17
17
  import { McpOAuthProvider } from "./mcp-oauth-provider.ts";
18
18
  import { extractOAuthConfig, supportsOAuth } from "./mcp-auth-flow.ts";
19
19
  import { registerSamplingHandler, type ServerSamplingConfig } from "./sampling-handler.ts";
20
+ import { registerElicitationHandler, type ServerElicitationConfig } from "./elicitation-handler.ts";
20
21
  import { interpolateEnvRecord, resolveBearerToken, resolveConfigPath } from "./utils.ts";
21
22
 
22
23
  interface ServerConnection {
@@ -37,10 +38,15 @@ export class McpServerManager {
37
38
  private connectPromises = new Map<string, Promise<ServerConnection>>();
38
39
  private uiStreamListeners = new Map<string, UiStreamListener>();
39
40
  private samplingConfig: ServerSamplingConfig | undefined;
41
+ private elicitationConfig: ServerElicitationConfig | undefined;
40
42
 
41
43
  setSamplingConfig(config: ServerSamplingConfig | undefined): void {
42
44
  this.samplingConfig = config;
43
45
  }
46
+
47
+ setElicitationConfig(config: ServerElicitationConfig | undefined): void {
48
+ this.elicitationConfig = config;
49
+ }
44
50
 
45
51
  async connect(name: string, definition: ServerDefinition): Promise<ServerConnection> {
46
52
  // Dedupe concurrent connection attempts
@@ -148,14 +154,32 @@ export class McpServerManager {
148
154
  }
149
155
  }
150
156
 
157
+ private buildClientCapabilities() {
158
+ return {
159
+ ...(this.samplingConfig ? { sampling: {} } : {}),
160
+ ...(this.elicitationConfig
161
+ ? {
162
+ elicitation: {
163
+ form: { applyDefaults: true },
164
+ url: {},
165
+ },
166
+ }
167
+ : {}),
168
+ };
169
+ }
170
+
151
171
  private createClient(serverName: string): Client {
172
+ const capabilities = this.buildClientCapabilities();
152
173
  const client = new Client(
153
174
  { name: `pi-mcp-${serverName}`, version: "1.0.0" },
154
- this.samplingConfig ? { capabilities: { sampling: {} } } : undefined,
175
+ Object.keys(capabilities).length > 0 ? { capabilities } : undefined,
155
176
  );
156
177
  if (this.samplingConfig) {
157
178
  registerSamplingHandler(client, { ...this.samplingConfig, serverName });
158
179
  }
180
+ if (this.elicitationConfig) {
181
+ registerElicitationHandler(client, { ...this.elicitationConfig, serverName });
182
+ }
159
183
  return client;
160
184
  }
161
185
 
package/types.ts CHANGED
@@ -326,6 +326,8 @@ export interface McpSettings {
326
326
  autoAuth?: boolean;
327
327
  sampling?: boolean;
328
328
  samplingAutoApprove?: boolean;
329
+ elicitation?: boolean;
330
+ elicitationAutoOpenUrls?: boolean;
329
331
  /**
330
332
  * Message returned in tool results when a server needs (re-)authentication.
331
333
  * "${server}" is substituted with the server name. Defaults to a TUI