fix(claude-code): accept secure_env_collect MCP elicitation forms
This commit is contained in:
parent
6b52f5df3f
commit
1495e711e1
3 changed files with 208 additions and 9 deletions
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<string, SdkElicitationFieldSchema>;
|
||||
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<string, unknown>;
|
||||
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<SdkElicitationRequest, "message" | "mode" | "requestedSchema">,
|
||||
): 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<SdkElicitationResult> {
|
||||
const content: Record<string, string | string[]> = {};
|
||||
|
||||
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<SdkElicitationResult>) | 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" };
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)", () => {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue