diff --git a/open-sse/mcp-server/__tests__/essentialTools.test.ts b/open-sse/mcp-server/__tests__/essentialTools.test.ts index 88353eed92..c611d5f3c3 100644 --- a/open-sse/mcp-server/__tests__/essentialTools.test.ts +++ b/open-sse/mcp-server/__tests__/essentialTools.test.ts @@ -1,7 +1,7 @@ /** * Unit tests for MCP Essential Tools (Phase 1) * - * Tests all 10 essential tool handlers via the tool handler functions. + * Tests the essential tool handlers via the tool handler functions. * The omniroute_web_search tests use InMemoryTransport + Client to exercise * the actual registered handler (not mockFetch directly). */ @@ -22,10 +22,10 @@ describe("MCP Essential Tools", () => { }); describe("Tool schema validation", () => { - it("should have exactly 12 essential tools (includes web_search + web_fetch + tool_search)", () => { - // 11 -> 12: #8925 shipped omniroute_create_combo as a phase-1 tool. + it("should have exactly 13 essential tools (including Radar catalog)", () => { + // 12 -> 13: F3 shipped omniroute_radar_catalog as a phase-1 read-only tool. const schemas = MCP_ESSENTIAL_TOOLS; - expect(schemas).toHaveLength(12); + expect(schemas).toHaveLength(13); }); it("all tools should have omniroute_ prefix", () => { diff --git a/open-sse/mcp-server/__tests__/radarCatalogTool.test.ts b/open-sse/mcp-server/__tests__/radarCatalogTool.test.ts new file mode 100644 index 0000000000..d30a59be7e --- /dev/null +++ b/open-sse/mcp-server/__tests__/radarCatalogTool.test.ts @@ -0,0 +1,151 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; + +import { MCP_SCOPE_LIST, MCP_TOOL_SCOPES } from "../../../src/shared/constants/mcpScopes.ts"; +import { evaluateToolScopes } from "../scopeEnforcement.ts"; +import { getMcpRadarCatalog } from "../radarCatalog.ts"; +import { MCP_ESSENTIAL_TOOLS, MCP_TOOL_MAP } from "../schemas/tools.ts"; +import { createMcpServer } from "../server.ts"; + +vi.mock("../audit.ts", () => ({ + logToolCall: vi.fn().mockResolvedValue(undefined), +})); + +const catalog = { + entries: [ + { + provider: "groq", + modelId: "llama", + displayName: "Llama on Groq", + familyId: "llama-family", + monthlyTokens: 200, + creditTokens: 0, + freeType: "recurring-daily", + poolKey: null, + tos: "ok", + enabled: true, + origin: "radar", + capabilities: { tools: true, vision: false, thinking: false }, + limits: { rpm: 30, rpd: null, tpm: null, tpd: null }, + setup: { keyUrl: "https://secret.example/key", steps: ["do not expose"] }, + }, + { + provider: "cerebras", + modelId: "llama", + displayName: "Llama on Cerebras", + familyId: "llama-family", + monthlyTokens: 300, + creditTokens: 0, + freeType: "recurring-daily", + poolKey: null, + tos: "ok", + enabled: false, + disabledBy: "radar", + origin: "radar", + capabilities: { tools: true, vision: false, thinking: true }, + limits: { rpm: null, rpd: 100, tpm: null, tpd: null }, + }, + ], + meta: { version: "2026.08.08.1", tier: "community", fetchedAt: "2026-08-08T20:00:00Z" }, +}; + +describe("omniroute_radar_catalog", () => { + it("is a phase-1 read-only registry tool with the dedicated Radar scope", () => { + const definition = MCP_TOOL_MAP.omniroute_radar_catalog; + expect(definition).toBeDefined(); + expect(definition.phase).toBe(1); + expect(definition.scopes).toEqual(["read:radar"]); + expect(definition.auditLevel).toBe("none"); + expect(definition.sourceEndpoints).toEqual(["/api/radar/catalog"]); + expect(MCP_ESSENTIAL_TOOLS).toContain(definition); + expect(MCP_SCOPE_LIST).toContain("read:radar"); + expect(MCP_TOOL_SCOPES.omniroute_radar_catalog).toEqual(["read:radar"]); + }); + + it("reads only the local catalog and returns a closed filtered projection", async () => { + const fetchJson = vi.fn().mockResolvedValue(catalog); + const result = await getMcpRadarCatalog( + { provider: "groq", familyId: "llama-family", enabledOnly: true }, + { fetchJson } + ); + + expect(fetchJson).toHaveBeenCalledOnce(); + expect(fetchJson).toHaveBeenCalledWith("/api/radar/catalog"); + expect(result.models).toHaveLength(1); + expect(result.models[0]).toEqual({ + provider: "groq", + modelId: "llama", + displayName: "Llama on Groq", + familyId: "llama-family", + quota: { + monthlyTokens: 200, + creditTokens: 0, + freeType: "recurring-daily", + limits: { rpm: 30, rpd: null, tpm: null, tpd: null }, + }, + capabilities: { tools: true, vision: false, thinking: false }, + enabled: true, + origin: "radar", + disabledBy: null, + }); + expect(JSON.stringify(result)).not.toContain("secret.example"); + expect(JSON.stringify(result)).not.toContain("setup"); + }); + + it("defaults enabledOnly to true and includes disabled models only when explicitly requested", async () => { + const fetchJson = vi.fn().mockResolvedValue(catalog); + expect((await getMcpRadarCatalog({}, { fetchJson })).models).toHaveLength(1); + expect((await getMcpRadarCatalog({ enabledOnly: false }, { fetchJson })).models).toHaveLength( + 2 + ); + }); + + it("allows read:radar and read:* but denies a missing scope when enforcement is active", () => { + expect(evaluateToolScopes("omniroute_radar_catalog", ["read:radar"], true).allowed).toBe(true); + expect(evaluateToolScopes("omniroute_radar_catalog", ["read:*"], true).allowed).toBe(true); + expect(evaluateToolScopes("omniroute_radar_catalog", [], true)).toMatchObject({ + allowed: false, + reason: "missing_scopes", + missing: ["read:radar"], + }); + }); +}); + +describe("omniroute_radar_catalog MCP dispatch", () => { + const mockFetch = vi.fn(); + let client: Client; + + beforeEach(async () => { + mockFetch.mockReset(); + vi.stubGlobal("fetch", mockFetch); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + const server = createMcpServer(); + await server.connect(serverTransport); + client = new Client({ name: "radar-catalog-test", version: "1.0.0" }); + await client.connect(clientTransport); + }); + + afterEach(async () => { + await client.close(); + vi.unstubAllGlobals(); + }); + + it("registers and dispatches a real read without sync or write", async () => { + mockFetch.mockResolvedValueOnce({ ok: true, json: async () => catalog }); + + const listed = await client.listTools(); + expect(listed.tools.some((tool) => tool.name === "omniroute_radar_catalog")).toBe(true); + + const result = await client.callTool({ + name: "omniroute_radar_catalog", + arguments: { enabledOnly: false }, + }); + expect(result.isError).toBeFalsy(); + expect(mockFetch).toHaveBeenCalledOnce(); + expect(mockFetch.mock.calls[0][0]).toContain("/api/radar/catalog"); + expect(mockFetch.mock.calls[0][1]).not.toMatchObject({ method: "POST" }); + const body = JSON.parse((result.content[0] as { text: string }).text); + expect(body.models).toHaveLength(2); + }); +}); diff --git a/open-sse/mcp-server/radarCatalog.ts b/open-sse/mcp-server/radarCatalog.ts new file mode 100644 index 0000000000..0feea50899 --- /dev/null +++ b/open-sse/mcp-server/radarCatalog.ts @@ -0,0 +1,117 @@ +type JsonRecord = Record; + +export interface McpRadarCatalogArgs { + provider?: string; + familyId?: string; + enabledOnly?: boolean; +} + +interface McpRadarCatalogDeps { + fetchJson?: (path: string) => Promise; +} + +function record(value: unknown): JsonRecord { + return value !== null && typeof value === "object" && !Array.isArray(value) + ? (value as JsonRecord) + : {}; +} + +function text(value: unknown, fallback = ""): string { + return typeof value === "string" ? value : fallback; +} + +function number(value: unknown): number { + return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : 0; +} + +function nullableNumber(value: unknown): number | null { + return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : null; +} + +function normalizeMeta( + value: unknown +): { version: string; tier: string; fetchedAt: string } | null { + const meta = record(value); + if ( + typeof meta.version !== "string" || + typeof meta.tier !== "string" || + typeof meta.fetchedAt !== "string" + ) { + return null; + } + return { version: meta.version, tier: meta.tier, fetchedAt: meta.fetchedAt }; +} + +function normalizeEntry(value: unknown) { + const entry = record(value); + const provider = text(entry.provider).trim(); + const modelId = text(entry.modelId).trim(); + if (!provider || !modelId) return null; + + const capabilities = record(entry.capabilities); + const limits = record(entry.limits); + const origin = + entry.origin === "radar" || entry.origin === "local" ? entry.origin : ("baseline" as const); + return { + provider, + modelId, + displayName: text(entry.displayName, modelId), + familyId: typeof entry.familyId === "string" ? entry.familyId : null, + quota: { + monthlyTokens: number(entry.monthlyTokens), + creditTokens: number(entry.creditTokens), + freeType: text(entry.freeType, "unknown"), + limits: + Object.keys(limits).length > 0 + ? { + rpm: nullableNumber(limits.rpm), + rpd: nullableNumber(limits.rpd), + tpm: nullableNumber(limits.tpm), + tpd: nullableNumber(limits.tpd), + } + : null, + }, + capabilities: + Object.keys(capabilities).length > 0 + ? { + tools: capabilities.tools === true, + vision: capabilities.vision === true, + thinking: capabilities.thinking === true, + } + : null, + enabled: entry.enabled !== false, + origin, + disabledBy: entry.disabledBy === "radar" ? ("radar" as const) : null, + }; +} + +function compareEntries( + left: NonNullable>, + right: NonNullable> +): number { + return left.provider.localeCompare(right.provider) || left.modelId.localeCompare(right.modelId); +} + +/** Read and project the local Radar catalog without exposing setup or secret-bearing state. */ +export async function getMcpRadarCatalog( + args: McpRadarCatalogArgs, + deps: McpRadarCatalogDeps = {} +) { + const fetchJson = + deps.fetchJson ?? + ((path: string) => import("./server.ts").then((module) => module.omniRouteFetch(path))); + const raw = record(await fetchJson("/api/radar/catalog")); + const providerFilter = args.provider?.trim().toLowerCase(); + const familyFilter = args.familyId?.trim().toLowerCase(); + const enabledOnly = args.enabledOnly !== false; + const entries = Array.isArray(raw.entries) ? raw.entries : []; + const models = entries + .map(normalizeEntry) + .filter((entry): entry is NonNullable => entry !== null) + .filter((entry) => !enabledOnly || entry.enabled) + .filter((entry) => !providerFilter || entry.provider.toLowerCase() === providerFilter) + .filter((entry) => !familyFilter || entry.familyId?.toLowerCase() === familyFilter) + .sort(compareEntries); + + return { meta: normalizeMeta(raw.meta), models }; +} diff --git a/open-sse/mcp-server/schemas/index.ts b/open-sse/mcp-server/schemas/index.ts index fe9df69ff2..1bc0f83b07 100644 --- a/open-sse/mcp-server/schemas/index.ts +++ b/open-sse/mcp-server/schemas/index.ts @@ -35,6 +35,9 @@ export { listModelsCatalogInput, listModelsCatalogOutput, listModelsCatalogTool, + radarCatalogInput, + radarCatalogOutput, + radarCatalogTool, // Phase 2: Advanced tool schemas simulateRouteInput, simulateRouteOutput, diff --git a/open-sse/mcp-server/schemas/tools.ts b/open-sse/mcp-server/schemas/tools.ts index 4db6a850dc..dd39de481e 100644 --- a/open-sse/mcp-server/schemas/tools.ts +++ b/open-sse/mcp-server/schemas/tools.ts @@ -1,5 +1,5 @@ /** - * MCP Tool Schemas — Contracts for all 23 core and advanced OmniRoute MCP tools. + * MCP Tool Schemas — Contracts for the canonical OmniRoute MCP tools. * * Defines input/output Zod schemas, descriptions, scopes, and audit levels * for both essential (Phase 1) and advanced (Phase 2) MCP tools. @@ -27,7 +27,7 @@ import type { McpToolDefinition } from "./toolDefinition.ts"; export { pickFastestModelInput, pickFastestModelOutput } from "./pickFastestModel.ts"; export * from "./ccrTools.ts"; -// ============ Phase 1: Essential Tools (8) ============ +// ============ Phase 1: Essential Tools ============ // --- Tool 1: omniroute_get_health --- export const getHealthInput = z.object({}).describe("No parameters required"); @@ -432,7 +432,70 @@ export const listModelsCatalogTool: McpToolDefinition< sourceEndpoints: ["/api/models/catalog", "/v1/models"], }; -// --- Tool 9: omniroute_web_search --- +// --- Tool 9: omniroute_radar_catalog --- +export const radarCatalogInput = z.object({ + provider: z.string().trim().min(1).max(100).optional().describe("Filter by provider id"), + familyId: z.string().trim().min(1).max(120).optional().describe("Filter by curated family id"), + enabledOnly: z.boolean().default(true).describe("Exclude models disabled by the Radar feed"), +}); + +const radarLimitOutput = z.object({ + rpm: z.number().nullable(), + rpd: z.number().nullable(), + tpm: z.number().nullable(), + tpd: z.number().nullable(), +}); + +export const radarCatalogOutput = z.object({ + meta: z + .object({ + version: z.string(), + tier: z.string(), + fetchedAt: z.string(), + }) + .nullable(), + models: z.array( + z.object({ + provider: z.string(), + modelId: z.string(), + displayName: z.string(), + familyId: z.string().nullable(), + quota: z.object({ + monthlyTokens: z.number(), + creditTokens: z.number(), + freeType: z.string(), + limits: radarLimitOutput.nullable(), + }), + capabilities: z + .object({ + tools: z.boolean(), + vision: z.boolean(), + thinking: z.boolean(), + }) + .nullable(), + enabled: z.boolean(), + origin: z.enum(["baseline", "radar", "local"]), + disabledBy: z.literal("radar").nullable(), + }) + ), +}); + +export const radarCatalogTool: McpToolDefinition< + typeof radarCatalogInput, + typeof radarCatalogOutput +> = { + name: "omniroute_radar_catalog", + description: + "Reads the local signed Radar catalog with optional provider and curated-family filters. Never syncs or writes data.", + inputSchema: radarCatalogInput, + outputSchema: radarCatalogOutput, + scopes: ["read:radar"], + auditLevel: "none", + phase: 1, + sourceEndpoints: ["/api/radar/catalog"], +}; + +// --- Tool 10: omniroute_web_search --- export const webSearchInput = z.object({ query: z .string() @@ -1512,6 +1575,7 @@ export const MCP_TOOLS = [ routeRequestTool, costReportTool, listModelsCatalogTool, + radarCatalogTool, webSearchTool, webFetchTool, simulateRouteTool, diff --git a/open-sse/mcp-server/server.ts b/open-sse/mcp-server/server.ts index d61b654f3c..00e8a2f8fd 100644 --- a/open-sse/mcp-server/server.ts +++ b/open-sse/mcp-server/server.ts @@ -17,6 +17,8 @@ import { routeRequestInput, costReportInput, listModelsCatalogInput, + radarCatalogInput, + radarCatalogOutput, webSearchInput, webFetchInput, simulateRouteInput, @@ -93,7 +95,9 @@ import { normalizeQuotaResponse } from "../../src/shared/contracts/quota.ts"; import { resolveOmniRouteBaseUrl } from "../../src/shared/utils/resolveOmniRouteBaseUrl.ts"; import { sanitizeErrorMessage } from "../utils/error.ts"; import { getMcpModelsCatalog } from "./catalog.ts"; +import { getMcpRadarCatalog } from "./radarCatalog.ts"; export { getMcpModelsCatalog } from "./catalog.ts"; +export { getMcpRadarCatalog } from "./radarCatalog.ts"; const OMNIROUTE_BASE_URL = resolveOmniRouteBaseUrl(); const MCP_ENFORCE_SCOPES = process.env.OMNIROUTE_MCP_ENFORCE_SCOPES === "true"; @@ -570,6 +574,29 @@ async function handleListModelsCatalog(args: { provider?: string; capability?: s } } +async function handleRadarCatalog(args: { + provider?: string; + familyId?: string; + enabledOnly: boolean; +}) { + const start = Date.now(); + try { + const result = radarCatalogOutput.parse(await getMcpRadarCatalog(args)); + await logToolCall( + "omniroute_radar_catalog", + args, + { modelCount: result.models.length }, + Date.now() - start, + true + ); + return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] }; + } catch (error) { + const message = sanitizeErrorMessage(error) || "Failed to read Radar catalog"; + await logToolCall("omniroute_radar_catalog", args, null, Date.now() - start, false, message); + return { content: [{ type: "text" as const, text: `Error: ${message}` }], isError: true }; + } +} + async function handleWebSearch(args: { query: string; max_results?: number; @@ -815,6 +842,17 @@ export function createMcpServer(): McpServer { ) ); + server.registerTool( + "omniroute_radar_catalog", + { + description: "Reads the local signed Radar catalog with optional provider and family filters", + inputSchema: radarCatalogInput, + }, + withScopeEnforcement("omniroute_radar_catalog", (args) => + handleRadarCatalog(radarCatalogInput.parse(args)) + ) + ); + server.registerTool( "omniroute_simulate_route", { diff --git a/src/shared/constants/mcpScopes.ts b/src/shared/constants/mcpScopes.ts index d5babbb63c..c03e4e5797 100644 --- a/src/shared/constants/mcpScopes.ts +++ b/src/shared/constants/mcpScopes.ts @@ -15,6 +15,7 @@ export const MCP_SCOPE_LIST = [ "read:quota", "read:usage", "read:models", + "read:radar", "execute:completions", "execute:search", "write:budget", @@ -44,6 +45,7 @@ export const MCP_TOOL_SCOPES: Record = { omniroute_web_fetch: ["execute:search"], omniroute_cost_report: ["read:usage"], omniroute_list_models_catalog: ["read:models"], + omniroute_radar_catalog: ["read:radar"], // Phase 2: Advanced Tools omniroute_simulate_route: ["read:health", "read:combos"],