28b8aa36c6
MCP capability broker gateway with closed 9-tool registry, adapter router, write-method blocklist (INV-7 backstop), token-bucket rate limiter (60/min user, 300/min tenant), SSE stream manager (ULID, per-call, 30s timeout), OpenAI↔MCP translator, in-process custom transport + stdio, mcp_adapters table with RLS, 5 API routes, 4 stub adapters + McpAdapter interface, 7 MCP conformance tests. G-012 (audit type widening), G-015 (INV-7 framing), G-016 (two enforcement models), G-017 (stdio-interop 7th test), G-020 (McpAdapter interface) applied. Tests: 254 green (224 unit + 32 conformance). Coverage: 88.7% on packages/mcp. M1 non-regression: all M1 tests pass. ---ci--- phase: 1 milestone: v0.2 status: complete wave: F phase_role: execution ---/ci---
141 lines
4.8 KiB
TypeScript
141 lines
4.8 KiB
TypeScript
/**
|
|
* translator.test.ts — OpenAI ↔ MCP translation (R-001 §5, conformance test 5).
|
|
*/
|
|
|
|
import { describe, it, expect } from "vitest";
|
|
import {
|
|
toolCallToMcp,
|
|
toolCallsToMcp,
|
|
mcpResultToToolMessage,
|
|
toolsToOpenAi,
|
|
TranslationError,
|
|
} from "../src/translator.js";
|
|
import { listTools } from "../src/registry.js";
|
|
import type { McpResult, OpenAiToolCall } from "../src/translator.js";
|
|
|
|
const okResult: McpResult = {
|
|
content: [{ type: "text", text: '{"repos":["a","b"]}' }],
|
|
isError: false,
|
|
};
|
|
const errResult: McpResult = {
|
|
content: [{ type: "text", text: "upstream timed out" }],
|
|
isError: true,
|
|
};
|
|
|
|
describe("OpenAI tool_calls → MCP tools/call", () => {
|
|
it("parses a JSON-string arguments into an object", () => {
|
|
const tc: OpenAiToolCall = {
|
|
id: "call_1",
|
|
type: "function",
|
|
function: { name: "github.list_repos", arguments: "{}" },
|
|
};
|
|
expect(toolCallToMcp(tc)).toEqual({ name: "github.list_repos", arguments: {} });
|
|
});
|
|
|
|
it("parses arguments with content", () => {
|
|
const tc: OpenAiToolCall = {
|
|
id: "call_2",
|
|
type: "function",
|
|
function: { name: "proxmox.list_vms", arguments: '{"node":"pve1"}' },
|
|
};
|
|
expect(toolCallToMcp(tc)).toEqual({ name: "proxmox.list_vms", arguments: { node: "pve1" } });
|
|
});
|
|
|
|
it("accepts an already-object arguments (defensive coercion)", () => {
|
|
const tc = {
|
|
id: "call_3",
|
|
type: "function" as const,
|
|
function: { name: "proxmox.list_vms", arguments: { node: "pve1" } },
|
|
};
|
|
expect(toolCallToMcp(tc)).toEqual({ name: "proxmox.list_vms", arguments: { node: "pve1" } });
|
|
});
|
|
|
|
it("throws TranslationError (protocol error) on invalid JSON arguments", () => {
|
|
const tc: OpenAiToolCall = {
|
|
id: "call_4",
|
|
type: "function",
|
|
function: { name: "github.list_repos", arguments: "{not json" },
|
|
};
|
|
expect(() => toolCallToMcp(tc)).toThrow(TranslationError);
|
|
expect(() => toolCallToMcp(tc)).toThrow(/not valid JSON/);
|
|
});
|
|
|
|
it("throws when arguments parses to a non-object (e.g. array)", () => {
|
|
const tc: OpenAiToolCall = {
|
|
id: "call_5",
|
|
type: "function",
|
|
function: { name: "github.list_repos", arguments: "[1,2,3]" },
|
|
};
|
|
expect(() => toolCallToMcp(tc)).toThrow(/must parse to a JSON object/);
|
|
});
|
|
|
|
it("throws when function.name is missing", () => {
|
|
const tc = { id: "x", type: "function" as const, function: { arguments: "{}" } };
|
|
expect(() => toolCallToMcp(tc as unknown as OpenAiToolCall)).toThrow(/name/);
|
|
});
|
|
|
|
it("translates an array of tool_calls (one per call)", () => {
|
|
const tcs: OpenAiToolCall[] = [
|
|
{ id: "a", type: "function", function: { name: "github.list_repos", arguments: "{}" } },
|
|
{ id: "b", type: "function", function: { name: "gitea.list_repos", arguments: "{}" } },
|
|
];
|
|
expect(toolCallsToMcp(tcs)).toHaveLength(2);
|
|
expect(toolCallsToMcp(tcs)[1].name).toBe("gitea.list_repos");
|
|
});
|
|
});
|
|
|
|
describe("MCP result → OpenAI tool message", () => {
|
|
it("maps isError:false → content is the text", () => {
|
|
const msg = mcpResultToToolMessage(okResult, "call_1");
|
|
expect(msg).toEqual({
|
|
role: "tool",
|
|
tool_call_id: "call_1",
|
|
content: '{"repos":["a","b"]}',
|
|
});
|
|
});
|
|
|
|
it("maps isError:true → content prefixed with 'ERROR: ' (M2 convention)", () => {
|
|
const msg = mcpResultToToolMessage(errResult, "call_2");
|
|
expect(msg.content).toBe("ERROR: upstream timed out");
|
|
expect(msg.tool_call_id).toBe("call_2");
|
|
});
|
|
|
|
it("concatenates multiple text blocks", () => {
|
|
const multi: McpResult = {
|
|
content: [
|
|
{ type: "text", text: "part1-" },
|
|
{ type: "text", text: "part2" },
|
|
],
|
|
isError: false,
|
|
};
|
|
expect(mcpResultToToolMessage(multi, "c").content).toBe("part1-part2");
|
|
});
|
|
|
|
it("empty content → empty string (or ERROR: empty on error)", () => {
|
|
const empty: McpResult = { content: [], isError: false };
|
|
expect(mcpResultToToolMessage(empty, "c").content).toBe("");
|
|
const emptyErr: McpResult = { content: [], isError: true };
|
|
expect(mcpResultToToolMessage(emptyErr, "c").content).toBe("ERROR: ");
|
|
});
|
|
});
|
|
|
|
describe("toolsToOpenAi — registry → OpenAI tools param", () => {
|
|
it("maps the closed 9-tool set to OpenAiToolDef", () => {
|
|
const defs = toolsToOpenAi(listTools());
|
|
expect(defs).toHaveLength(9);
|
|
expect(defs[0]).toEqual({
|
|
type: "function",
|
|
function: {
|
|
name: expect.any(String),
|
|
description: expect.any(String),
|
|
parameters: expect.any(Object),
|
|
},
|
|
});
|
|
});
|
|
|
|
it("preserves the inputSchema as parameters", () => {
|
|
const defs = toolsToOpenAi(listTools());
|
|
const listVms = defs.find((d) => d.function.name === "proxmox.list_vms");
|
|
expect(listVms?.function.parameters).toMatchObject({ type: "object", required: ["node"] });
|
|
});
|
|
}); |