fix(a2a): canonical buildMessageSendBody — role + kind parts (#2251) #39
Generated
+2
-2
@@ -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",
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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 || [];
|
||||
|
||||
@@ -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(),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user