From 03a026512ead172b8451f34390661e2828284fd0 Mon Sep 17 00:00:00 2001 From: murashit Date: Fri, 10 Jul 2026 12:29:02 +0900 Subject: [PATCH] Reject unsupported MCP elicitation forms --- src/app-server/protocol/server-requests.ts | 20 +++++++++++-------- .../server-requests/mcp-elicitation.test.ts | 13 ++++++++---- .../chat/app-server/inbound/routing.test.ts | 17 ++++++++++++++++ 3 files changed, 38 insertions(+), 12 deletions(-) diff --git a/src/app-server/protocol/server-requests.ts b/src/app-server/protocol/server-requests.ts index 72cc0fcc..7c31a03d 100644 --- a/src/app-server/protocol/server-requests.ts +++ b/src/app-server/protocol/server-requests.ts @@ -159,6 +159,8 @@ export function appServerMcpElicitationRequest(request: AppServerRequest): Pendi }, }; } + const fields = mcpElicitationFieldsFromRequestedSchema(params.requestedSchema); + if (!fields) return null; return { requestId: request.id, params: { @@ -168,7 +170,7 @@ export function appServerMcpElicitationRequest(request: AppServerRequest): Pendi mode: "form", message: params.message, meta: params.meta, - fields: mcpElicitationFieldsFromRequestedSchema(params.requestedSchema), + fields, }, }; } @@ -604,17 +606,19 @@ function normalizeMcpElicitationParams(params: McpElicitationParams): Normalized } } -function mcpElicitationFieldsFromRequestedSchema(schema: unknown): PendingMcpElicitationField[] { +function mcpElicitationFieldsFromRequestedSchema(schema: unknown): PendingMcpElicitationField[] | null { const record = asRecordOrNull(schema); const properties = asRecordOrNull(record?.["properties"]); - if (!properties) return []; + if (record?.["type"] !== "object" || !properties) return null; const required = new Set( - Array.isArray(record?.["required"]) ? record["required"].filter((item): item is string => typeof item === "string") : [], + Array.isArray(record["required"]) ? record["required"].filter((item): item is string => typeof item === "string") : [], ); - return Object.entries(properties).flatMap(([id, fieldSchema]) => { - if (!isMcpElicitationPrimitiveSchema(fieldSchema)) return []; - return [mcpElicitationFieldFromSchema(id, fieldSchema, required.has(id))]; - }); + const fields: PendingMcpElicitationField[] = []; + for (const [id, fieldSchema] of Object.entries(properties)) { + if (!isMcpElicitationPrimitiveSchema(fieldSchema)) return null; + fields.push(mcpElicitationFieldFromSchema(id, fieldSchema, required.has(id))); + } + return fields; } function isMcpElicitationPrimitiveSchema(schema: unknown): schema is AppServerMcpElicitationPrimitiveSchema { diff --git a/tests/app-server/protocol/server-requests/mcp-elicitation.test.ts b/tests/app-server/protocol/server-requests/mcp-elicitation.test.ts index 28c6b17f..627d7ba4 100644 --- a/tests/app-server/protocol/server-requests/mcp-elicitation.test.ts +++ b/tests/app-server/protocol/server-requests/mcp-elicitation.test.ts @@ -103,8 +103,12 @@ describe("MCP elicitation request model", () => { expect(input.params.fields).toEqual([expect.objectContaining({ id: "title", type: "string" })]); }); - it("normalizes malformed schema fields without leaking invalid values", () => { - const input = expectPresent(toPendingMcpElicitation(malformedSchemaRequest())); + it("rejects a form instead of dropping unsupported schema fields", () => { + expect(toPendingMcpElicitation(malformedSchemaRequest())).toBeNull(); + }); + + it("normalizes malformed primitive schema values without leaking them", () => { + const input = expectPresent(toPendingMcpElicitation(malformedSchemaRequest({ includeUnsupported: false }))); if (input.params.mode !== "form") throw new Error("Expected form mode"); expect(input.params.fields.map((field) => field.id)).toEqual(["badDefault", "brokenSelect", "enumSelect", "labels"]); @@ -217,7 +221,7 @@ function openAiFormRequest(): ServerRequest { }; } -function malformedSchemaRequest(): ServerRequest { +function malformedSchemaRequest({ includeUnsupported = true }: { includeUnsupported?: boolean } = {}): ServerRequest { return { id: 44, method: "mcpServer/elicitation/request", @@ -230,8 +234,9 @@ function malformedSchemaRequest(): ServerRequest { message: "Provide issue details", requestedSchema: { type: "object", + required: includeUnsupported ? ["unsupported"] : [], properties: { - unsupported: { type: "object", title: "Unsupported" }, + ...(includeUnsupported ? { unsupported: { type: "object", title: "Unsupported" } } : {}), badDefault: { type: "boolean", title: "Bad default", default: "yes" }, brokenSelect: { type: "string", title: "Broken select", oneOf: { const: "low", title: "Low" } }, enumSelect: { type: "string", title: "Enum select", enum: ["low", 1, "high"] }, diff --git a/tests/features/chat/app-server/inbound/routing.test.ts b/tests/features/chat/app-server/inbound/routing.test.ts index 8d1d9b7f..bdd72f11 100644 --- a/tests/features/chat/app-server/inbound/routing.test.ts +++ b/tests/features/chat/app-server/inbound/routing.test.ts @@ -119,6 +119,23 @@ describe("chat inbound routing", () => { expectRequestRouteKind({ ...request, params: { ...request.params, threadId: "thread-other" } } as ServerRequest, "inactive"); }); + it("routes MCP forms with unsupported fields to diagnostic rejection", () => { + const request = mcpElicitationRequest(); + const unsupported = { + ...request, + params: { + ...request.params, + requestedSchema: { + type: "object", + required: ["nested"], + properties: { nested: { type: "object" } }, + }, + }, + } as ServerRequest; + + expectRequestRouteKind(unsupported, "unsupported"); + }); + it("marks scoped messages inactive when the thread or turn does not match", () => { const otherThread = { method: "item/agentMessage/delta",