feat(giellie): in-process MCP bridge
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
5b0a572e5c
commit
133fa626fa
2 changed files with 148 additions and 0 deletions
77
src/lib/server/giellie-mcp-bridge.test.ts
Normal file
77
src/lib/server/giellie-mcp-bridge.test.ts
Normal 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/,
|
||||
);
|
||||
});
|
||||
});
|
||||
71
src/lib/server/giellie-mcp-bridge.ts
Normal file
71
src/lib/server/giellie-mcp-bridge.ts
Normal 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 };
|
||||
}
|
||||
Loading…
Reference in a new issue