pi-mcp-adapter 2.9.0 → 2.10.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.
@@ -2,352 +2,346 @@ import type { ExtensionUIContext } from "@earendil-works/pi-coding-agent";
2
2
  import type { Client } from "@modelcontextprotocol/sdk/client/index.js";
3
3
  import {
4
4
  ElicitRequestSchema,
5
+ ErrorCode,
6
+ McpError,
5
7
  type ElicitRequest,
6
8
  type ElicitRequestFormParams,
7
9
  type ElicitRequestURLParams,
8
10
  type ElicitResult,
9
11
  } from "@modelcontextprotocol/sdk/types.js";
12
+ import { AjvJsonSchemaValidator } from "@modelcontextprotocol/sdk/validation/ajv";
13
+ import type { JsonSchemaType } from "@modelcontextprotocol/sdk/validation/types.js";
10
14
  import open from "open";
11
15
 
12
- export type ExtensionUIFormValue = string | number | boolean | string[] | undefined;
16
+ export type ElicitationValue = string | number | boolean | string[] | undefined;
17
+ type FormProperty = ElicitRequestFormParams["requestedSchema"]["properties"][string];
13
18
 
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
- }
19
+ export type ElicitationUIContext = ExtensionUIContext;
86
20
 
87
21
  export interface ElicitationHandlerOptions {
88
22
  serverName: string;
89
23
  ui: ElicitationUIContext;
90
- autoOpenUrls: boolean;
24
+ allowUrl: boolean;
25
+ onUrlAccepted?: (elicitationId: string) => void;
91
26
  }
92
27
 
93
- export type ServerElicitationConfig = Omit<ElicitationHandlerOptions, "serverName">;
28
+ export type ServerElicitationConfig = Omit<ElicitationHandlerOptions, "serverName" | "onUrlAccepted">;
94
29
 
95
30
  export function registerElicitationHandler(client: Client, options: ElicitationHandlerOptions): void {
96
- client.setRequestHandler(ElicitRequestSchema, (request) => {
97
- return handleElicitationRequest(options, request as ElicitRequest);
98
- });
31
+ client.setRequestHandler(ElicitRequestSchema, (request) =>
32
+ handleElicitationRequest(options, request));
99
33
  }
100
34
 
101
35
  export async function handleElicitationRequest(
102
36
  options: ElicitationHandlerOptions,
103
37
  request: ElicitRequest,
104
38
  ): Promise<ElicitResult> {
105
- const params = request.params;
106
- if (params.mode === "url") {
107
- return handleUrlElicitation(options, params);
108
- }
109
- return handleFormElicitation(options, params);
39
+ return request.params.mode === "url"
40
+ ? handleUrlElicitation(options, request.params)
41
+ : handleFormElicitation(options, request.params);
110
42
  }
111
43
 
112
44
  export async function handleFormElicitation(
113
45
  options: ElicitationHandlerOptions,
114
46
  params: ElicitRequestFormParams,
115
47
  ): 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);
48
+ const decision = await options.ui.select(
49
+ `MCP Input Request\nServer: ${options.serverName}\n\n${params.message}`,
50
+ ["Continue", "Decline"],
51
+ );
52
+ if (decision === undefined) return { action: "cancel" };
53
+ if (decision === "Decline") return { action: "decline" };
54
+
55
+ const values: Record<string, ElicitationValue> = {};
56
+ const properties = Object.entries(params.requestedSchema.properties);
57
+ for (const [name, schema] of properties) {
58
+ const value = await collectValidField(options.ui, params, name, schema);
59
+ if (!("value" in value)) return { action: "cancel" };
60
+ values[name] = value.value;
120
61
  }
121
- return {
122
- action: "accept",
123
- content: coerceAndValidateFormValues(params, result.values),
124
- };
125
- }
126
62
 
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" };
63
+ while (true) {
64
+ const content = coerceAndValidateFormValues(params, values);
65
+ const action = await options.ui.select(
66
+ formatReview(options.serverName, properties, content),
67
+ properties.length > 0 ? ["Submit", "Edit", "Decline"] : ["Submit", "Decline"],
68
+ );
69
+ if (action === undefined) return { action: "cancel" };
70
+ if (action === "Decline") return { action: "decline" };
71
+ if (action === "Submit") return { action: "accept", content };
72
+
73
+ const labels = properties.map(([name, schema]) => `${schema.title ?? humanizeName(name)} (${name})`);
74
+ const selected = await options.ui.select("Choose a field to edit", labels);
75
+ if (selected === undefined) return { action: "cancel" };
76
+ const property = properties[labels.indexOf(selected)];
77
+ if (!property) continue;
78
+ const [name, schema] = property;
79
+ const value = await collectValidField(options.ui, params, name, schema, values[name]);
80
+ if (!("value" in value)) return { action: "cancel" };
81
+ values[name] = value.value;
152
82
  }
153
-
154
- await open(browserUrl.toString());
155
- options.ui.notify("Opened browser for MCP elicitation.", "info");
156
- return { action: "accept" };
157
83
  }
158
84
 
159
- export function convertMcpSchemaToPiForm(
160
- serverName: string,
85
+ async function collectValidField(
86
+ ui: ElicitationUIContext,
161
87
  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
- }
88
+ name: string,
89
+ schema: FormProperty,
90
+ current?: ElicitationValue,
91
+ ): Promise<{ cancelled: true } | { cancelled: false; value: ElicitationValue }> {
92
+ const required = params.requestedSchema.required?.includes(name) === true;
93
+ while (true) {
94
+ const result = await collectField(ui, params, name, schema, current);
95
+ if (!("value" in result)) return result;
96
+ try {
97
+ coerceAndValidateFormValues({
98
+ ...params,
99
+ requestedSchema: {
100
+ type: "object",
101
+ properties: { [name]: schema },
102
+ ...(required ? { required: [name] } : {}),
103
+ },
104
+ }, { [name]: result.value });
105
+ return result;
106
+ } catch (error) {
107
+ ui.notify(error instanceof Error ? error.message : String(error), "error");
108
+ current = result.value;
109
+ }
110
+ }
111
+ }
206
112
 
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
- }
113
+ async function collectField(
114
+ ui: ElicitationUIContext,
115
+ params: ElicitRequestFormParams,
116
+ name: string,
117
+ schema: FormProperty,
118
+ current?: ElicitationValue,
119
+ ): Promise<{ cancelled: true } | { cancelled: false; value: ElicitationValue }> {
120
+ const required = params.requestedSchema.required?.includes(name) === true;
121
+ const title = [schema.title ?? humanizeName(name), required ? "(required)" : "", schema.description]
122
+ .filter(Boolean)
123
+ .join(" ");
124
+
125
+ if (schema.type === "string" && ("enum" in schema || "oneOf" in schema)) {
126
+ const choices = "oneOf" in schema
127
+ ? schema.oneOf.map(option => ({ value: option.const, display: formatChoice(option.const, option.title) }))
128
+ : schema.enum.map((value, index) => ({
129
+ value,
130
+ display: formatChoice(value, "enumNames" in schema ? schema.enumNames?.[index] : undefined),
131
+ }));
132
+ const displays = uniqueLabels(choices.map(choice => choice.display));
133
+ const actions = [...displays];
134
+ const useDefault = schema.default === undefined ? undefined : uniqueAction("Use default", actions);
135
+ if (useDefault) actions.push(useDefault);
136
+ const omit = required ? undefined : uniqueAction("Omit", actions);
137
+ if (omit) actions.push(omit);
138
+ const action = await ui.select(title, actions);
139
+ if (action === undefined) return { cancelled: true };
140
+ if (action === useDefault) return { cancelled: false, value: schema.default };
141
+ if (action === omit) return { cancelled: false, value: undefined };
142
+ return { cancelled: false, value: choices[displays.indexOf(action)]?.value };
143
+ }
216
144
 
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
- }
145
+ if (schema.type === "boolean") {
146
+ const actions = ["Yes", "No"];
147
+ if (schema.default !== undefined) actions.push("Use default");
148
+ if (!required) actions.push("Omit");
149
+ const action = await ui.select(title, actions);
150
+ if (action === undefined) return { cancelled: true };
151
+ if (action === "Use default") return { cancelled: false, value: schema.default };
152
+ if (action === "Omit") return { cancelled: false, value: undefined };
153
+ return { cancelled: false, value: action === "Yes" };
154
+ }
226
155
 
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
- }
156
+ if (schema.type === "array") {
157
+ const actions = ["Choose values"];
158
+ if (schema.default !== undefined) actions.push("Use default");
159
+ if (!required) actions.push("Omit");
160
+ const action = await ui.select(title, actions);
161
+ if (action === undefined) return { cancelled: true };
162
+ if (action === "Use default") return { cancelled: false, value: schema.default };
163
+ if (action === "Omit") return { cancelled: false, value: undefined };
164
+
165
+ const choices = extractMultiSelectOptions(schema);
166
+ const selected = new Set(Array.isArray(current) ? current : []);
167
+ while (true) {
168
+ const displays = uniqueLabels(choices.map(choice => selected.has(choice.value) ? `✓ ${choice.display}` : choice.display));
169
+ const done = uniqueAction("Done", displays);
170
+ const picked = await ui.select(title, [...displays, done]);
171
+ if (picked === undefined) return { cancelled: true };
172
+ if (picked === done) return { cancelled: false, value: [...selected] };
173
+ const choice = choices[displays.indexOf(picked)];
174
+ if (!choice) continue;
175
+ if (selected.has(choice.value)) selected.delete(choice.value);
176
+ else selected.add(choice.value);
177
+ }
178
+ }
238
179
 
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"] };
180
+ const actions = ["Enter value"];
181
+ if (schema.default !== undefined) actions.push("Use default");
182
+ if (!required) actions.push("Omit");
183
+ const action = await ui.select(title, actions);
184
+ if (action === undefined) return { cancelled: true };
185
+ if (action === "Use default") return { cancelled: false, value: schema.default };
186
+ if (action === "Omit") return { cancelled: false, value: undefined };
187
+ const entered = await ui.input(title, current === undefined ? undefined : String(current));
188
+ return entered === undefined ? { cancelled: true } : { cancelled: false, value: entered };
243
189
  }
