mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-11 09:42:15 +03:00
feat(mcp): expose Radar catalog tool
This commit is contained in:
@@ -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", () => {
|
||||
|
||||
151
open-sse/mcp-server/__tests__/radarCatalogTool.test.ts
Normal file
151
open-sse/mcp-server/__tests__/radarCatalogTool.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
117
open-sse/mcp-server/radarCatalog.ts
Normal file
117
open-sse/mcp-server/radarCatalog.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
|
||||
export interface McpRadarCatalogArgs {
|
||||
provider?: string;
|
||||
familyId?: string;
|
||||
enabledOnly?: boolean;
|
||||
}
|
||||
|
||||
interface McpRadarCatalogDeps {
|
||||
fetchJson?: (path: string) => Promise<unknown>;
|
||||
}
|
||||
|
||||
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<ReturnType<typeof normalizeEntry>>,
|
||||
right: NonNullable<ReturnType<typeof normalizeEntry>>
|
||||
): 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<typeof entry> => 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 };
|
||||
}
|
||||
@@ -35,6 +35,9 @@ export {
|
||||
listModelsCatalogInput,
|
||||
listModelsCatalogOutput,
|
||||
listModelsCatalogTool,
|
||||
radarCatalogInput,
|
||||
radarCatalogOutput,
|
||||
radarCatalogTool,
|
||||
// Phase 2: Advanced tool schemas
|
||||
simulateRouteInput,
|
||||
simulateRouteOutput,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
{
|
||||
|
||||
@@ -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<string, readonly McpScope[]> = {
|
||||
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"],
|
||||
|
||||
Reference in New Issue
Block a user