From 4a6d3436878ce28d96d7bfc266bfda8f99e20676 Mon Sep 17 00:00:00 2001 From: molecule-code-reviewer Date: Wed, 3 Jun 2026 00:45:12 +0000 Subject: [PATCH 1/6] =?UTF-8?q?test(integration#34):=20real=20MCP=20sessio?= =?UTF-8?q?n=20over-the-wire=20=E2=80=94=20peer-ACL=20+=20GLOBAL=20memory-?= =?UTF-8?q?scope=20(internal#765)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/__tests__/a2a_session.integration.test.ts | 372 ++++++++++++++++++ 1 file changed, 372 insertions(+) create mode 100644 src/__tests__/a2a_session.integration.test.ts diff --git a/src/__tests__/a2a_session.integration.test.ts b/src/__tests__/a2a_session.integration.test.ts new file mode 100644 index 0000000..f5f7b4f --- /dev/null +++ b/src/__tests__/a2a_session.integration.test.ts @@ -0,0 +1,372 @@ +/** + * INTEGRATION regression test — molecule-ai/molecule-mcp-server#34 + * + * SOP rule internal#765 (regression-coverage). The repo is otherwise entirely + * fetch-mocked Jest unit tests; the security-bearing peer-ACL boundary, the + * GLOBAL memory-scope write boundary, and the highest-frequency + * reply / delegate / list_peers / commit_memory paths had NO real, + * over-the-wire gate, and async_delegate had ZERO tests. + * + * This closes that gap with a REAL integration session: + * + * - The REAL MCP server is built via createServer() (real McpServer, real + * tool registrations, real Zod validation, real handlers, real api.ts + * apiCall()/platformGet() → real fetch). NO SDK mock, NO fetch mock — + * contrast index.test.ts which jest.mock()s both. internal#765 requires + * the real layer (integration), not a mock-only proxy. + * - It is connected to a REAL MCP Client over a REAL InMemoryTransport + * linked pair, so every tool call is genuine JSON-RPC serialized + * OVER-THE-WIRE through the transport boundary — NOT a direct handler + * call. stdio and InMemory share the identical Protocol/Server request + * loop; the only difference is the byte pipe. We use InMemory so CI need + * not spawn a child process, while still exercising the real + * client → protocol → server → handler → fetch path. + * - A REAL node:http server stands in for the platform ("fake-but-real"): + * it speaks the actual REST contract api.ts targets, and enforces the SAME + * authorization boundaries the Go control plane does: + * * peer-ACL — GET /registry/:id/peers only returns peers the caller + * may reach; an unknown / cross-org workspace gets 403. + * * GLOBAL memory scope — POST /workspaces/:id/memories with + * scope="GLOBAL" only succeeds for a tier-0 root; a non-root caller + * is rejected 403 AUTH_ERROR. + * + * Env note: api.ts captures PLATFORM_URL as a module-load-time const from + * MOLECULE_API_URL. We therefore set the env to the fake-platform URL and + * lazily require("../index.js") AFTER the http server is listening, so the + * server's fetch target is the fake platform — not the localhost default. + * + * WATCH-FAIL intent (how a regression of the covered behavior trips this): + * - async_delegate dropping target_id/task from the POST body → fake + * platform records no delegation / 400 → assertion on recorded body FAILS. + * - list_peers not threading workspace_id into /registry/:id/peers → wrong + * peer set or 403 → ACL assertions FAIL. + * - commit_memory dropping `scope` → a non-root GLOBAL write would silently + * succeed → the "unauthorized GLOBAL write is rejected" assertion FAILS. + * - Removing the platform-side GLOBAL / peer-ACL gate → the deny assertions + * FAIL (they expect a structured AUTH_ERROR, not data). + */ + +import * as http from "node:http"; +import type { AddressInfo } from "node:net"; + +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; + +// --------------------------------------------------------------------------- +// Fake-but-real platform — a real node:http server speaking the REST contract +// src/api.ts targets, with the SAME ACL + scope gates as the control plane. +// --------------------------------------------------------------------------- + +interface CapturedRequest { + method: string; + path: string; + body: unknown; +} + +interface FakePlatform { + server: http.Server; + baseUrl: string; + requests: CapturedRequest[]; + delegations: Array<{ workspace_id: string; target_id: string; task: string }>; + memories: Array<{ workspace_id: string; content: string; scope: string }>; + close: () => Promise; +} + +/** + * Canvas/registry fixture mirroring how the platform models reachability. + * + * - "ws-root" : tier-0 root (org owner). MAY write GLOBAL memory. Peers = + * its children. + * - "ws-child" : tier-1 child of ws-root. NOT a root → may NOT write GLOBAL. + * Peers = parent + siblings. + * - "ws-foreign" : a workspace in a DIFFERENT org. Not reachable / not a peer + * of ws-root or ws-child. + */ +const TIER0_ROOTS = new Set(["ws-root"]); + +const PEERS: Record> = { + "ws-root": [{ workspace_id: "ws-child", name: "Child Agent", role: "child" }], + "ws-child": [ + { workspace_id: "ws-root", name: "Root Agent", role: "parent" }, + { workspace_id: "ws-sibling", name: "Sibling Agent", role: "sibling" }, + ], +}; + +// Same-org, addressable delegation targets (ws-foreign is intentionally absent). +const REACHABLE_TARGETS = new Set(["ws-root", "ws-child", "ws-sibling"]); + +function readBody(req: http.IncomingMessage): Promise { + return new Promise((resolve) => { + let raw = ""; + req.on("data", (c) => (raw += c)); + req.on("end", () => { + if (!raw) return resolve(undefined); + try { + resolve(JSON.parse(raw)); + } catch { + resolve(raw); + } + }); + }); +} + +async function startFakePlatform(): Promise { + const requests: CapturedRequest[] = []; + const delegations: FakePlatform["delegations"] = []; + const memories: FakePlatform["memories"] = []; + + const server = http.createServer(async (req, res) => { + const url = new URL(req.url || "/", "http://internal"); + const path = url.pathname; + const body = await readBody(req); + requests.push({ method: req.method || "GET", path, body }); + + const send = (status: number, payload: unknown) => { + res.writeHead(status, { "Content-Type": "application/json" }); + res.end(JSON.stringify(payload)); + }; + + if (path === "/health") return send(200, { status: "ok" }); + + // --- peer-ACL: GET /registry/:id/peers ------------------------------- + const peersMatch = path.match(/^\/registry\/([^/]+)\/peers$/); + if (peersMatch && req.method === "GET") { + const wsId = decodeURIComponent(peersMatch[1]); + const peers = PEERS[wsId]; + if (!peers) { + return send(403, { error: "AUTH_ERROR", detail: `workspace ${wsId} not reachable` }); + } + return send(200, { peers }); + } + + // --- delegate: POST /workspaces/:id/delegate ------------------------- + const delegateMatch = path.match(/^\/workspaces\/([^/]+)\/delegate$/); + if (delegateMatch && req.method === "POST") { + const wsId = decodeURIComponent(delegateMatch[1]); + const b = (body || {}) as { target_id?: string; task?: string }; + if (!b.target_id || !b.task) { + return send(400, { error: "INVALID_ARGUMENTS", detail: "target_id and task are required" }); + } + if (!REACHABLE_TARGETS.has(b.target_id)) { + return send(403, { error: "AUTH_ERROR", detail: `target ${b.target_id} not reachable from ${wsId}` }); + } + delegations.push({ workspace_id: wsId, target_id: b.target_id, task: b.task }); + return send(202, { delegation_id: `del-${delegations.length}`, status: "pending", target_id: b.target_id }); + } + + // --- commit_memory: POST /workspaces/:id/memories -------------------- + const memMatch = path.match(/^\/workspaces\/([^/]+)\/memories$/); + if (memMatch && req.method === "POST") { + const wsId = decodeURIComponent(memMatch[1]); + const b = (body || {}) as { content?: string; scope?: string }; + const scope = b.scope || "LOCAL"; + if (scope === "GLOBAL" && !TIER0_ROOTS.has(wsId)) { + return send(403, { + error: "AUTH_ERROR", + detail: `workspace ${wsId} is not a tier-0 root; GLOBAL memory writes are forbidden`, + }); + } + memories.push({ workspace_id: wsId, content: b.content || "", scope }); + return send(201, { memory_id: `mem-${memories.length}`, scope }); + } + + // --- reply_to_workspace analog on this server's surface -------------- + // notify_user → POST /workspaces/:id/notify (canvas reply primitive). + const notifyMatch = path.match(/^\/workspaces\/([^/]+)\/notify$/); + if (notifyMatch && req.method === "POST") { + return send(200, { delivered: true }); + } + + return send(404, { error: "NOT_FOUND", detail: path }); + }); + + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const { port } = server.address() as AddressInfo; + + return { + server, + baseUrl: `http://127.0.0.1:${port}`, + requests, + delegations, + memories, + close: () => new Promise((resolve) => server.close(() => resolve())), + }; +} + +/** Parse the JSON blob a handler wraps via toMcpResult(). */ +function parseToolJson(result: unknown): any { + const r = result as { content: Array<{ type: string; text: string }> }; + const text = r.content.map((c) => c.text).join(""); + return JSON.parse(text); +} + +// --------------------------------------------------------------------------- +// Suite +// --------------------------------------------------------------------------- + +describe("integration#34: real MCP session over-the-wire (peer-ACL + GLOBAL memory-scope)", () => { + let platform: FakePlatform; + let client: Client; + let closeSession: () => Promise; + const savedEnv = { ...process.env }; + + beforeAll(async () => { + // 1. Bring up the fake-but-real platform. + platform = await startFakePlatform(); + + // 2. Point the server's REST client at it BEFORE the module is loaded, + // because api.ts captures PLATFORM_URL as a load-time const. + process.env.MOLECULE_API_URL = platform.baseUrl; + delete process.env.MOLECULE_URL; + delete process.env.PLATFORM_URL; + + // 3. Lazily load the REAL server module now that the env is set. + // jest.isolateModules guarantees a fresh module graph that re-reads env. + let createServer!: () => any; + jest.isolateModules(() => { + // eslint-disable-next-line @typescript-eslint/no-var-requires + ({ createServer } = require("../index.js")); + }); + + // 4. Connect a REAL client to the REAL server over a REAL transport pair. + const server = createServer(); + client = new Client({ name: "issue-34-integration-test", version: "1.0.0" }); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await Promise.all([server.connect(serverTransport), client.connect(clientTransport)]); + closeSession = async () => { + await client.close(); + await server.close(); + }; + }); + + afterAll(async () => { + if (closeSession) await closeSession(); + if (platform) await platform.close(); + process.env = savedEnv; + }); + + it("exposes the A2A tool surface over the wire (list_peers/async_delegate/commit_memory/notify_user)", async () => { + const { tools } = await client.listTools(); + const names = tools.map((t) => t.name); + expect(names).toEqual(expect.arrayContaining(["list_peers", "async_delegate", "commit_memory", "notify_user"])); + }); + + // --- list_peers + peer-ACL ------------------------------------------------ + + it("list_peers returns only ACL-reachable peers for the calling workspace", async () => { + const res = await client.callTool({ name: "list_peers", arguments: { workspace_id: "ws-child" } }); + const data = parseToolJson(res); + expect(data.peers.map((p: any) => p.workspace_id).sort()).toEqual(["ws-root", "ws-sibling"]); + // ws-foreign (different org) must NOT leak into the peer set. + expect(JSON.stringify(data)).not.toContain("ws-foreign"); + // The handler must have hit the per-workspace registry path (ACL scope). + expect(platform.requests.some((r) => r.method === "GET" && r.path === "/registry/ws-child/peers")).toBe(true); + }); + + it("list_peers surfaces a peer-ACL denial (403) for an unreachable / cross-org workspace", async () => { + const res = await client.callTool({ name: "list_peers", arguments: { workspace_id: "ws-foreign" } }); + const data = parseToolJson(res); + // api.ts maps non-2xx to { error: "HTTP 403", detail: "...AUTH_ERROR..." }. + expect(data.error).toBe("HTTP 403"); + expect(String(data.detail)).toContain("not reachable"); + expect(data.peers).toBeUndefined(); + }); + + // --- async_delegate (was ZERO tests) ------------------------------------- + + it("async_delegate POSTs {target_id, task} to a reachable peer and returns a delegation_id", async () => { + const res = await client.callTool({ + name: "async_delegate", + arguments: { workspace_id: "ws-child", target_id: "ws-sibling", task: "summarize the Q3 report" }, + }); + const data = parseToolJson(res); + expect(data.delegation_id).toMatch(/^del-\d+$/); + expect(data.status).toBe("pending"); + expect(data.target_id).toBe("ws-sibling"); + + // WATCH-FAIL: the real request body must carry target_id + task. + const recorded = platform.delegations.find((d) => d.workspace_id === "ws-child"); + expect(recorded).toBeDefined(); + expect(recorded).toMatchObject({ target_id: "ws-sibling", task: "summarize the Q3 report" }); + + const sent = platform.requests.find((r) => r.method === "POST" && r.path === "/workspaces/ws-child/delegate"); + expect(sent?.body).toMatchObject({ target_id: "ws-sibling", task: "summarize the Q3 report" }); + }); + + it("async_delegate to an unreachable target is denied (peer-ACL, 403) and records no delegation", async () => { + const before = platform.delegations.length; + const res = await client.callTool({ + name: "async_delegate", + arguments: { workspace_id: "ws-child", target_id: "ws-foreign", task: "leak org data" }, + }); + const data = parseToolJson(res); + expect(data.error).toBe("HTTP 403"); + expect(String(data.detail)).toContain("not reachable"); + // No delegation may be recorded for a denied target. + expect(platform.delegations.length).toBe(before); + }); + + it("async_delegate rejects missing required args before any platform call (real Zod validation over the wire)", async () => { + const before = platform.requests.length; + await expect( + // target_id + task are required by the registered Zod schema. + client.callTool({ name: "async_delegate", arguments: { workspace_id: "ws-child" } }), + ).rejects.toThrow(); + // Validation must short-circuit — no POST should reach the platform. + expect(platform.requests.length).toBe(before); + }); + + // --- commit_memory + GLOBAL-scope authorization -------------------------- + + it("commit_memory LOCAL succeeds for a non-root workspace and carries scope over the wire", async () => { + const res = await client.callTool({ + name: "commit_memory", + arguments: { workspace_id: "ws-child", content: "child remembers a LOCAL fact", scope: "LOCAL" }, + }); + const data = parseToolJson(res); + expect(data.memory_id).toMatch(/^mem-\d+$/); + expect(data.scope).toBe("LOCAL"); + const sent = platform.requests.find( + (r) => r.method === "POST" && r.path === "/workspaces/ws-child/memories" && (r.body as any)?.scope === "LOCAL", + ); + expect((sent?.body as any)?.content).toBe("child remembers a LOCAL fact"); + }); + + it("commit_memory GLOBAL succeeds for a tier-0 root workspace", async () => { + const res = await client.callTool({ + name: "commit_memory", + arguments: { workspace_id: "ws-root", content: "org-wide policy", scope: "GLOBAL" }, + }); + const data = parseToolJson(res); + expect(data.memory_id).toMatch(/^mem-\d+$/); + expect(data.scope).toBe("GLOBAL"); + expect(platform.memories.some((m) => m.workspace_id === "ws-root" && m.scope === "GLOBAL")).toBe(true); + }); + + it("commit_memory GLOBAL from a NON-root workspace is rejected (AUTH_ERROR) and writes nothing", async () => { + const before = platform.memories.length; + const res = await client.callTool({ + name: "commit_memory", + arguments: { workspace_id: "ws-child", content: "child tries to escalate to GLOBAL", scope: "GLOBAL" }, + }); + const data = parseToolJson(res); + // WATCH-FAIL: if scope is dropped or the gate removed, this becomes a 201. + expect(data.error).toBe("HTTP 403"); + expect(String(data.detail)).toContain("not a tier-0 root"); + // The unauthorized GLOBAL write must NOT have been persisted. + expect(platform.memories.length).toBe(before); + expect(platform.memories.some((m) => m.workspace_id === "ws-child" && m.scope === "GLOBAL")).toBe(false); + }); + + // --- reply_to_workspace analog (canvas reply primitive) ------------------ + + it("notify_user delivers a canvas reply over the wire (reply_to_workspace analog on this surface)", async () => { + const res = await client.callTool({ + name: "notify_user", + arguments: { workspace_id: "ws-child", type: "delegation_complete" }, + }); + const data = parseToolJson(res); + expect(data.delivered).toBe(true); + expect(platform.requests.some((r) => r.method === "POST" && r.path === "/workspaces/ws-child/notify")).toBe(true); + }); +}); -- 2.52.0 From 473ee8ce2a79514aeda241a5236e85037387f5d0 Mon Sep 17 00:00:00 2001 From: molecule-code-reviewer Date: Wed, 3 Jun 2026 00:45:13 +0000 Subject: [PATCH 2/6] test(integration#34): dedicated integration jest config (real non-mocked SDK + transport) --- jest.integration.cjs | 54 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 jest.integration.cjs diff --git a/jest.integration.cjs b/jest.integration.cjs new file mode 100644 index 0000000..8e933d7 --- /dev/null +++ b/jest.integration.cjs @@ -0,0 +1,54 @@ +/** + * Jest config for the INTEGRATION test layer (SOP rule internal#765). + * + * Distinct from the default jest.config.cjs (unit, fetch-mocked) so the + * integration suite: + * - runs as its own CI-gating job (npm run test:integration), and + * - can map the REAL (non-mocked) MCP SDK client + InMemory transport to + * their CJS builds, which the unit config did not need. + * + * The integration suite uses NEITHER an SDK mock NOR a fetch mock — it boots + * the real server over a real transport against a real node:http platform. + * + * @type {import('jest').Config} + */ +module.exports = { + preset: "ts-jest", + testEnvironment: "node", + // Only the *.integration.test.ts files live in this layer. + testMatch: ["**/__tests__/**/*.integration.test.ts"], + moduleNameMapper: { + // Strip .js extensions from relative imports so ts-jest resolves .ts. + "^(\\.{1,2}/.*)\\.js$": "$1", + // Map ESM-only MCP SDK imports to their CJS equivalents so the real + // (non-mocked) SDK loads under ts-jest's CommonJS transform. + "^@modelcontextprotocol/sdk/server/mcp\\.js$": + "/node_modules/@modelcontextprotocol/sdk/dist/cjs/server/mcp.js", + "^@modelcontextprotocol/sdk/server/stdio\\.js$": + "/node_modules/@modelcontextprotocol/sdk/dist/cjs/server/stdio.js", + "^@modelcontextprotocol/sdk/client/index\\.js$": + "/node_modules/@modelcontextprotocol/sdk/dist/cjs/client/index.js", + "^@modelcontextprotocol/sdk/inMemory\\.js$": + "/node_modules/@modelcontextprotocol/sdk/dist/cjs/inMemory.js", + "^@modelcontextprotocol/sdk/types\\.js$": + "/node_modules/@modelcontextprotocol/sdk/dist/cjs/types.js", + }, + transform: { + "^.+\\.tsx?$": [ + "ts-jest", + { + tsconfig: { + module: "CommonJS", + moduleResolution: "node", + esModuleInterop: true, + strict: true, + target: "ES2022", + isolatedModules: true, + }, + diagnostics: false, + }, + ], + }, + // Real HTTP + transport teardown can take a beat; keep a generous timeout. + testTimeout: 30000, +}; -- 2.52.0 From 4a255ce0f2196a040f906df2c06dbcd4960a3ae0 Mon Sep 17 00:00:00 2001 From: molecule-code-reviewer Date: Wed, 3 Jun 2026 00:45:22 +0000 Subject: [PATCH 3/6] test(integration#34): exclude *.integration.test.ts from the unit jest run --- jest.config.cjs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/jest.config.cjs b/jest.config.cjs index 2983255..1dac9a3 100644 --- a/jest.config.cjs +++ b/jest.config.cjs @@ -3,6 +3,12 @@ module.exports = { preset: "ts-jest", testEnvironment: "node", testMatch: ["**/__tests__/**/*.test.ts"], + // The integration layer (SOP internal#765) runs under its own config + // (jest.integration.cjs) because it loads the REAL, non-mocked MCP SDK + // client + InMemory transport and must NOT be picked up by this unit run + // (which lacks the client/inMemory CJS module mappings). Exclude it here so + // `npm test` and `npm run test:integration` stay cleanly separated. + testPathIgnorePatterns: ["/node_modules/", "\\.integration\\.test\\.ts$"], moduleNameMapper: { // Strip .js extensions from imports so ts-jest can resolve .ts files "^(\\.{1,2}/.*)\\.js$": "$1", -- 2.52.0 From 67287917348801528f3c1f501d04aaf8cd0ca67b Mon Sep 17 00:00:00 2001 From: molecule-code-reviewer Date: Wed, 3 Jun 2026 00:45:22 +0000 Subject: [PATCH 4/6] test(integration#34): add test:integration script --- package.json | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index d192032..7c8f150 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@molecule-ai/mcp-server", "version": "1.4.1", - "description": "MCP server for Molecule AI Agent Team \u2014 manage workspaces, agents, and skills from any AI coding tool", + "description": "MCP server for Molecule AI Agent Team — manage workspaces, agents, and skills from any AI coding tool", "type": "module", "exports": { ".": "./dist/index.js", @@ -17,7 +17,8 @@ "scripts": { "build": "tsc", "start": "node dist/index.js", - "test": "jest" + "test": "jest", + "test:integration": "jest --config jest.integration.cjs" }, "dependencies": { "@modelcontextprotocol/sdk": "^1.12.0", -- 2.52.0 From fac7328f8aa108017c263e12e7504b2b469df137 Mon Sep 17 00:00:00 2001 From: molecule-code-reviewer Date: Wed, 3 Jun 2026 00:45:23 +0000 Subject: [PATCH 5/6] ci(integration#34): add merge-gating integration job (internal#765) --- .gitea/workflows/ci.yml | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 545c0f4..de4cfe4 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -24,3 +24,28 @@ jobs: - name: Test run: npm test + + # Integration layer (SOP rule internal#765, issue #34): a REAL MCP client <-> + # REAL server over a REAL transport, against a REAL node:http platform that + # enforces peer-ACL + GLOBAL-memory-scope authorization. NEITHER the SDK nor + # fetch is mocked here (contrast the unit `test` job). This is a separate, + # merge-gating job so a regression in list_peers / async_delegate / + # commit_memory / notify_user authorization fails CI loudly rather than + # hiding behind the fetch-mocked unit suite. + integration: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Install dependencies + run: npm install + + - name: Build + run: npm run build + + - name: Integration tests (real transport + real platform) + run: npm run test:integration -- 2.52.0 From a1d3030c35837975f390ec64848ee03cbb9eff40 Mon Sep 17 00:00:00 2001 From: "Molecule AI Dev Engineer A (Kimi)" Date: Sun, 7 Jun 2026 03:59:11 +0000 Subject: [PATCH 6/6] =?UTF-8?q?test(integration#34):=20fix=20async=5Fdeleg?= =?UTF-8?q?ate=20Zod=20validation=20assertion=20=E2=80=94=20MCP=20returns?= =?UTF-8?q?=20isError,=20not=20rejection?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The real MCP client resolves with an {isError:true} result when Zod validation fails; it does not throw. Update the assertion to match the actual SDK contract. All 10 integration tests now pass. --- src/__tests__/a2a_session.integration.test.ts | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/src/__tests__/a2a_session.integration.test.ts b/src/__tests__/a2a_session.integration.test.ts index f5f7b4f..7b17c8f 100644 --- a/src/__tests__/a2a_session.integration.test.ts +++ b/src/__tests__/a2a_session.integration.test.ts @@ -308,10 +308,17 @@ describe("integration#34: real MCP session over-the-wire (peer-ACL + GLOBAL memo it("async_delegate rejects missing required args before any platform call (real Zod validation over the wire)", async () => { const before = platform.requests.length; - await expect( - // target_id + task are required by the registered Zod schema. - client.callTool({ name: "async_delegate", arguments: { workspace_id: "ws-child" } }), - ).rejects.toThrow(); + const res = await client.callTool({ + name: "async_delegate", + arguments: { workspace_id: "ws-child" }, + }); + // Real Zod validation produces an MCP error result (isError=true), + // not a thrown exception — the transport resolves with the error shape. + expect((res as any).isError).toBe(true); + const text = (res as any).content?.[0]?.text ?? ""; + expect(text).toContain("Input validation error"); + expect(text).toContain("target_id"); + expect(text).toContain("task"); // Validation must short-circuit — no POST should reach the platform. expect(platform.requests.length).toBe(before); }); -- 2.52.0