Files
sproutclaw-data/agent/extensions/pi-mcp-adapter/elicitation-handler.ts
shumengya 50edff80f5 feat: 导出 SproutClaw .sproutclaw 配置
包含 extensions、skills、prompts、settings、auth、models、mcp 等配置。
排除 node_modules、npm 缓存、sessions 等运行时数据。
2026-06-26 15:48:56 +08:00

348 lines
14 KiB
TypeScript

import type { ExtensionUIContext } from "@earendil-works/pi-coding-agent";
import type { Client } from "@modelcontextprotocol/sdk/client/index.js";
import {
ElicitRequestSchema,
ErrorCode,
McpError,
type ElicitRequest,
type ElicitRequestFormParams,
type ElicitRequestURLParams,
type ElicitResult,
} from "@modelcontextprotocol/sdk/types.js";
import { AjvJsonSchemaValidator } from "@modelcontextprotocol/sdk/validation/ajv";
import type { JsonSchemaType } from "@modelcontextprotocol/sdk/validation/types.js";
import open from "open";
export type ElicitationValue = string | number | boolean | string[] | undefined;
type FormProperty = ElicitRequestFormParams["requestedSchema"]["properties"][string];
export type ElicitationUIContext = ExtensionUIContext;
export interface ElicitationHandlerOptions {
serverName: string;
ui: ElicitationUIContext;
allowUrl: boolean;
onUrlAccepted?: (elicitationId: string) => void;
}
export type ServerElicitationConfig = Omit<ElicitationHandlerOptions, "serverName" | "onUrlAccepted">;
export function registerElicitationHandler(client: Client, options: ElicitationHandlerOptions): void {
client.setRequestHandler(ElicitRequestSchema, (request) =>
handleElicitationRequest(options, request));
}
export async function handleElicitationRequest(
options: ElicitationHandlerOptions,
request: ElicitRequest,
): Promise<ElicitResult> {
return request.params.mode === "url"
? handleUrlElicitation(options, request.params)
: handleFormElicitation(options, request.params);
}
export async function handleFormElicitation(
options: ElicitationHandlerOptions,
params: ElicitRequestFormParams,
): Promise<ElicitResult> {
const decision = await options.ui.select(
`MCP Input Request\nServer: ${options.serverName}\n\n${params.message}`,
["Continue", "Decline"],
);
if (decision === undefined) return { action: "cancel" };
if (decision === "Decline") return { action: "decline" };
const values: Record<string, ElicitationValue> = {};
const properties = Object.entries(params.requestedSchema.properties);
for (const [name, schema] of properties) {
const value = await collectValidField(options.ui, params, name, schema);
if (!("value" in value)) return { action: "cancel" };
values[name] = value.value;
}
while (true) {
const content = coerceAndValidateFormValues(params, values);
const action = await options.ui.select(
formatReview(options.serverName, properties, content),
properties.length > 0 ? ["Submit", "Edit", "Decline"] : ["Submit", "Decline"],
);
if (action === undefined) return { action: "cancel" };
if (action === "Decline") return { action: "decline" };
if (action === "Submit") return { action: "accept", content };
const labels = properties.map(([name, schema]) => `${schema.title ?? humanizeName(name)} (${name})`);
const selected = await options.ui.select("Choose a field to edit", labels);
if (selected === undefined) return { action: "cancel" };
const property = properties[labels.indexOf(selected)];
if (!property) continue;
const [name, schema] = property;
const value = await collectValidField(options.ui, params, name, schema, values[name]);
if (!("value" in value)) return { action: "cancel" };
values[name] = value.value;
}
}
async function collectValidField(
ui: ElicitationUIContext,
params: ElicitRequestFormParams,
name: string,
schema: FormProperty,
current?: ElicitationValue,
): Promise<{ cancelled: true } | { cancelled: false; value: ElicitationValue }> {
const required = params.requestedSchema.required?.includes(name) === true;
while (true) {
const result = await collectField(ui, params, name, schema, current);
if (!("value" in result)) return result;
try {
coerceAndValidateFormValues({
...params,
requestedSchema: {
type: "object",
properties: { [name]: schema },
...(required ? { required: [name] } : {}),
},
}, { [name]: result.value });
return result;
} catch (error) {
ui.notify(error instanceof Error ? error.message : String(error), "error");
current = result.value;
}
}
}
async function collectField(
ui: ElicitationUIContext,
params: ElicitRequestFormParams,
name: string,
schema: FormProperty,
current?: ElicitationValue,
): Promise<{ cancelled: true } | { cancelled: false; value: ElicitationValue }> {
const required = params.requestedSchema.required?.includes(name) === true;
const title = [schema.title ?? humanizeName(name), required ? "(required)" : "", schema.description]
.filter(Boolean)
.join(" ");
if (schema.type === "string" && ("enum" in schema || "oneOf" in schema)) {
const choices = "oneOf" in schema
? schema.oneOf.map(option => ({ value: option.const, display: formatChoice(option.const, option.title) }))
: schema.enum.map((value, index) => ({
value,
display: formatChoice(value, "enumNames" in schema ? schema.enumNames?.[index] : undefined),
}));
const displays = uniqueLabels(choices.map(choice => choice.display));
const actions = [...displays];
const useDefault = schema.default === undefined ? undefined : uniqueAction("Use default", actions);
if (useDefault) actions.push(useDefault);
const omit = required ? undefined : uniqueAction("Omit", actions);
if (omit) actions.push(omit);
const action = await ui.select(title, actions);
if (action === undefined) return { cancelled: true };
if (action === useDefault) return { cancelled: false, value: schema.default };
if (action === omit) return { cancelled: false, value: undefined };
return { cancelled: false, value: choices[displays.indexOf(action)]?.value };
}
if (schema.type === "boolean") {
const actions = ["Yes", "No"];
if (schema.default !== undefined) actions.push("Use default");
if (!required) actions.push("Omit");
const action = await ui.select(title, actions);
if (action === undefined) return { cancelled: true };
if (action === "Use default") return { cancelled: false, value: schema.default };
if (action === "Omit") return { cancelled: false, value: undefined };
return { cancelled: false, value: action === "Yes" };
}
if (schema.type === "array") {
const actions = ["Choose values"];
if (schema.default !== undefined) actions.push("Use default");
if (!required) actions.push("Omit");
const action = await ui.select(title, actions);
if (action === undefined) return { cancelled: true };
if (action === "Use default") return { cancelled: false, value: schema.default };
if (action === "Omit") return { cancelled: false, value: undefined };
const choices = extractMultiSelectOptions(schema);
const selected = new Set(Array.isArray(current) ? current : []);
while (true) {
const displays = uniqueLabels(choices.map(choice => selected.has(choice.value) ? `${choice.display}` : choice.display));
const done = uniqueAction("Done", displays);
const picked = await ui.select(title, [...displays, done]);
if (picked === undefined) return { cancelled: true };
if (picked === done) return { cancelled: false, value: [...selected] };
const choice = choices[displays.indexOf(picked)];
if (!choice) continue;
if (selected.has(choice.value)) selected.delete(choice.value);
else selected.add(choice.value);
}
}
const actions = ["Enter value"];
if (schema.default !== undefined) actions.push("Use default");
if (!required) actions.push("Omit");
const action = await ui.select(title, actions);
if (action === undefined) return { cancelled: true };
if (action === "Use default") return { cancelled: false, value: schema.default };
if (action === "Omit") return { cancelled: false, value: undefined };
const entered = await ui.input(title, current === undefined ? undefined : String(current));
return entered === undefined ? { cancelled: true } : { cancelled: false, value: entered };
}
export function coerceAndValidateFormValues(
params: ElicitRequestFormParams,
values: Record<string, ElicitationValue>,
): Record<string, string | number | boolean | string[]> {
const output: Record<string, string | number | boolean | string[]> = {};
const required = new Set(params.requestedSchema.required ?? []);
for (const [name, schema] of Object.entries(params.requestedSchema.properties)) {
const value = values[name];
if (value === undefined) {
if (required.has(name)) throw new Error(`Missing required elicitation field: ${name}`);
continue;
}
if (schema.type === "string") {
const stringValue = String(value);
const limits = schema as typeof schema & { minLength?: number; maxLength?: number };
if (limits.minLength !== undefined && stringValue.length < limits.minLength) {
throw new Error(`Elicitation field ${name} is shorter than minimum length ${limits.minLength}`);
}
if (limits.maxLength !== undefined && stringValue.length > limits.maxLength) {
throw new Error(`Elicitation field ${name} is longer than maximum length ${limits.maxLength}`);
}
if ("enum" in schema && !schema.enum.includes(stringValue)) {
throw new Error(`Elicitation field ${name} is not an allowed value`);
}
if ("oneOf" in schema && !schema.oneOf.some(option => option.const === stringValue)) {
throw new Error(`Elicitation field ${name} is not an allowed value`);
}
output[name] = stringValue;
continue;
}
if (schema.type === "number" || schema.type === "integer") {
if (typeof value === "string" && value.trim() === "") {
throw new Error(`Elicitation field ${name} must be a number`);
}
const numberValue = typeof value === "number" ? value : Number(value);
if (!Number.isFinite(numberValue)) throw new Error(`Elicitation field ${name} must be a number`);
if (schema.type === "integer" && !Number.isInteger(numberValue)) {
throw new Error(`Elicitation field ${name} must be an integer`);
}
if (schema.minimum !== undefined && numberValue < schema.minimum) {
throw new Error(`Elicitation field ${name} is below minimum ${schema.minimum}`);
}
if (schema.maximum !== undefined && numberValue > schema.maximum) {
throw new Error(`Elicitation field ${name} is above maximum ${schema.maximum}`);
}
output[name] = numberValue;
continue;
}
if (schema.type === "boolean") {
output[name] = typeof value === "boolean" ? value : value === "true";
continue;
}
if (schema.type === "array") {
if (!Array.isArray(value)) throw new Error(`Elicitation field ${name} must be a list`);
const allowed = new Set(extractMultiSelectOptions(schema).map(option => option.value));
const arrayValue = value.map(String);
if (schema.minItems !== undefined && arrayValue.length < schema.minItems) {
throw new Error(`Elicitation field ${name} has fewer than ${schema.minItems} selections`);
}
if (schema.maxItems !== undefined && arrayValue.length > schema.maxItems) {
throw new Error(`Elicitation field ${name} has more than ${schema.maxItems} selections`);
}
if (arrayValue.some(item => !allowed.has(item))) {
throw new Error(`Elicitation field ${name} contains an invalid selection`);
}
output[name] = arrayValue;
}
}
const validation = new AjvJsonSchemaValidator()
.getValidator(params.requestedSchema as JsonSchemaType)(output);
if (!validation.valid) {
throw new Error(`Invalid elicitation response: ${validation.errorMessage}`);
}
return output;
}
function formatChoice(value: string, title?: string): string {
return title && title !== value ? `${title} (${value})` : value;
}
function uniqueLabels(labels: string[]): string[] {
const used = new Set<string>();
return labels.map(label => {
let unique = label;
while (used.has(unique)) unique += "…";
used.add(unique);
return unique;
});
}
function uniqueAction(label: string, choices: string[]): string {
let unique = label;
while (choices.includes(unique)) unique += "…";
return unique;
}
function extractMultiSelectOptions(schema: Extract<FormProperty, { type: "array" }>): Array<{ value: string; display: string }> {
const items = schema.items as { enum?: string[]; anyOf?: Array<{ const: string; title: string }> };
return items.anyOf
? items.anyOf.map(option => ({ value: option.const, display: formatChoice(option.const, option.title) }))
: (items.enum ?? []).map(value => ({ value, display: value }));
}
function formatReview(
serverName: string,
properties: Array<[string, FormProperty]>,
content: Record<string, string | number | boolean | string[]>,
): string {
const rows = properties.map(([name, schema]) =>
`${schema.title ?? humanizeName(name)}: ${content[name] === undefined ? "(omitted)" : String(content[name])}`);
return [`Review input for ${serverName}`, "", ...rows].join("\n");
}
export async function handleUrlElicitation(
options: ElicitationHandlerOptions,
params: ElicitRequestURLParams,
): Promise<ElicitResult> {
if (!options.allowUrl) throw new McpError(ErrorCode.InvalidParams, "URL elicitation is not supported");
let parsed: URL;
try {
parsed = new URL(params.url);
} catch {
throw new McpError(ErrorCode.InvalidParams, "URL elicitation supplied an invalid URL");
}
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
throw new McpError(ErrorCode.InvalidParams, "URL elicitation only supports HTTP and HTTPS URLs");
}
const decision = await options.ui.select([
"MCP Browser Request",
`Server: ${options.serverName}`,
"",
params.message,
"",
`Host: ${parsed.host}`,
`Full URL: ${params.url}`,
"",
"Open this URL in your browser?",
].join("\n"), ["Open", "Decline"]);
if (decision === undefined) return { action: "cancel" };
if (decision === "Decline") return { action: "decline" };
try {
await open(params.url);
} catch (error) {
options.ui.notify(`Could not open MCP elicitation URL: ${error instanceof Error ? error.message : String(error)}`, "error");
return { action: "cancel" };
}
options.onUrlAccepted?.(params.elicitationId);
options.ui.notify("Opened browser for MCP elicitation.", "info");
return { action: "accept" };
}
function humanizeName(name: string): string {
return name.replace(/[_-]+/g, " ").replace(/([a-z0-9])([A-Z])/g, "$1 $2").replace(/^./, char => char.toUpperCase());
}