Reject unsupported MCP elicitation forms

This commit is contained in:
murashit 2026-07-10 12:29:02 +09:00
parent 9616fe588d
commit 03a026512e
3 changed files with 38 additions and 12 deletions

View file

@ -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 {

View file

@ -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"] },

View file

@ -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",