diff --git a/src/lib/server/giellie-mcp-bridge.test.ts b/src/lib/server/giellie-mcp-bridge.test.ts new file mode 100644 index 0000000..7c664d8 --- /dev/null +++ b/src/lib/server/giellie-mcp-bridge.test.ts @@ -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/, + ); + }); +}); diff --git a/src/lib/server/giellie-mcp-bridge.ts b/src/lib/server/giellie-mcp-bridge.ts new file mode 100644 index 0000000..85e8260 --- /dev/null +++ b/src/lib/server/giellie-mcp-bridge.ts @@ -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, + 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>; +} + +export async function listMcpTools(origin: string): Promise { + 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, + 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 }; +}