fix: Use SDK guards for elicitation validation (#850)

This commit is contained in:
Ben Brandt
2026-07-07 17:52:37 +02:00
committed by GitHub
parent f08c4521ec
commit 32b93501d7
4 changed files with 15 additions and 44 deletions

8
package-lock.json generated
View File

@@ -9,7 +9,7 @@
"version": "0.57.0",
"license": "Apache-2.0",
"dependencies": {
"@agentclientprotocol/sdk": "1.2.0",
"@agentclientprotocol/sdk": "1.2.1",
"@anthropic-ai/claude-agent-sdk": "0.3.202",
"zod": "^3.25.0 || ^4.0.0"
},
@@ -36,9 +36,9 @@
}
},
"node_modules/@agentclientprotocol/sdk": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@agentclientprotocol/sdk/-/sdk-1.2.0.tgz",
"integrity": "sha512-rJ3766M2eouZsNCaK21JbPD++JLBZ0PWq38dzj1sA4/i0njaQEkiC2F5v7MlizFW6akX+qu5Ks8R00Xw+YMHKA==",
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@agentclientprotocol/sdk/-/sdk-1.2.1.tgz",
"integrity": "sha512-jwYUdOQR7tc+Zfch53VL4JJyUNK/46q03uUTYb+PjECsmnNl94XFXOfYLJ8RBpMNidXd1rpOAVgb0vqD98xImA==",
"license": "Apache-2.0",
"peerDependencies": {
"zod": "^3.25.0 || ^4.0.0"

View File

@@ -63,7 +63,7 @@
"node": ">=22"
},
"dependencies": {
"@agentclientprotocol/sdk": "1.2.0",
"@agentclientprotocol/sdk": "1.2.1",
"@anthropic-ai/claude-agent-sdk": "0.3.202",
"zod": "^3.25.0 || ^4.0.0"
},

View File

@@ -1,9 +1,8 @@
import { randomUUID } from "node:crypto";
import { CreateElicitationResponse } from "@agentclientprotocol/sdk";
import type {
CreateElicitationRequest,
CreateElicitationResponse,
ElicitationAcceptAction,
ElicitationContentValue,
ElicitationPropertySchema,
ElicitationSchema,
EnumOption,
@@ -70,43 +69,18 @@ export function mcpElicitationToCreateRequest(
};
}
/** A wire value allowed in elicitation form content. */
function isElicitationContentValue(value: unknown): value is ElicitationContentValue {
return (
typeof value === "string" ||
typeof value === "number" ||
typeof value === "boolean" ||
(Array.isArray(value) && value.every((item) => typeof item === "string"))
);
}
/**
* Content of an accepted elicitation response.
*
* The response union includes a custom/future-action variant whose `action` is
* plain `string`, so a positive `=== "accept"` check can never narrow it away
* and `content` reads as `unknown`. Rather than asserting which union member
* this is, validate the values: the SDK's own wire validation has the same
* blind spot (an accept response with malformed content parses via the
* catch-all variant), so ill-typed values are dropped here either way.
* Uses the SDK's validating guard rather than an `action === "accept"` check:
* the guard both narrows past the union's custom/future variant and validates
* the payload, so a malformed accept (right tag, ill-typed content) yields
* empty content — the same classification the SDK's wire validators apply.
*/
function acceptedElicitationContent(
response: CreateElicitationResponse,
): NonNullable<ElicitationAcceptAction["content"]> {
if (response.action !== "accept") {
return {};
}
const { content } = response;
if (!content || typeof content !== "object") {
return {};
}
const validated: NonNullable<ElicitationAcceptAction["content"]> = {};
for (const [key, value] of Object.entries(content)) {
if (isElicitationContentValue(value)) {
validated[key] = value;
}
}
return validated;
return CreateElicitationResponse.isAccept(response) ? (response.content ?? {}) : {};
}
/**

View File

@@ -215,9 +215,7 @@ describe.skipIf(!process.env.RUN_INTEGRATION_TESTS)("ACP subprocess integration"
params: CreateElicitationRequest,
): Promise<CreateElicitationResponse> {
this.elicitations.push(params);
// The `in` check also excludes the custom/future-mode variant, whose
// `mode: string` would otherwise survive the literal comparison.
if (params.mode !== "form" || !("requestedSchema" in params)) {
if (!CreateElicitationRequest.isForm(params)) {
return { action: "decline" };
}
// Accept the first option of every choice field (skip the free-text one).
@@ -448,8 +446,7 @@ describe.skipIf(!process.env.RUN_INTEGRATION_TESTS)("ACP subprocess integration"
// ...built by our converter (indexed field key + free-text "Other" field),
// which confirms our interception path produced it rather than some other
// mechanism.
const properties =
elicitation.mode === "form" && "requestedSchema" in elicitation
const properties = CreateElicitationRequest.isForm(elicitation)
? Object.keys(elicitation.requestedSchema.properties ?? {})
: [];
expect(properties).toContain("question_0");