From d4a8eb0dd8b889a047938a6be0783923dd174ff1 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Mon, 26 Oct 2026 23:46:26 -0400 Subject: [PATCH] fix(everything): summarize the elicitation form's own fields The accepted-form summary of trigger-elicitation-request read `color` and `petType`, which the form does not define, and never printed `firstLine` or the five enum fields. The summary is now derived from the form's properties, with a label map typed by the form's field names, so a field added to or removed from the form fails to compile until the summary follows. Closes #5209 Co-Authored-By: Claude Opus 5.5 Signed-off-by: cliffhall --- .changeset/elicitation-summary-fields.md | 5 + .../__tests__/sampling-elicitation.test.ts | 46 ++- .../tools/trigger-elicitation-request.ts | 264 ++++++++++-------- 3 files changed, 185 insertions(+), 130 deletions(-) create mode 100644 .changeset/elicitation-summary-fields.md diff --git a/.changeset/elicitation-summary-fields.md b/.changeset/elicitation-summary-fields.md new file mode 100644 index 0000000000..2130f1ad69 --- /dev/null +++ b/.changeset/elicitation-summary-fields.md @@ -0,0 +1,5 @@ +--- +"@modelcontextprotocol/server-everything": patch +--- + +`trigger-elicitation-request` now summarizes an accepted form by the fields the form actually asks for: it lists the favorite first line and the five enum fields, and no longer reports `color` or `petType`, which the form never defined. diff --git a/src/everything/__tests__/sampling-elicitation.test.ts b/src/everything/__tests__/sampling-elicitation.test.ts index 3e59f950df..c2cd38c74c 100644 --- a/src/everything/__tests__/sampling-elicitation.test.ts +++ b/src/everything/__tests__/sampling-elicitation.test.ts @@ -336,35 +336,61 @@ describe("trigger-elicitation-request", () => { `); }); - it("summarizes every field of an accepted form", async () => { - // `color` and `petType` are not in the requested schema; the summary - // still reads them if a client sends them (#4854 baseline). + it("summarizes every field of an accepted form, in the form's order (#5209)", async () => { const content = { name: "Ada", check: false, - color: "teal", + firstLine: "Call me Ishmael.", email: "ada@example.com", homepage: "https://example.com", birthdate: "1815-12-10", integer: 0, number: 3.5, - petType: "pet-2", + untitledSingleSelectEnum: "Joey", + untitledMultipleSelectEnum: ["Piano", "Drums"], + titledSingleSelectEnum: "hero-3", + titledMultipleSelectEnum: ["fish-2"], + legacyTitledEnum: "pet-2", }; - const { texts } = await trigger({ action: "accept", content }); + const { texts, asked } = await trigger({ action: "accept", content }); expect(texts).toEqual([ "✅ User provided the requested information!", "User inputs:\n" + "- Name: Ada\n" + "- Agreed to terms: false\n" + - "- Favorite Color: teal\n" + + "- Favorite first line: Call me Ishmael.\n" + "- Email: ada@example.com\n" + "- Homepage: https://example.com\n" + "- Birthdate: 1815-12-10\n" + - "- Favorite Integer: 0\n" + - "- Favorite Number: 3.5\n" + - "- Pet Type: pet-2", + "- Favorite integer: 0\n" + + "- Favorite number: 3.5\n" + + "- Favorite friend: Joey\n" + + "- Favorite instruments: Piano, Drums\n" + + "- Favorite hero: hero-3\n" + + "- Favorite fish: fish-2\n" + + "- Favorite pet: pet-2", `\nRaw result: ${JSON.stringify({ action: "accept", content }, null, 2)}`, ]); + // One line per field the form asked for: the summary and the requested + // schema list the same fields. + const params = asked[0]; + expect(params && "requestedSchema" in params).toBe(true); + if (params && "requestedSchema" in params) { + expect(Object.keys(content)).toEqual( + Object.keys(params.requestedSchema.properties), + ); + } + }); + + it("leaves out fields the form does not define (#5209)", async () => { + const content = { name: "Ada", color: "teal", petType: "pet-2" }; + const { texts } = await trigger({ action: "accept", content }); + expect(texts.slice(0, 2)).toEqual([ + "✅ User provided the requested information!", + "User inputs:\n- Name: Ada", + ]); + // The raw result still shows what the client sent. + expect(texts[2]).toContain('"color": "teal"'); }); it("summarizes an accepted form with no fields filled", async () => { diff --git a/src/everything/tools/trigger-elicitation-request.ts b/src/everything/tools/trigger-elicitation-request.ts index 44bb1041a2..688474534b 100644 --- a/src/everything/tools/trigger-elicitation-request.ts +++ b/src/everything/tools/trigger-elicitation-request.ts @@ -32,119 +32,157 @@ const config = { }, }; -/** The form: one field of each type form-mode elicitation supports. */ -const requestedSchema: ElicitRequestFormParams["requestedSchema"] = { - type: "object", - properties: { - name: { - title: "String", - type: "string", - description: "Your full, legal name", - }, - check: { - title: "Boolean", - type: "boolean", - description: "Agree to the terms and conditions", - }, - firstLine: { - title: "String with default", - type: "string", - description: "Favorite first line of a story", - default: "It was a dark and stormy night.", - }, - email: { - title: "String with email format", - type: "string", - format: "email", - description: - "Your email address (will be verified, and never shared with anyone else)", - }, - homepage: { - type: "string", - format: "uri", - title: "String with uri format", - description: "Portfolio / personal website", - }, - birthdate: { - title: "String with date format", - type: "string", - format: "date", - description: "Your date of birth", - }, - integer: { - title: "Integer", - type: "integer", - description: - "Your favorite integer (do not give us your phone number, pin, or other sensitive info)", - minimum: 1, - maximum: 100, - default: 42, - }, - number: { - title: "Number in range 1-1000", - type: "number", - description: "Favorite number (there are no wrong answers)", - minimum: 0, - maximum: 1000, - default: 3.14, - }, - untitledSingleSelectEnum: { +/** The form's fields: one of each type form-mode elicitation supports. */ +const properties = { + name: { + title: "String", + type: "string", + description: "Your full, legal name", + }, + check: { + title: "Boolean", + type: "boolean", + description: "Agree to the terms and conditions", + }, + firstLine: { + title: "String with default", + type: "string", + description: "Favorite first line of a story", + default: "It was a dark and stormy night.", + }, + email: { + title: "String with email format", + type: "string", + format: "email", + description: + "Your email address (will be verified, and never shared with anyone else)", + }, + homepage: { + type: "string", + format: "uri", + title: "String with uri format", + description: "Portfolio / personal website", + }, + birthdate: { + title: "String with date format", + type: "string", + format: "date", + description: "Your date of birth", + }, + integer: { + title: "Integer", + type: "integer", + description: + "Your favorite integer (do not give us your phone number, pin, or other sensitive info)", + minimum: 1, + maximum: 100, + default: 42, + }, + number: { + title: "Number in range 1-1000", + type: "number", + description: "Favorite number (there are no wrong answers)", + minimum: 0, + maximum: 1000, + default: 3.14, + }, + untitledSingleSelectEnum: { + type: "string", + title: "Untitled Single Select Enum", + description: "Choose your favorite friend", + enum: ["Monica", "Rachel", "Joey", "Chandler", "Ross", "Phoebe"], + default: "Monica", + }, + untitledMultipleSelectEnum: { + type: "array", + title: "Untitled Multiple Select Enum", + description: "Choose your favorite instruments", + minItems: 1, + maxItems: 3, + items: { type: "string", - title: "Untitled Single Select Enum", - description: "Choose your favorite friend", - enum: ["Monica", "Rachel", "Joey", "Chandler", "Ross", "Phoebe"], - default: "Monica", + enum: ["Guitar", "Piano", "Violin", "Drums", "Bass"], }, - untitledMultipleSelectEnum: { - type: "array", - title: "Untitled Multiple Select Enum", - description: "Choose your favorite instruments", - minItems: 1, - maxItems: 3, - items: { - type: "string", - enum: ["Guitar", "Piano", "Violin", "Drums", "Bass"], - }, - default: ["Guitar"], - }, - titledSingleSelectEnum: { - type: "string", - title: "Titled Single Select Enum", - description: "Choose your favorite hero", - oneOf: [ - { const: "hero-1", title: "Superman" }, - { const: "hero-2", title: "Green Lantern" }, - { const: "hero-3", title: "Wonder Woman" }, + default: ["Guitar"], + }, + titledSingleSelectEnum: { + type: "string", + title: "Titled Single Select Enum", + description: "Choose your favorite hero", + oneOf: [ + { const: "hero-1", title: "Superman" }, + { const: "hero-2", title: "Green Lantern" }, + { const: "hero-3", title: "Wonder Woman" }, + ], + default: "hero-1", + }, + titledMultipleSelectEnum: { + type: "array", + title: "Titled Multiple Select Enum", + description: "Choose your favorite types of fish", + minItems: 1, + maxItems: 3, + items: { + anyOf: [ + { const: "fish-1", title: "Tuna" }, + { const: "fish-2", title: "Salmon" }, + { const: "fish-3", title: "Trout" }, ], - default: "hero-1", - }, - titledMultipleSelectEnum: { - type: "array", - title: "Titled Multiple Select Enum", - description: "Choose your favorite types of fish", - minItems: 1, - maxItems: 3, - items: { - anyOf: [ - { const: "fish-1", title: "Tuna" }, - { const: "fish-2", title: "Salmon" }, - { const: "fish-3", title: "Trout" }, - ], - }, - default: ["fish-1"], - }, - legacyTitledEnum: { - type: "string", - title: "Legacy Titled Single Select Enum", - description: "Choose your favorite type of pet", - enum: ["pet-1", "pet-2", "pet-3", "pet-4", "pet-5"], - enumNames: ["Cats", "Dogs", "Birds", "Fish", "Reptiles"], - default: "pet-1", }, + default: ["fish-1"], + }, + legacyTitledEnum: { + type: "string", + title: "Legacy Titled Single Select Enum", + description: "Choose your favorite type of pet", + enum: ["pet-1", "pet-2", "pet-3", "pet-4", "pet-5"], + enumNames: ["Cats", "Dogs", "Birds", "Fish", "Reptiles"], + default: "pet-1", }, +} satisfies ElicitRequestFormParams["requestedSchema"]["properties"]; + +/** The form, as the elicitation request carries it. */ +const requestedSchema: ElicitRequestFormParams["requestedSchema"] = { + type: "object", + properties, required: ["name"], }; +/** + * The label each field gets in the summary of an accepted form. Keyed by the + * form's own field names, so a field added to or removed from the form fails + * to compile until its label follows, and the summary cannot drift from the + * form again (#5209). + */ +const summaryLabels: Record = { + name: "Name", + check: "Agreed to terms", + firstLine: "Favorite first line", + email: "Email", + homepage: "Homepage", + birthdate: "Birthdate", + integer: "Favorite integer", + number: "Favorite number", + untitledSingleSelectEnum: "Favorite friend", + untitledMultipleSelectEnum: "Favorite instruments", + titledSingleSelectEnum: "Favorite hero", + titledMultipleSelectEnum: "Favorite fish", + legacyTitledEnum: "Favorite pet", +}; + +/** + * One summary line per form field the user answered, in the form's order. + * A field the form does not define is left out, whatever the client sent. + */ +const summarize = (userData: Record): string[] => + (Object.keys(properties) as (keyof typeof properties)[]) + .filter((field) => userData[field] !== undefined) + .map((field) => { + const value = userData[field]; + const shown = Array.isArray(value) ? value.join(", ") : String(value); + return `- ${summaryLabels[field]}: ${shown}`; + }); + /** * Registers the 'trigger-elicitation-request' tool. * @@ -197,21 +235,7 @@ export const registerTriggerElicitationRequestTool = (server: McpServer) => { }); // Only access answer.content when action is accept - const userData = answer.content; - const lines = []; - if (userData.name) lines.push(`- Name: ${userData.name}`); - if (userData.check !== undefined) - lines.push(`- Agreed to terms: ${userData.check}`); - if (userData.color) lines.push(`- Favorite Color: ${userData.color}`); - if (userData.email) lines.push(`- Email: ${userData.email}`); - if (userData.homepage) lines.push(`- Homepage: ${userData.homepage}`); - if (userData.birthdate) - lines.push(`- Birthdate: ${userData.birthdate}`); - if (userData.integer !== undefined) - lines.push(`- Favorite Integer: ${userData.integer}`); - if (userData.number !== undefined) - lines.push(`- Favorite Number: ${userData.number}`); - if (userData.petType) lines.push(`- Pet Type: ${userData.petType}`); + const lines = summarize(answer.content); content.push({ type: "text",