fix(v2): reject MCP transports and prompt content the agent doesn't advertise (#1459)

Stacked on #1457 (SDK 2.1.0).

The v2 spec says Agents MUST reject with `-32602` a transport or content
type they did not advertise. Before this PR, both of these were accepted
and then silently dropped:

- **`acp` MCP servers:** the agent advertises only `stdio` and `http`.
The v1 setup skipped these servers.
- **`audio` blocks:** not advertised, and `promptToClaude` ignored them.
This also applies to a v2 `systemPrompt`, since it is converted the same
way.

Embedded blob `resource` blocks are still accepted; see the blob
resources PR.
This commit is contained in:
Ben Brandt
2026-10-28 13:35:01 +01:00
committed by GitHub
parent c2e85cb580
commit 2480888b90
3 changed files with 50 additions and 9 deletions

View File

@@ -452,6 +452,21 @@ describe("ACP v2 sessions", () => {
expect(sdk.queryOptions).toHaveLength(0);
});
it("rejects an MCP-over-ACP server, which the agent does not advertise", async () => {
const { app, authUpdate } = v2Client();
await app.connectWith(connectRouter(), async (agent) => {
await initializeV2(agent);
await expect(
agent.request(v2.methods.agent.session.new, {
cwd,
mcpServers: [{ type: "acp", name: "tools", serverId: "server-1" }],
}),
).rejects.toMatchObject({ code: -32602, data: { type: "acp" } });
await authUpdate(1);
});
expect(sdk.queryOptions).toHaveLength(0);
});
it("lists, resumes, closes, and deletes sessions", async () => {
const { app, authUpdate } = v2Client();
await app.connectWith(connectRouter(), async (agent) => {
@@ -5422,6 +5437,32 @@ describe("ACP v2 session translation", () => {
expect(prompt([])).toEqual({ audience: [], priority: 1 });
});
it("rejects audio prompt content, which the agent does not advertise", () => {
expect(() =>
v1PromptRequest({
sessionId: "s",
prompt: [{ type: "audio", data: "AAAA", mimeType: "audio/wav" }],
}),
).toThrow(expect.objectContaining({ code: -32602, data: { type: "audio" } }));
// An embedded blob resource still converts.
expect(
v1PromptRequest({
sessionId: "s",
prompt: [
{
type: "resource",
resource: { uri: "file:///a.bin", blob: "AAAA", mimeType: "application/octet-stream" },
},
],
}).prompt,
).toEqual([
{
type: "resource",
resource: { uri: "file:///a.bin", blob: "AAAA", mimeType: "application/octet-stream" },
},
]);
});
it("fails on a message chunk without a messageId, which v2 requires", () => {
expect(() =>
v2SessionUpdate({ sessionUpdate: "agent_message_chunk", content: text("hi")[0] }),

View File

@@ -141,9 +141,11 @@ function idle({ stopReason, usage, _meta }: TurnOutcome): v2.SessionUpdate {
}
/**
* A v2 prompt may hold content blocks that v1 cannot express: a custom one,
* or one from a newer ACP version. Clients send only the kinds the agent
* advertised, so such a block is rejected rather than silently dropped.
* A v2 prompt may hold content blocks that the agent does not take: audio,
* which it does not advertise and Claude cannot read, a custom one, or one
* from a newer ACP version. Clients send only the kinds the agent advertised,
* so such a block fails the request with invalid params (-32602) rather than
* being dropped from the prompt.
*/
export function v1PromptRequest({ prompt, ...request }: v2.PromptRequest): PromptRequest {
return { ...request, prompt: prompt.map(v1ContentBlock) };
@@ -153,7 +155,6 @@ export function v1ContentBlock(block: v2.ContentBlock): ContentBlock {
if (
v2.ContentBlock.isText(block) ||
v2.ContentBlock.isImage(block) ||
v2.ContentBlock.isAudio(block) ||
v2.ContentBlock.isResourceLink(block) ||
v2.ContentBlock.isResource(block)
) {

View File

@@ -165,8 +165,10 @@ function v2ConfigOptionsField(options: SessionConfigOption[] | null | undefined)
/**
* v2 requires a `type` on every transport and makes the empty lists optional.
* The agent recognizes a v1 stdio server by its missing `type`, so the v2 tag
* is dropped. A transport that v1 cannot express is rejected rather than
* silently left out of the session.
* is dropped. Any other transport, such as `acp` or a custom `_` type, fails
* the request with invalid params (-32602): the agent advertises only stdio
* and HTTP, and the v1 session setup would otherwise drop the server without
* telling the client.
*/
function v1McpServer(server: v2.McpServer): McpServer {
if (v2.McpServer.isStdio(server)) {
@@ -176,9 +178,6 @@ function v1McpServer(server: v2.McpServer): McpServer {
if (v2.McpServer.isHttp(server)) {
return { ...server, headers: server.headers ?? [] };
}
if (v2.McpServer.isAcp(server)) {
return server;
}
throw RequestError.invalidParams(
{ type: server.type },
`MCP servers of type ${server.type} are not supported`,