From 0c664ac7beaa82222f835daf305ac1736a4a7abc Mon Sep 17 00:00:00 2001 From: core-devops Date: Thu, 4 Jun 2026 14:32:37 -0700 Subject: [PATCH] =?UTF-8?q?fix(a2a):=20canonical=20buildMessageSendBody=20?= =?UTF-8?q?builder=20=E2=80=94=20role=20+=20kind=20parts=20(#2251)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Single canonical A2A message/send builder (src/utils/a2a.ts): role defaults "user", parts use the v0.3 "kind":"text" discriminator (was the invalid "type"), messageId via randomUUID. Routed the one message/send call site (chat_with_agent) through it. Part of the cross-repo SSOT fix anchored on the a2a-sdk SendMessageRequest schema (runtime + core companions). Contract test asserts role/kind/messageId + fails on the old role-less/type shape. Co-Authored-By: Claude Opus 4.8 (1M context) --- package-lock.json | 4 +- src/__tests__/a2a.test.ts | 60 +++++++++++++++++++++++++++++ src/__tests__/index.test.ts | 6 +++ src/tools/agents.ts | 8 +--- src/utils/a2a.ts | 76 +++++++++++++++++++++++++++++++++++++ 5 files changed, 146 insertions(+), 8 deletions(-) create mode 100644 src/__tests__/a2a.test.ts create mode 100644 src/utils/a2a.ts diff --git a/package-lock.json b/package-lock.json index 05f70d6..d954095 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@molecule-ai/mcp-server", - "version": "1.5.0", + "version": "1.6.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@molecule-ai/mcp-server", - "version": "1.5.0", + "version": "1.6.1", "dependencies": { "@modelcontextprotocol/sdk": "^1.12.0", "pino": "^9.6.0", diff --git a/src/__tests__/a2a.test.ts b/src/__tests__/a2a.test.ts new file mode 100644 index 0000000..307f89c --- /dev/null +++ b/src/__tests__/a2a.test.ts @@ -0,0 +1,60 @@ +/** + * Contract test for the canonical a2a `message/send` builder. + * + * These assertions encode the a2a wire contract (molecule-core #2251): + * - `params.message.role` is present and a valid role. + * - parts use the `kind:"text"` discriminator, NOT the legacy `type:"text"`. + * - a `messageId` is present and unique. + * + * The `type`/role-less-shape guards below would FAIL on the old hand-rolled + * `{ role: "user", parts: [{ type: "text", text }] }` (no messageId) envelope. + */ + +import { buildMessageSendBody } from "../utils/a2a.js"; + +describe("buildMessageSendBody()", () => { + test("produces a schema-valid message/send envelope", () => { + const body = buildMessageSendBody("Hello there"); + + expect(body.method).toBe("message/send"); + expect(body.params.message.role).toBe("user"); + expect(body.params.message.parts).toEqual([{ kind: "text", text: "Hello there" }]); + expect(typeof body.params.message.messageId).toBe("string"); + expect(body.params.message.messageId.length).toBeGreaterThan(0); + }); + + test("parts use the `kind` discriminator, NOT the legacy `type` field", () => { + const body = buildMessageSendBody("payload"); + const part = body.params.message.parts[0] as unknown as Record; + + expect(part.kind).toBe("text"); + expect(part.text).toBe("payload"); + // Guard against regression to the old `{ type: "text" }` shape. + expect(part).not.toHaveProperty("type"); + }); + + test("role defaults to 'user' and is always present", () => { + const body = buildMessageSendBody("x"); + const message = body.params.message as unknown as Record; + + // role MUST exist (the old shape was sometimes role-less). + expect(message).toHaveProperty("role"); + expect(message.role).toBe("user"); + }); + + test("honours an explicit 'agent' role", () => { + const body = buildMessageSendBody("x", { role: "agent" }); + expect(body.params.message.role).toBe("agent"); + }); + + test("generates a fresh messageId per call by default", () => { + const a = buildMessageSendBody("a"); + const b = buildMessageSendBody("b"); + expect(a.params.message.messageId).not.toBe(b.params.message.messageId); + }); + + test("honours an explicit messageId", () => { + const body = buildMessageSendBody("x", { messageId: "fixed-id-123" }); + expect(body.params.message.messageId).toBe("fixed-id-123"); + }); +}); diff --git a/src/__tests__/index.test.ts b/src/__tests__/index.test.ts index 3813190..69e48d2 100644 --- a/src/__tests__/index.test.ts +++ b/src/__tests__/index.test.ts @@ -590,7 +590,13 @@ describe("handleChatWithAgent()", () => { const sent = JSON.parse(callArgs[1].body); expect(sent.method).toBe("message/send"); expect(sent.params.message.role).toBe("user"); + // Parts MUST use the a2a `kind` discriminator, NOT the legacy `type` field. + expect(sent.params.message.parts[0].kind).toBe("text"); + expect(sent.params.message.parts[0].type).toBeUndefined(); expect(sent.params.message.parts[0].text).toBe("Hi there"); + // Envelope MUST carry a messageId. + expect(typeof sent.params.message.messageId).toBe("string"); + expect(sent.params.message.messageId.length).toBeGreaterThan(0); // Text parts should be extracted and joined expect(result.content[0].text).toBe("Hello from agent\nSecond line"); diff --git a/src/tools/agents.ts b/src/tools/agents.ts index 33ec671..ae85e2f 100644 --- a/src/tools/agents.ts +++ b/src/tools/agents.ts @@ -3,6 +3,7 @@ import { z } from "zod"; import { apiCall, toMcpResult, toMcpText } from "../api.js"; import { validate } from "../utils/validation.js"; import { platformGet } from "../api.js"; +import { buildMessageSendBody } from "../utils/a2a.js"; // --------------------------------------------------------------------------- // Schemas @@ -53,12 +54,7 @@ export async function handleChatWithAgent(args: unknown): Promise( "POST", `/workspaces/${params.workspace_id}/a2a`, - { - method: "message/send", - params: { - message: { role: "user", parts: [{ type: "text", text: params.message }] }, - }, - }, + buildMessageSendBody(params.message), ); const parts = (data as { result?: { parts?: Array<{ kind?: string; text?: string }> } } | null)?.result?.parts || []; diff --git a/src/utils/a2a.ts b/src/utils/a2a.ts new file mode 100644 index 0000000..9cae698 --- /dev/null +++ b/src/utils/a2a.ts @@ -0,0 +1,76 @@ +/** + * Canonical A2A (agent-to-agent) `message/send` request-body builder. + * + * The a2a protocol requires every outbound `message/send` envelope to be + * schema-valid: + * + * - `params.message.role` MUST be present and one of "user" | "agent". + * - Each message part MUST use the `kind` discriminator (e.g. + * `{ kind: "text", text: "..." }`) — NOT the legacy `type` field. + * - `params.message.messageId` MUST be a unique id for the message. + * + * Historically this server hand-rolled envelopes per call site, which drifted + * out of spec (e.g. `parts: [{ type: "text", ... }]` with no `messageId`). + * Every `message/send` body MUST now funnel through this builder so the wire + * shape is the single source of truth. See molecule-core #2251 for the + * cross-repo "missing role / type-vs-kind" fix this mirrors. + */ + +import { randomUUID } from "crypto"; + +/** A2A message roles, per the a2a spec. */ +export type A2aRole = "user" | "agent"; + +/** A single text part of an a2a message — note the `kind` discriminator. */ +export interface A2aTextPart { + kind: "text"; + text: string; +} + +/** The `message` object inside a `message/send` request's params. */ +export interface A2aMessage { + role: A2aRole; + parts: A2aTextPart[]; + messageId: string; +} + +/** A complete, schema-valid `message/send` JSON-RPC request body. */ +export interface A2aMessageSendBody { + method: "message/send"; + params: { + message: A2aMessage; + }; +} + +/** Options for {@link buildMessageSendBody}. */ +export interface BuildMessageSendOptions { + /** Sender role. Defaults to "user". */ + role?: A2aRole; + /** Explicit message id. Defaults to a fresh `crypto.randomUUID()`. */ + messageId?: string; +} + +/** + * Build a schema-valid a2a `message/send` request body. + * + * @param text The text content of the single text part. + * @param opts Optional `role` (default "user") and `messageId` (default a + * fresh UUID). + * @returns A complete `message/send` request body with `role`, a `kind:"text"` + * part, and a `messageId`. + */ +export function buildMessageSendBody( + text: string, + opts?: BuildMessageSendOptions, +): A2aMessageSendBody { + return { + method: "message/send", + params: { + message: { + role: opts?.role ?? "user", + parts: [{ kind: "text", text }], + messageId: opts?.messageId ?? randomUUID(), + }, + }, + }; +} -- 2.52.0