mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-07 07:42:13 +03:00
feat(mcp): add omniroute_web_search tool with execute:search scope
- Add execute:search scope to MCP_SCOPE_LIST, MCP_TOOL_SCOPES, agent preset - Add webSearchInput/webSearchOutput Zod schemas (query, max_results, search_type, provider) - Add handleWebSearch handler wrapping POST /v1/search - Add unit tests (3 test cases, 12 tests pass) Essential tool (Phase 1) for MCP clients to perform web search via Search Gateway.
This commit is contained in:
@@ -17,9 +17,9 @@ describe("MCP Essential Tools", () => {
|
||||
});
|
||||
|
||||
describe("Tool schema validation", () => {
|
||||
it("should have exactly 8 essential tools", () => {
|
||||
it("should have exactly 9 essential tools", () => {
|
||||
const schemas = MCP_ESSENTIAL_TOOLS;
|
||||
expect(schemas).toHaveLength(8);
|
||||
expect(schemas).toHaveLength(9);
|
||||
});
|
||||
|
||||
it("all tools should have omniroute_ prefix", () => {
|
||||
@@ -136,4 +136,77 @@ describe("MCP Essential Tools", () => {
|
||||
expect(data).toHaveProperty("requestCount");
|
||||
});
|
||||
});
|
||||
|
||||
describe("web_search handler", () => {
|
||||
it("should return search results when API is available", async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
id: "search-123",
|
||||
provider: "serper",
|
||||
query: "typescript best practices",
|
||||
results: [
|
||||
{
|
||||
title: "TypeScript Best Practices 2024",
|
||||
url: "https://example.com/ts-best",
|
||||
display_url: "https://example.com/ts-best",
|
||||
snippet: "Best practices for TypeScript development...",
|
||||
position: 1,
|
||||
},
|
||||
{
|
||||
title: "Advanced TypeScript Patterns",
|
||||
url: "https://example.com/ts-advanced",
|
||||
snippet: "Advanced patterns and techniques...",
|
||||
position: 2,
|
||||
},
|
||||
],
|
||||
cached: false,
|
||||
usage: { queries_used: 1, search_cost_usd: 0.002 },
|
||||
}),
|
||||
});
|
||||
|
||||
const response = await mockFetch(
|
||||
"http://localhost:20128/v1/search?query=typescript%20best%20practices&max_results=5"
|
||||
);
|
||||
const data = await response.json();
|
||||
expect(data.results).toHaveLength(2);
|
||||
expect(data.results[0].title).toBe("TypeScript Best Practices 2024");
|
||||
expect(data.provider).toBe("serper");
|
||||
});
|
||||
|
||||
it("should handle API failure gracefully", async () => {
|
||||
mockFetch.mockRejectedValueOnce(new Error("Search service unavailable"));
|
||||
|
||||
await expect(mockFetch("http://localhost:20128/v1/search?query=test")).rejects.toThrow(
|
||||
"Search service unavailable"
|
||||
);
|
||||
});
|
||||
|
||||
it("should pass correct parameters to /v1/search", async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
id: "search-456",
|
||||
provider: "brave",
|
||||
query: "react hooks tutorial",
|
||||
results: [],
|
||||
cached: false,
|
||||
usage: { queries_used: 1, search_cost_usd: 0.003 },
|
||||
}),
|
||||
});
|
||||
|
||||
const query = "react hooks tutorial";
|
||||
const response = await mockFetch(
|
||||
`http://localhost:20128/v1/search?query=${encodeURIComponent(query)}&max_results=10&search_type=news&provider=brave`
|
||||
);
|
||||
const data = await response.json();
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(expect.stringContaining("/v1/search"));
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
expect.stringContaining("query=react%20hooks%20tutorial")
|
||||
);
|
||||
expect(mockFetch).toHaveBeenCalledWith(expect.stringContaining("max_results=10"));
|
||||
expect(data.provider).toBe("brave");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -394,6 +394,59 @@ export const listModelsCatalogTool: McpToolDefinition<
|
||||
sourceEndpoints: ["/api/models/catalog", "/v1/models"],
|
||||
};
|
||||
|
||||
// --- Tool 9: omniroute_web_search ---
|
||||
export const webSearchInput = z.object({
|
||||
query: z
|
||||
.string()
|
||||
.min(1, "Query is required")
|
||||
.max(1000, "Query must be 1000 characters or fewer")
|
||||
.describe("The search query string"),
|
||||
max_results: z
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.max(20)
|
||||
.default(5)
|
||||
.describe("Maximum number of search results to return"),
|
||||
search_type: z.enum(["web", "news"]).default("web").describe("Type of search to perform"),
|
||||
provider: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe("Specific search provider to use (serper, brave, perplexity, exa, tavily)"),
|
||||
});
|
||||
|
||||
export const webSearchOutput = z.object({
|
||||
id: z.string(),
|
||||
provider: z.string(),
|
||||
query: z.string(),
|
||||
results: z.array(
|
||||
z.object({
|
||||
title: z.string(),
|
||||
url: z.string(),
|
||||
display_url: z.string().optional(),
|
||||
snippet: z.string(),
|
||||
position: z.number().int().positive(),
|
||||
})
|
||||
),
|
||||
cached: z.boolean(),
|
||||
usage: z.object({
|
||||
queries_used: z.number().int().min(0),
|
||||
search_cost_usd: z.number().min(0),
|
||||
}),
|
||||
});
|
||||
|
||||
export const webSearchTool: McpToolDefinition<typeof webSearchInput, typeof webSearchOutput> = {
|
||||
name: "omniroute_web_search",
|
||||
description:
|
||||
"Performs a web search using OmniRoute's search gateway. Supports multiple providers (Serper, Brave, Perplexity, Exa, Tavily) with automatic failover. Returns search results with titles, URLs, snippets, and position data.",
|
||||
inputSchema: webSearchInput,
|
||||
outputSchema: webSearchOutput,
|
||||
scopes: ["execute:search"],
|
||||
auditLevel: "basic",
|
||||
phase: 1,
|
||||
sourceEndpoints: ["/v1/search"],
|
||||
};
|
||||
|
||||
// ============ Phase 2: Advanced Tools (8) ============
|
||||
|
||||
// --- Tool 9: omniroute_simulate_route ---
|
||||
@@ -881,6 +934,7 @@ export const MCP_TOOLS = [
|
||||
routeRequestTool,
|
||||
costReportTool,
|
||||
listModelsCatalogTool,
|
||||
webSearchTool,
|
||||
simulateRouteTool,
|
||||
setBudgetGuardTool,
|
||||
setRoutingStrategyTool,
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
routeRequestInput,
|
||||
costReportInput,
|
||||
listModelsCatalogInput,
|
||||
webSearchInput,
|
||||
simulateRouteInput,
|
||||
setBudgetGuardInput,
|
||||
setRoutingStrategyInput,
|
||||
@@ -492,6 +493,37 @@ async function handleListModelsCatalog(args: { provider?: string; capability?: s
|
||||
}
|
||||
}
|
||||
|
||||
async function handleWebSearch(args: {
|
||||
query: string;
|
||||
max_results?: number;
|
||||
search_type?: "web" | "news";
|
||||
provider?: string;
|
||||
}) {
|
||||
const start = Date.now();
|
||||
try {
|
||||
const body: Record<string, unknown> = {
|
||||
query: args.query,
|
||||
max_results: args.max_results ?? 5,
|
||||
search_type: args.search_type ?? "web",
|
||||
};
|
||||
if (args.provider) {
|
||||
body["provider"] = args.provider;
|
||||
}
|
||||
|
||||
const data = await omniRouteFetch("/v1/search", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
await logToolCall("omniroute_web_search", args, data, Date.now() - start, true);
|
||||
return { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] };
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
await logToolCall("omniroute_web_search", args, null, Date.now() - start, false, msg);
|
||||
return { content: [{ type: "text" as const, text: `Error: ${msg}` }], isError: true };
|
||||
}
|
||||
}
|
||||
|
||||
// ============ MCP Server Setup ============
|
||||
|
||||
/**
|
||||
@@ -595,6 +627,18 @@ export function createMcpServer(): McpServer {
|
||||
)
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
"omniroute_web_search",
|
||||
{
|
||||
description:
|
||||
"Performs a web search using OmniRoute's search gateway. Supports multiple providers (Serper, Brave, Perplexity, Exa, Tavily) with automatic failover. Returns search results with titles, URLs, snippets, and position data.",
|
||||
inputSchema: webSearchInput,
|
||||
},
|
||||
withScopeEnforcement("omniroute_web_search", (args) =>
|
||||
handleWebSearch(webSearchInput.parse(args))
|
||||
)
|
||||
);
|
||||
|
||||
// ── Advanced Tools (Phase 3) ──────────────────────────────
|
||||
|
||||
server.registerTool(
|
||||
|
||||
@@ -16,6 +16,7 @@ export const MCP_SCOPE_LIST = [
|
||||
"read:usage",
|
||||
"read:models",
|
||||
"execute:completions",
|
||||
"execute:search",
|
||||
"write:budget",
|
||||
"write:resilience",
|
||||
] as const;
|
||||
@@ -33,6 +34,7 @@ export const MCP_TOOL_SCOPES: Record<string, readonly McpScope[]> = {
|
||||
omniroute_switch_combo: ["write:combos"],
|
||||
omniroute_check_quota: ["read:quota"],
|
||||
omniroute_route_request: ["execute:completions"],
|
||||
omniroute_web_search: ["execute:search"],
|
||||
omniroute_cost_report: ["read:usage"],
|
||||
omniroute_list_models_catalog: ["read:models"],
|
||||
|
||||
@@ -74,6 +76,7 @@ export const MCP_SCOPE_PRESETS = {
|
||||
"read:usage",
|
||||
"read:models",
|
||||
"execute:completions",
|
||||
"execute:search",
|
||||
] as const satisfies readonly McpScope[],
|
||||
} as const;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user