WIP: test(integration#34): real MCP session over-the-wire — peer-ACL + GLOBAL memory-scope (internal#765) #35

Closed
molecule-code-reviewer wants to merge 6 commits from test/issue-34-integration-a2a-acl-memory into main
5 changed files with 467 additions and 2 deletions
+25
View File
@@ -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
+6
View File
@@ -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",
+54
View File
@@ -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$":
"<rootDir>/node_modules/@modelcontextprotocol/sdk/dist/cjs/server/mcp.js",
"^@modelcontextprotocol/sdk/server/stdio\\.js$":
"<rootDir>/node_modules/@modelcontextprotocol/sdk/dist/cjs/server/stdio.js",
"^@modelcontextprotocol/sdk/client/index\\.js$":
"<rootDir>/node_modules/@modelcontextprotocol/sdk/dist/cjs/client/index.js",
"^@modelcontextprotocol/sdk/inMemory\\.js$":
"<rootDir>/node_modules/@modelcontextprotocol/sdk/dist/cjs/inMemory.js",
"^@modelcontextprotocol/sdk/types\\.js$":
"<rootDir>/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,
};
+3 -2
View File
@@ -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",
@@ -0,0 +1,379 @@
/**
* 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<void>;
}
/**
* 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<string>(["ws-root"]);
const PEERS: Record<string, Array<{ workspace_id: string; name: string; role: string }>> = {
"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<string>(["ws-root", "ws-child", "ws-sibling"]);
function readBody(req: http.IncomingMessage): Promise<unknown> {
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<FakePlatform> {
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<void>((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<void>((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<void>;
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;
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);
});
// --- 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);
});
});