From ebdbe3a38f9c1f91a5a9b9b49e591bcf4d1cf273 Mon Sep 17 00:00:00 2001 From: Lucas Mellos Carlos <102970912+lucasmellos@users.noreply.github.com> Date: Thu, 6 Aug 2026 19:11:07 -0300 Subject: [PATCH] feat(mcp): add omniroute_create_combo tool (#8925) Validated in local merge-train (tomni-proxmox-113) --- .../__tests__/createComboTool.test.ts | 193 ++++++++++++++++++ open-sse/mcp-server/schemas/tools.ts | 48 +++++ open-sse/mcp-server/server.ts | 33 +++ 3 files changed, 274 insertions(+) create mode 100644 open-sse/mcp-server/__tests__/createComboTool.test.ts diff --git a/open-sse/mcp-server/__tests__/createComboTool.test.ts b/open-sse/mcp-server/__tests__/createComboTool.test.ts new file mode 100644 index 0000000000..a0c4ce0ee0 --- /dev/null +++ b/open-sse/mcp-server/__tests__/createComboTool.test.ts @@ -0,0 +1,193 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; +import { MCP_TOOLS, MCP_TOOL_MAP, createComboInput, createComboTool } from "../schemas/tools.ts"; +import { createMcpServer } from "../server.ts"; + +const mockFetch = vi.fn(); +vi.stubGlobal("fetch", mockFetch); + +const mockLogToolCall = vi.hoisted(() => vi.fn().mockResolvedValue(undefined)); +vi.mock("../audit.ts", () => ({ + logToolCall: mockLogToolCall, +})); + +describe("omniroute_create_combo MCP tool schema", () => { + it("should be registered in MCP_TOOLS and MCP_TOOL_MAP", () => { + const tool = MCP_TOOLS.find((t) => t.name === "omniroute_create_combo"); + expect(tool).toBeDefined(); + expect(MCP_TOOL_MAP["omniroute_create_combo"]).toBeDefined(); + }); + + it("should require write:combos scope", () => { + expect(createComboTool.scopes).toContain("write:combos"); + }); + + it("should validate a minimal payload (name + models)", () => { + const result = createComboInput.safeParse({ + name: "My Combo", + models: [{ provider: "anthropic", model: "claude-sonnet" }], + }); + expect(result.success).toBe(true); + }); + + it("should validate a full payload with description and strategy", () => { + const result = createComboInput.safeParse({ + name: "My Combo", + description: "A test combo", + strategy: "priority", + models: [ + { provider: "anthropic", model: "claude-sonnet" }, + { provider: "google", model: "gemini-pro" }, + ], + }); + expect(result.success).toBe(true); + }); + + it("should reject a payload missing name", () => { + const result = createComboInput.safeParse({ + models: [{ provider: "anthropic", model: "claude-sonnet" }], + }); + expect(result.success).toBe(false); + }); + + it("should reject a payload with an empty models array", () => { + const result = createComboInput.safeParse({ name: "My Combo", models: [] }); + expect(result.success).toBe(false); + }); + + it("should reject an unknown strategy value", () => { + const result = createComboInput.safeParse({ + name: "My Combo", + strategy: "not-a-real-strategy", + models: [{ provider: "anthropic", model: "claude-sonnet" }], + }); + expect(result.success).toBe(false); + }); +}); + +describe("omniroute_create_combo handler (via MCP dispatch)", () => { + let client: Client; + + beforeEach(async () => { + mockFetch.mockReset(); + mockLogToolCall.mockClear(); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + const server = createMcpServer(); + await server.connect(serverTransport); + client = new Client({ name: "create-combo-test", version: "1.0.0" }); + await client.connect(clientTransport); + }); + + afterEach(async () => { + await client.close(); + }); + + it("should appear in tools/list after registration", async () => { + const { tools } = await client.listTools(); + const tool = tools.find((t) => t.name === "omniroute_create_combo"); + expect(tool).toBeDefined(); + expect(tool?.description).toContain("Registers new combo"); + }); + + it("should POST to /api/combos and return the created combo on success", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + success: true, + combo: { id: "combo-123", name: "My Combo", strategy: "priority", enabled: true }, + }), + }); + + const args = { + name: "My Combo", + models: [{ provider: "anthropic", model: "claude-sonnet" }], + }; + + const result = await client.callTool({ name: "omniroute_create_combo", arguments: args }); + + expect(result.isError).toBeFalsy(); + const content = result.content[0] as { type: string; text: string }; + const data = JSON.parse(content.text); + expect(data.success).toBe(true); + expect(data.combo.id).toBe("combo-123"); + expect(data.combo.name).toBe("My Combo"); + + expect(mockFetch).toHaveBeenCalledWith( + expect.stringContaining("/api/combos"), + expect.objectContaining({ method: "POST" }) + ); + const [, options] = mockFetch.mock.calls[0]; + const body = JSON.parse(options.body as string); + expect(body.name).toBe("My Combo"); + expect(body.models).toHaveLength(1); + + // Audit: the invocation must be logged to mcp_audit (via logToolCall). + expect(mockLogToolCall).toHaveBeenCalledWith( + "omniroute_create_combo", + expect.objectContaining({ name: "My Combo" }), + expect.objectContaining({ success: true }), + expect.any(Number), + true + ); + }); + + it("should pass through optional description and strategy fields", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + success: true, + combo: { id: "combo-456", name: "Cost Saver", strategy: "cost-optimized", enabled: true }, + }), + }); + + await client.callTool({ + name: "omniroute_create_combo", + arguments: { + name: "Cost Saver", + description: "Prefers cheaper models", + strategy: "cost-optimized", + models: [ + { provider: "anthropic", model: "claude-haiku" }, + { provider: "google", model: "gemini-flash" }, + ], + }, + }); + + const [, options] = mockFetch.mock.calls[0]; + const body = JSON.parse(options.body as string); + expect(body.description).toBe("Prefers cheaper models"); + expect(body.strategy).toBe("cost-optimized"); + expect(body.models).toHaveLength(2); + }); + + it("should return isError and log the failure when the backend rejects the combo (e.g. name collision)", async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 409, + text: async () => "Combo name already exists", + }); + + const result = await client.callTool({ + name: "omniroute_create_combo", + arguments: { + name: "Duplicate Combo", + models: [{ provider: "anthropic", model: "claude-sonnet" }], + }, + }); + + expect(result.isError).toBe(true); + const content = result.content[0] as { type: string; text: string }; + expect(content.text).toContain("Error"); + + expect(mockLogToolCall).toHaveBeenCalledWith( + "omniroute_create_combo", + expect.objectContaining({ name: "Duplicate Combo" }), + null, + expect.any(Number), + false, + expect.stringContaining("Combo name already exists") + ); + }); +}); diff --git a/open-sse/mcp-server/schemas/tools.ts b/open-sse/mcp-server/schemas/tools.ts index 012198baf8..4db6a850dc 100644 --- a/open-sse/mcp-server/schemas/tools.ts +++ b/open-sse/mcp-server/schemas/tools.ts @@ -192,6 +192,53 @@ export const switchComboTool: McpToolDefinition = + { + name: "omniroute_create_combo", + description: + "Registers a new combo (model chain) with a name, ordered model list, and optional routing strategy. Full validation (name collisions, nested-combo DAG, composite tiers) is enforced by the combos API.", + inputSchema: createComboInput, + outputSchema: createComboOutput, + scopes: ["write:combos"], + auditLevel: "full", + phase: 1, + sourceEndpoints: ["/api/combos"], + }; + // --- Tool 5: omniroute_check_quota --- export const checkQuotaInput = z.object({ provider: z @@ -1460,6 +1507,7 @@ export const MCP_TOOLS = [ listCombosTool, getComboMetricsTool, switchComboTool, + createComboTool, checkQuotaTool, routeRequestTool, costReportTool, diff --git a/open-sse/mcp-server/server.ts b/open-sse/mcp-server/server.ts index 9f8a536744..d37a157f71 100644 --- a/open-sse/mcp-server/server.ts +++ b/open-sse/mcp-server/server.ts @@ -12,6 +12,7 @@ import { listCombosInput, getComboMetricsInput, switchComboInput, + createComboInput, checkQuotaInput, routeRequestInput, costReportInput, @@ -393,6 +394,27 @@ async function handleSwitchCombo(args: { comboId: string; active: boolean }) { } } +async function handleCreateCombo(args: { + name: string; + description?: string; + strategy?: string; + models: { provider: string; model: string }[]; +}) { + const start = Date.now(); + try { + const result = await omniRouteFetch("/api/combos", { + method: "POST", + body: JSON.stringify(args), + }); + await logToolCall("omniroute_create_combo", args, result, Date.now() - start, true); + return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] }; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + await logToolCall("omniroute_create_combo", args, null, Date.now() - start, false, msg); + return { content: [{ type: "text" as const, text: `Error: ${msg}` }], isError: true }; + } +} + async function handleCheckQuota(args: { provider?: string; connectionId?: string }) { const start = Date.now(); try { @@ -738,6 +760,17 @@ export function createMcpServer(): McpServer { ) ); + server.registerTool( + "omniroute_create_combo", + { + description: "Registers a new combo (model chain) with name, models, and strategy", + inputSchema: createComboInput, + }, + withScopeEnforcement("omniroute_create_combo", (args) => + handleCreateCombo(createComboInput.parse(args)) + ) + ); + server.registerTool( "omniroute_check_quota", {