fix(a2a): canonical buildMessageSendBody — role + kind parts (#2251) #39

Open
core-devops wants to merge 1 commits from fix/a2a-2251-ts-canonical-builder into main
5 changed files with 146 additions and 8 deletions
+2 -2
View File
@@ -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",
+60
View File
@@ -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<string, unknown>;
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<string, unknown>;
// 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");
});
});
+6
View File
@@ -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");
+2 -6
View File
@@ -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<ReturnType<typ
>(
"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 || [];
+76
View File
@@ -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(),
},
},
};
}