diff --git a/packages/mcp-server/src/server.ts b/packages/mcp-server/src/server.ts index d619ff0f6..1db1e6254 100644 --- a/packages/mcp-server/src/server.ts +++ b/packages/mcp-server/src/server.ts @@ -547,6 +547,9 @@ export async function createMcpServer(sessionManager: SessionManager): Promise<{ type: 'string', title: item.key, description: descParts.join('\n'), + format: 'password', + writeOnly: true, + 'x-sensitive': true, }; // Don't mark as required — empty string = skip } diff --git a/src/resources/extensions/claude-code-cli/stream-adapter.ts b/src/resources/extensions/claude-code-cli/stream-adapter.ts index 9e2541221..a1d08e7b4 100644 --- a/src/resources/extensions/claude-code-cli/stream-adapter.ts +++ b/src/resources/extensions/claude-code-cli/stream-adapter.ts @@ -60,6 +60,8 @@ interface SdkElicitationFieldSchema { type?: string; title?: string; description?: string; + format?: string; + writeOnly?: boolean; oneOf?: SdkElicitationRequestOption[]; items?: { anyOf?: SdkElicitationRequestOption[]; @@ -73,6 +75,7 @@ interface SdkElicitationRequest { requestedSchema?: { type?: string; properties?: Record; + required?: string[]; }; } @@ -85,7 +88,16 @@ interface ParsedElicitationQuestion extends Question { noteFieldId?: string; } +interface ParsedTextInputField { + id: string; + title: string; + description: string; + required: boolean; + secure: boolean; +} + const OTHER_OPTION_LABEL = "None of the above"; +const SENSITIVE_FIELD_PATTERN = /(password|passphrase|secret|token|api[_\s-]*key|private[_\s-]*key|credential)/i; // --------------------------------------------------------------------------- // Stream factory @@ -274,6 +286,60 @@ export function parseAskUserQuestionsElicitation( return questions.length > 0 ? questions : null; } +function isSecureElicitationField( + requestMessage: string, + fieldId: string, + field: SdkElicitationFieldSchema, +): boolean { + if (field.format === "password") return true; + if (field.writeOnly === true) return true; + + const rawField = field as Record; + if (rawField.sensitive === true || rawField["x-sensitive"] === true) return true; + + const haystack = [ + requestMessage, + fieldId.replace(/[_-]+/g, " "), + typeof field.title === "string" ? field.title : "", + typeof field.description === "string" ? field.description : "", + ] + .join(" ") + .toLowerCase(); + + return SENSITIVE_FIELD_PATTERN.test(haystack); +} + +export function parseTextInputElicitation( + request: Pick, +): ParsedTextInputField[] | null { + if (request.mode && request.mode !== "form") return null; + const properties = request.requestedSchema?.properties; + if (!properties || typeof properties !== "object") return null; + + const requiredSet = new Set( + Array.isArray(request.requestedSchema?.required) + ? request.requestedSchema.required.filter((value): value is string => typeof value === "string") + : [], + ); + + const fields: ParsedTextInputField[] = []; + for (const [fieldId, field] of Object.entries(properties)) { + if (!field || typeof field !== "object") return null; + if (field.type !== "string") return null; + if (Array.isArray(field.oneOf) && field.oneOf.length > 0) return null; + + fields.push({ + id: fieldId, + title: typeof field.title === "string" && field.title.length > 0 ? field.title : fieldId, + description: typeof field.description === "string" ? field.description : "", + required: requiredSet.has(fieldId), + secure: isSecureElicitationField(request.message, fieldId, field), + }); + } + + return fields.length > 0 ? fields : null; +} + export function roundResultToElicitationContent( questions: ParsedElicitationQuestion[], result: RoundResult, @@ -355,6 +421,52 @@ async function promptElicitationWithDialogs( return { action: "accept", content }; } +function buildTextInputPromptTitle(request: SdkElicitationRequest, field: ParsedTextInputField): string { + const parts = [ + request.serverName ? `[${request.serverName}]` : "", + field.title, + field.description, + ].filter((part) => typeof part === "string" && part.trim().length > 0); + return parts.join("\n\n"); +} + +function buildTextInputPlaceholder(field: ParsedTextInputField): string | undefined { + const desc = field.description.trim(); + if (!desc) return field.required ? "Required" : "Leave empty to skip"; + + const formatLine = desc + .split(/\r?\n/) + .map((line) => line.trim()) + .find((line) => /^format:/i.test(line)); + + if (!formatLine) return field.required ? "Required" : "Leave empty to skip"; + const hint = formatLine.replace(/^format:\s*/i, "").trim(); + return hint.length > 0 ? hint : field.required ? "Required" : "Leave empty to skip"; +} + +async function promptTextInputElicitation( + request: SdkElicitationRequest, + fields: ParsedTextInputField[], + ui: ExtensionUIContext, + signal: AbortSignal, +): Promise { + const content: Record = {}; + + for (const field of fields) { + const value = await ui.input( + buildTextInputPromptTitle(request, field), + buildTextInputPlaceholder(field), + { signal, ...(field.secure ? { secure: true } : {}) }, + ); + if (value === undefined) { + return { action: "cancel" }; + } + content[field.id] = value; + } + + return { action: "accept", content }; +} + export function createClaudeCodeElicitationHandler( ui: ExtensionUIContext | undefined, ): ((request: SdkElicitationRequest, options: { signal: AbortSignal }) => Promise) | undefined { @@ -366,19 +478,24 @@ export function createClaudeCodeElicitationHandler( } const questions = parseAskUserQuestionsElicitation(request); - if (!questions) { - return { action: "decline" }; + if (questions) { + const interviewResult = await showInterviewRound(questions, { signal }, { ui } as any).catch(() => undefined); + if (interviewResult && Object.keys(interviewResult.answers).length > 0) { + return { + action: "accept", + content: roundResultToElicitationContent(questions, interviewResult), + }; + } + + return promptElicitationWithDialogs(request, questions, ui, signal); } - const interviewResult = await showInterviewRound(questions, { signal }, { ui } as any).catch(() => undefined); - if (interviewResult && Object.keys(interviewResult.answers).length > 0) { - return { - action: "accept", - content: roundResultToElicitationContent(questions, interviewResult), - }; + const textFields = parseTextInputElicitation(request); + if (textFields) { + return promptTextInputElicitation(request, textFields, ui, signal); } - return promptElicitationWithDialogs(request, questions, ui, signal); + return { action: "decline" }; }; } diff --git a/src/resources/extensions/claude-code-cli/tests/stream-adapter.test.ts b/src/resources/extensions/claude-code-cli/tests/stream-adapter.test.ts index 3616f239b..d0b7bed10 100644 --- a/src/resources/extensions/claude-code-cli/tests/stream-adapter.test.ts +++ b/src/resources/extensions/claude-code-cli/tests/stream-adapter.test.ts @@ -11,6 +11,7 @@ import { extractToolResultsFromSdkUserMessage, getClaudeLookupCommand, parseAskUserQuestionsElicitation, + parseTextInputElicitation, parseClaudeLookupOutput, roundResultToElicitationContent, } from "../stream-adapter.ts"; @@ -514,6 +515,84 @@ describe("stream-adapter — MCP elicitation bridge", () => { }, }); }); + + test("parseTextInputElicitation recognizes secure free-text MCP forms", () => { + const request = { + serverName: "gsd-workflow", + message: "Enter values for environment variables.", + mode: "form" as const, + requestedSchema: { + type: "object" as const, + properties: { + TEST_PASSWORD: { + type: "string", + title: "TEST_PASSWORD", + description: "Format: min 8 characters\nLeave empty to skip.", + }, + PROJECT_NAME: { + type: "string", + title: "PROJECT_NAME", + description: "Human-readable project name.", + }, + }, + }, + }; + + const parsed = parseTextInputElicitation(request as any); + assert.deepEqual(parsed, [ + { + id: "TEST_PASSWORD", + title: "TEST_PASSWORD", + description: "Format: min 8 characters\nLeave empty to skip.", + required: false, + secure: true, + }, + { + id: "PROJECT_NAME", + title: "PROJECT_NAME", + description: "Human-readable project name.", + required: false, + secure: false, + }, + ]); + }); + + test("createClaudeCodeElicitationHandler collects secure_env_collect fields through input dialogs", async () => { + const secureRequest = { + serverName: "gsd-workflow", + message: "Enter values for environment variables.", + mode: "form" as const, + requestedSchema: { + type: "object" as const, + properties: { + TEST_PASSWORD: { + type: "string", + title: "TEST_PASSWORD", + description: "Format: Your secure testing password\nLeave empty to skip.", + }, + }, + }, + }; + + const inputCalls: Array<{ opts?: { secure?: boolean } }> = []; + const handler = createClaudeCodeElicitationHandler({ + input: async (_title: string, _placeholder?: string, opts?: { secure?: boolean }) => { + inputCalls.push({ opts }); + return "super-secret"; + }, + } as any); + assert.ok(handler); + + const result = await handler!(secureRequest as any, { signal: new AbortController().signal }); + assert.deepEqual(result, { + action: "accept", + content: { + TEST_PASSWORD: "super-secret", + }, + }); + assert.equal(inputCalls.length, 1); + assert.equal(inputCalls[0]?.opts?.secure, true, "secure_env_collect fields should request secure input"); + }); }); describe("stream-adapter — Windows Claude path lookup (#3770)", () => {