244
190
 
245
191
  export function coerceAndValidateFormValues(
246
192
  params: ElicitRequestFormParams,
247
- values: Record<string, ExtensionUIFormValue>,
193
+ values: Record<string, ElicitationValue>,
248
194
  ): Record<string, string | number | boolean | string[]> {
249
195
  const output: Record<string, string | number | boolean | string[]> = {};
250
196
  const required = new Set(params.requestedSchema.required ?? []);
251
-
252
197
  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")) {
198
+ const value = values[name];
199
+ if (value === undefined) {
255
200
  if (required.has(name)) throw new Error(`Missing required elicitation field: ${name}`);
256
201
  continue;
257
202
  }
258
-
259
203
  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}`);
204
+ const stringValue = String(value);
205
+ const limits = schema as typeof schema & { minLength?: number; maxLength?: number };
206
+ if (limits.minLength !== undefined && stringValue.length < limits.minLength) {
207
+ throw new Error(`Elicitation field ${name} is shorter than minimum length ${limits.minLength}`);
264
208
  }
265
- if (stringSchema.maxLength !== undefined && value.length > stringSchema.maxLength) {
266
- throw new Error(`Elicitation field ${name} is longer than maximum length ${stringSchema.maxLength}`);
209
+ if (limits.maxLength !== undefined && stringValue.length > limits.maxLength) {
210
+ throw new Error(`Elicitation field ${name} is longer than maximum length ${limits.maxLength}`);
267
211
  }
268
- if ("enum" in schema && Array.isArray(schema.enum) && !schema.enum.includes(value)) {
212
+ if ("enum" in schema && !schema.enum.includes(stringValue)) {
269
213
  throw new Error(`Elicitation field ${name} is not an allowed value`);
270
214
  }
271
- if ("oneOf" in schema && Array.isArray(schema.oneOf) && !schema.oneOf.some((option) => option.const === value)) {
215
+ if ("oneOf" in schema && !schema.oneOf.some(option => option.const === stringValue)) {
272
216
  throw new Error(`Elicitation field ${name} is not an allowed value`);
273
217
  }
274
- output[name] = value;
218
+ output[name] = stringValue;
275
219
  continue;
276
220
  }
277
-
278
221
  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) {
222
+ if (typeof value === "string" && value.trim() === "") {
223
+ throw new Error(`Elicitation field ${name} must be a number`);
224
+ }
225
+ const numberValue = typeof value === "number" ? value : Number(value);
226
+ if (!Number.isFinite(numberValue)) throw new Error(`Elicitation field ${name} must be a number`);
227
+ if (schema.type === "integer" && !Number.isInteger(numberValue)) {
228
+ throw new Error(`Elicitation field ${name} must be an integer`);
229
+ }
230
+ if (schema.minimum !== undefined && numberValue < schema.minimum) {
283
231
  throw new Error(`Elicitation field ${name} is below minimum ${schema.minimum}`);
284
232
  }
285
- if (schema.maximum !== undefined && value > schema.maximum) {
233
+ if (schema.maximum !== undefined && numberValue > schema.maximum) {
286
234
  throw new Error(`Elicitation field ${name} is above maximum ${schema.maximum}`);
287
235
  }
288
- output[name] = value;
236
+ output[name] = numberValue;
289
237
  continue;
290
238
  }
291
-
292
239
  if (schema.type === "boolean") {
293
- output[name] = typeof raw === "boolean" ? raw : raw === "true";
240
+ output[name] = typeof value === "boolean" ? value : value === "true";
294
241
  continue;
295
242
  }
296
-
297
243
  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) {
244
+ if (!Array.isArray(value)) throw new Error(`Elicitation field ${name} must be a list`);
245
+ const allowed = new Set(extractMultiSelectOptions(schema).map(option => option.value));
246
+ const arrayValue = value.map(String);
247
+ if (schema.minItems !== undefined && arrayValue.length < schema.minItems) {
302
248
  throw new Error(`Elicitation field ${name} has fewer than ${schema.minItems} selections`);
303
249
  }
304
- if (schema.maxItems !== undefined && value.length > schema.maxItems) {
250
+ if (schema.maxItems !== undefined && arrayValue.length > schema.maxItems) {
305
251
  throw new Error(`Elicitation field ${name} has more than ${schema.maxItems} selections`);
306
252
  }
307
- for (const item of value) {
308
- if (!allowed.has(item)) throw new Error(`Elicitation field ${name} contains an invalid selection`);
253
+ if (arrayValue.some(item => !allowed.has(item))) {
254
+ throw new Error(`Elicitation field ${name} contains an invalid selection`);
309
255
  }
310
- output[name] = value;
256
+ output[name] = arrayValue;
311
257
  }
312
258
  }
313
-
259
+ const validation = new AjvJsonSchemaValidator()
260
+ .getValidator(params.requestedSchema as JsonSchemaType)(output);
261
+ if (!validation.valid) {
262
+ throw new Error(`Invalid elicitation response: ${validation.errorMessage}`);
263
+ }
314
264
  return output;
315
265
  }
316
266
 
317
- function extractMultiSelectOptions(schema: Extract<ElicitRequestFormParams["requestedSchema"]["properties"][string], { type: "array" }>): ExtensionUIFormSelectOption[] {
267
+ function formatChoice(value: string, title?: string): string {
268
+ return title && title !== value ? `${title} (${value})` : value;
269
+ }
270
+
271
+ function uniqueLabels(labels: string[]): string[] {
272
+ const used = new Set<string>();
273
+ return labels.map(label => {
274
+ let unique = label;
275
+ while (used.has(unique)) unique += "…";
276
+ used.add(unique);
277
+ return unique;
278
+ });
279
+ }
280
+
281
+ function uniqueAction(label: string, choices: string[]): string {
282
+ let unique = label;
283
+ while (choices.includes(unique)) unique += "…";
284
+ return unique;
285
+ }
286
+
287
+ function extractMultiSelectOptions(schema: Extract<FormProperty, { type: "array" }>): Array<{ value: string; display: string }> {
318
288
  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 }));
289
+ return items.anyOf
290
+ ? items.anyOf.map(option => ({ value: option.const, display: formatChoice(option.const, option.title) }))
291
+ : (items.enum ?? []).map(value => ({ value, display: value }));
323
292
  }
324
293
 
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());
294
+ function formatReview(
295
+ serverName: string,
296
+ properties: Array<[string, FormProperty]>,
297
+ content: Record<string, string | number | boolean | string[]>,
298
+ ): string {
299
+ const rows = properties.map(([name, schema]) =>
300
+ `${schema.title ?? humanizeName(name)}: ${content[name] === undefined ? "(omitted)" : String(content[name])}`);
301
+ return [`Review input for ${serverName}`, "", ...rows].join("\n");
330
302
  }
331
303
 
332
- function getBrowserElicitationUrl(url: string): URL {
333
- const parsed = new URL(url);
304
+ export async function handleUrlElicitation(
305
+ options: ElicitationHandlerOptions,
306
+ params: ElicitRequestURLParams,
307
+ ): Promise<ElicitResult> {
308
+ if (!options.allowUrl) throw new McpError(ErrorCode.InvalidParams, "URL elicitation is not supported");
309
+
310
+ let parsed: URL;
311
+ try {
312
+ parsed = new URL(params.url);
313
+ } catch {
314
+ throw new McpError(ErrorCode.InvalidParams, "URL elicitation supplied an invalid URL");
315
+ }
334
316
  if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
335
- throw new Error(`MCP URL elicitation only supports http/https URLs: ${parsed.protocol}`);
317
+ throw new McpError(ErrorCode.InvalidParams, "URL elicitation only supports HTTP and HTTPS URLs");
336
318
  }
337
- return parsed;
338
- }
339
319
 
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;
320
+ const decision = await options.ui.select([
321
+ "MCP Browser Request",
322
+ `Server: ${options.serverName}`,
323
+ "",
324
+ params.message,
325
+ "",
326
+ `Host: ${parsed.host}`,
327
+ `Full URL: ${params.url}`,
328
+ "",
329
+ "Open this URL in your browser?",
330
+ ].join("\n"), ["Open", "Decline"]);
331
+ if (decision === undefined) return { action: "cancel" };
332
+ if (decision === "Decline") return { action: "decline" };
333
+
334
+ try {
335
+ await open(params.url);
336
+ } catch (error) {
337
+ options.ui.notify(`Could not open MCP elicitation URL: ${error instanceof Error ? error.message : String(error)}`, "error");
338
+ return { action: "cancel" };
344
339
  }
345
- return output;
340
+ options.onUrlAccepted?.(params.elicitationId);
341
+ options.ui.notify("Opened browser for MCP elicitation.", "info");
342
+ return { action: "accept" };
346
343
  }
347
344
 
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;
345
+ function humanizeName(name: string): string {
346
+ return name.replace(/[_-]+/g, " ").replace(/([a-z0-9])([A-Z])/g, "$1 $2").replace(/^./, char => char.toUpperCase());
353
347
  }
package/index.ts CHANGED
@@ -6,7 +6,7 @@ import { loadMcpConfig } from "./config.ts";
6
6
  import { buildProxyDescription, createDirectToolExecutor, getMissingConfiguredDirectToolServers, resolveDirectTools } from "./direct-tools.ts";
7
7
  import { flushMetadataCache, initializeMcp, updateStatusBar } from "./init.ts";
8
8
  import { loadMetadataCache } from "./metadata-cache.ts";
9
- import { executeCall, executeConnect, executeDescribe, executeList, executeSearch, executeStatus, executeUiMessages } from "./proxy-modes.ts";
9
+ import { executeAuthComplete, executeAuthStart, executeCall, executeConnect, executeDescribe, executeList, executeSearch, executeStatus, executeUiMessages } from "./proxy-modes.ts";
10
10
  import { getConfigPathFromArgv, truncateAtWord } from "./utils.ts";
11
11
  import { initializeOAuth, shutdownOAuth } from "./mcp-auth-flow.ts";
12
12
  import { createMcpDirectToolCallRenderer, renderMcpProxyToolCall, renderMcpToolResult } from "./tool-result-renderer.ts";
@@ -263,7 +263,7 @@ export default function mcpAdapter(pi: ExtensionAPI) {
263
263
  regex: Type.Optional(Type.Boolean({ description: "Treat search as regex (default: substring match)" })),
264
264
  includeSchemas: Type.Optional(Type.Boolean({ description: "Include parameter schemas in search results (default: true)" })),
265
265
  server: Type.Optional(Type.String({ description: "Filter to specific server (also disambiguates tool calls)" })),
266
- action: Type.Optional(Type.String({ description: "Action: 'ui-messages' to retrieve prompts/intents from UI sessions" })),
266
+ action: Type.Optional(Type.String({ description: "Action: 'ui-messages', 'auth-start', or 'auth-complete'" })),
267
267
  }),
268
268
  renderResult: renderMcpToolResult,
269
269
  async execute(_toolCallId, params: {
@@ -314,6 +314,31 @@ export default function mcpAdapter(pi: ExtensionAPI) {
314
314
  if (params.action === "ui-messages") {
315
315
  return executeUiMessages(state);
316
316
  }
317
+ if (params.action === "auth-start") {
318
+ if (!params.server) {
319
+ return {
320
+ content: [{ type: "text" as const, text: "auth-start requires `server`. Example: mcp({ action: \"auth-start\", server: \"linear-server\" })" }],
321
+ details: { mode: "auth-start", error: "missing_server" },
322
+ };
323
+ }
324
+ return executeAuthStart(state, params.server);
325
+ }
326
+ if (params.action === "auth-complete") {
327
+ if (!params.server) {
328
+ return {
329
+ content: [{ type: "text" as const, text: "auth-complete requires `server`." }],
330
+ details: { mode: "auth-complete", error: "missing_server" },
331
+ };
332
+ }
333
+ const input = parsedArgs?.redirectUrl ?? parsedArgs?.code ?? parsedArgs?.input;
334
+ if (typeof input !== "string" || input.trim().length === 0) {
335
+ return {
336
+ content: [{ type: "text" as const, text: "auth-complete requires args with `redirectUrl`, `code`, or `input`." }],
337
+ details: { mode: "auth-complete", error: "missing_input" },
338
+ };
339
+ }
340
+ return executeAuthComplete(state, params.server, input);
341
+ }
317
342
  if (params.tool) {
318
343
  return executeCall(state, params.tool, parsedArgs, params.server, getPiTools);
319
344
  }