feat(giellie): in-process MCP bridge

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Dave 2026-09-30 23:39:00 +02:00
parent 5b0a572e5c
commit 133fa626fa
2 changed files with 148 additions and 0 deletions

View file

@ -0,0 +1,77 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextResponse } from "next/server";
const mcpPost = vi.fn();
vi.mock("@/app/api/mcp/route", () => ({
POST: (...args: unknown[]) => mcpPost(...args),
}));
import { listMcpTools, callMcpTool, clearMcpToolCache } from "./giellie-mcp-bridge";
const ORIGIN = "https://wfdash.example.co.za";
beforeEach(() => {
mcpPost.mockReset();
clearMcpToolCache();
process.env.AGENT_API_KEY = "test-key";
});
describe("listMcpTools", () => {
it("calls tools/list with the agent key and caches the result", async () => {
mcpPost.mockResolvedValue(
NextResponse.json({ jsonrpc: "2.0", id: 1, result: { tools: [{ name: "orders.list" }] } }),
);
const a = await listMcpTools(ORIGIN);
const b = await listMcpTools(ORIGIN);
expect(a).toEqual([{ name: "orders.list" }]);
expect(b).toEqual(a);
expect(mcpPost).toHaveBeenCalledTimes(1);
const req = mcpPost.mock.calls[0][0] as Request;
expect(req.url).toBe(`${ORIGIN}/api/mcp`);
expect(req.headers.get("authorization")).toBe("Bearer test-key");
expect(await req.json()).toMatchObject({ method: "tools/list" });
});
});
describe("callMcpTool", () => {
it("sends name + args + actor and unwraps the text envelope", async () => {
mcpPost.mockResolvedValue(
NextResponse.json({
jsonrpc: "2.0",
id: 2,
result: { content: [{ type: "text", text: '{"total":3}' }] },
}),
);
const out = await callMcpTool(ORIGIN, "orders.stats", { from: "2026-09-01" }, { id: "u1", name: "Dave" });
expect(out).toEqual({ text: '{"total":3}', isError: false });
const body = await (mcpPost.mock.calls[0][0] as Request).json();
expect(body.method).toBe("tools/call");
expect(body.params.name).toBe("orders.stats");
expect(body.params.arguments).toEqual({ from: "2026-09-01", actor: { id: "u1", name: "Dave" } });
});
it("does not let the model override the actor", async () => {
mcpPost.mockResolvedValue(
NextResponse.json({ jsonrpc: "2.0", id: 2, result: { content: [{ type: "text", text: "{}" }] } }),
);
await callMcpTool(ORIGIN, "orders.update", { actor: { id: "evil", name: "Mallory" } }, { id: "u1", name: "Dave" });
const body = await (mcpPost.mock.calls[0][0] as Request).json();
expect(body.params.arguments.actor).toEqual({ id: "u1", name: "Dave" });
});
it("maps JSON-RPC errors to isError results", async () => {
mcpPost.mockResolvedValue(
NextResponse.json({ jsonrpc: "2.0", id: null, error: { code: -32000, message: "Order not found" } }, { status: 500 }),
);
const out = await callMcpTool(ORIGIN, "orders.get", { id: "x" }, { id: "u1", name: "Dave" });
expect(out.isError).toBe(true);
expect(out.text).toContain("Order not found");
});
it("throws a clear error when AGENT_API_KEY is missing", async () => {
delete process.env.AGENT_API_KEY;
await expect(callMcpTool(ORIGIN, "orders.get", {}, { id: "u1", name: "Dave" })).rejects.toThrow(
/AGENT_API_KEY/,
);
});
});

View file

@ -0,0 +1,71 @@
/**
* In-process bridge from Giellie to the WFDASH MCP route.
*
* Builds a synthetic JSON-RPC request and hands it to the route's own POST
* handler, authenticated with AGENT_API_KEY (server-side only). This keeps
* one implementation of every tool: anything added to /api/mcp is
* automatically available to Giellie, and the audit trail attributes writes
* to the signed-in user via the `actor` argument the route already reads.
*
* `origin` must be the origin of the incoming chat request — some MCP tools
* loop back to /api/v1 over HTTP using req.url (or AGENT_API_BASE_URL).
*/
import { NextRequest } from "next/server";
import { POST as mcpPost } from "@/app/api/mcp/route";
import type { McpTool } from "./giellie-tool-policy";
export interface GielieActor {
id: string;
name: string;
}
const TOOL_CACHE_TTL_MS = 5 * 60_000;
let toolCache: { tools: McpTool[]; expiresAt: number } | null = null;
export function clearMcpToolCache(): void {
toolCache = null;
}
async function rpc(
origin: string,
method: string,
params: Record<string, unknown>,
id: number,
): Promise<{
error?: { code?: number; message?: string };
result?: { tools?: McpTool[]; content?: Array<{ type: string; text?: string }> };
}> {
const key = process.env.AGENT_API_KEY;
if (!key) throw new Error("AGENT_API_KEY is not configured.");
const req = new NextRequest(new URL("/api/mcp", origin), {
method: "POST",
headers: { authorization: `Bearer ${key}`, "content-type": "application/json" },
body: JSON.stringify({ jsonrpc: "2.0", id, method, params }),
});
const res = await mcpPost(req);
return (await res.json()) as Awaited<ReturnType<typeof rpc>>;
}
export async function listMcpTools(origin: string): Promise<McpTool[]> {
if (toolCache && toolCache.expiresAt > Date.now()) return toolCache.tools;
const json = await rpc(origin, "tools/list", {}, 1);
if (json.error) throw new Error(`MCP tools/list failed: ${json.error.message}`);
const tools = json.result?.tools ?? [];
toolCache = { tools, expiresAt: Date.now() + TOOL_CACHE_TTL_MS };
return tools;
}
export async function callMcpTool(
origin: string,
name: string,
args: Record<string, unknown>,
actor: GielieActor,
): Promise<{ text: string; isError: boolean }> {
// `actor` goes last so the model cannot spoof another user.
const json = await rpc(origin, "tools/call", { name, arguments: { ...args, actor } }, 2);
if (json.error) {
return { text: `Error: ${json.error.message ?? "tool call failed"}`, isError: true };
}
const text = json.result?.content?.find((c) => c.type === "text")?.text ?? "";
return { text, isError: false };
}