diff --git a/open-sse/mcp-server/__tests__/essentialTools.test.ts b/open-sse/mcp-server/__tests__/essentialTools.test.ts index 96ca43454b..dd57a4f815 100644 --- a/open-sse/mcp-server/__tests__/essentialTools.test.ts +++ b/open-sse/mcp-server/__tests__/essentialTools.test.ts @@ -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"); + }); + }); }); diff --git a/open-sse/mcp-server/schemas/tools.ts b/open-sse/mcp-server/schemas/tools.ts index 46d03f325a..414390583f 100644 --- a/open-sse/mcp-server/schemas/tools.ts +++ b/open-sse/mcp-server/schemas/tools.ts @@ -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 = { + 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, diff --git a/open-sse/mcp-server/server.ts b/open-sse/mcp-server/server.ts index 9200290f3b..c5c80444c5 100644 --- a/open-sse/mcp-server/server.ts +++ b/open-sse/mcp-server/server.ts @@ -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 = { + 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( diff --git a/src/shared/constants/mcpScopes.ts b/src/shared/constants/mcpScopes.ts index afd4929c64..45378b048a 100644 --- a/src/shared/constants/mcpScopes.ts +++ b/src/shared/constants/mcpScopes.ts @@ -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 = { 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;