From a0cfae214dca7fa9bfc4ce765ebc60ce3e5d8147 Mon Sep 17 00:00:00 2001 From: Marcus Bearden Date: Fri, 3 Apr 2026 14:17:27 +0100 Subject: [PATCH] feat(mcp): register omniroute_web_search tool in MCP server (#951) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The tool was fully defined in schemas/tools.ts and backed by the working /v1/search endpoint, but server.registerTool() was never called, leaving it absent from tools/list. Changes: - server.ts: add webSearchInput import, handleWebSearch handler, and server.registerTool("omniroute_web_search") after sync_pricing block - schemas/tools.ts: align webSearchInput with /v1/search contract -- query max reduced 1000->500, provider narrowed to explicit enum - essentialTools.test.ts: rewrite web_search stubs to dispatch through a real McpServer+InMemoryTransport+Client, providing actual handler coverage (POST method, body fields, error paths, tools/list check) - vitest.mcp.config.ts: dedicated vitest config for MCP server tests; update test:vitest script to use it Note: omniRouteFetch hardcodes AbortSignal.timeout(10000) unconditionally (server.ts line 126), so caller signals are silently discarded -- the effective search timeout is 10s. Follow-up PR can add timeoutMs param. cacheStatsTool and cacheFlushTool are also unregistered; out of scope. 🤖 Generated with Claude Sonnet 4.6 via Claude Code (https://claude.com/claude-code) + Compound Engineering v2.58.1 Co-authored-by: Claude Sonnet 4.6 --- .../__tests__/essentialTools.test.ts | 219 ++++++++++++------ open-sse/mcp-server/schemas/tools.ts | 6 +- open-sse/mcp-server/server.ts | 46 ++++ package.json | 2 +- vitest.mcp.config.ts | 19 ++ 5 files changed, 221 insertions(+), 71 deletions(-) create mode 100644 vitest.mcp.config.ts diff --git a/open-sse/mcp-server/__tests__/essentialTools.test.ts b/open-sse/mcp-server/__tests__/essentialTools.test.ts index dd57a4f8..e10517bc 100644 --- a/open-sse/mcp-server/__tests__/essentialTools.test.ts +++ b/open-sse/mcp-server/__tests__/essentialTools.test.ts @@ -1,11 +1,16 @@ /** * Unit tests for MCP Essential Tools (Phase 1) * - * Tests all 8 essential tool handlers via the tool handler functions. + * Tests all 9 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). */ -import { describe, it, expect, vi, beforeEach } from "vitest"; +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_ESSENTIAL_TOOLS } from "../schemas/tools"; +import { createMcpServer } from "../server"; // Mock fetch globally const mockFetch = vi.fn(); @@ -17,7 +22,7 @@ describe("MCP Essential Tools", () => { }); describe("Tool schema validation", () => { - it("should have exactly 9 essential tools", () => { + it("should have exactly 9 essential tools (includes web_search)", () => { const schemas = MCP_ESSENTIAL_TOOLS; expect(schemas).toHaveLength(9); }); @@ -136,77 +141,157 @@ 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 }, - }), - }); +// ── omniroute_web_search: handler dispatch tests ────────────────────────────── +// These tests use InMemoryTransport + Client to exercise the actual registered +// handler (not mockFetch directly), ensuring real handler coverage. - 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"); +vi.mock("../audit.ts", () => ({ + logToolCall: vi.fn().mockResolvedValue(undefined), +})); + +describe("omniroute_web_search handler (via MCP dispatch)", () => { + let client: Client; + + beforeEach(async () => { + mockFetch.mockReset(); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + const server = createMcpServer(); + await server.connect(serverTransport); + client = new Client({ name: "test-client", 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 webSearch = tools.find((t) => t.name === "omniroute_web_search"); + expect(webSearch).toBeDefined(); + expect(webSearch?.description).toContain("web search"); + }); + + it("should return search results on success", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + id: "search-123", + provider: "serper-search", + 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, + }, + ], + cached: false, + usage: { queries_used: 1, search_cost_usd: 0.002 }, + }), }); - 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" - ); + const result = await client.callTool({ + name: "omniroute_web_search", + arguments: { query: "typescript best practices" }, }); - 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 }, - }), - }); + expect(result.isError).toBeFalsy(); + const content = result.content[0] as { type: string; text: string }; + const data = JSON.parse(content.text); + expect(data.results).toHaveLength(1); + expect(data.results[0].title).toBe("TypeScript Best Practices 2024"); + expect(data.provider).toBe("serper-search"); + }); - 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"); + it("should POST to /v1/search with correct body fields", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + id: "s1", + provider: "brave-search", + query: "react hooks tutorial", + results: [], + cached: false, + usage: { queries_used: 1, search_cost_usd: 0.003 }, + }), }); + + await client.callTool({ + name: "omniroute_web_search", + arguments: { + query: "react hooks tutorial", + max_results: 10, + search_type: "news", + provider: "brave-search", + }, + }); + + expect(mockFetch).toHaveBeenCalledWith( + expect.stringContaining("/v1/search"), + expect.objectContaining({ method: "POST" }) + ); + const [, options] = mockFetch.mock.calls[0]; + const body = JSON.parse(options.body as string); + expect(body.query).toBe("react hooks tutorial"); + expect(body.max_results).toBe(10); + expect(body.search_type).toBe("news"); + expect(body.provider).toBe("brave-search"); + }); + + it("should omit provider from POST body when not specified", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + id: "s2", + provider: "serper-search", + query: "test query", + results: [], + cached: false, + usage: { queries_used: 1, search_cost_usd: 0 }, + }), + }); + + await client.callTool({ + name: "omniroute_web_search", + arguments: { query: "test query" }, + }); + + const [, options] = mockFetch.mock.calls[0]; + const body = JSON.parse(options.body as string); + expect(body).not.toHaveProperty("provider"); + }); + + it("should return isError on backend non-OK response", async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 500, + text: async () => "Internal server error", + }); + + const result = await client.callTool({ + name: "omniroute_web_search", + arguments: { query: "test" }, + }); + + expect(result.isError).toBe(true); + const content = result.content[0] as { type: string; text: string }; + expect(content.text).toContain("Error"); + }); + + it("should return isError on fetch abort/timeout", async () => { + mockFetch.mockRejectedValueOnce(new DOMException("signal timed out", "TimeoutError")); + + const result = await client.callTool({ + name: "omniroute_web_search", + arguments: { query: "test" }, + }); + + expect(result.isError).toBe(true); }); }); diff --git a/open-sse/mcp-server/schemas/tools.ts b/open-sse/mcp-server/schemas/tools.ts index b501e90e..30ca659b 100644 --- a/open-sse/mcp-server/schemas/tools.ts +++ b/open-sse/mcp-server/schemas/tools.ts @@ -405,7 +405,7 @@ export const webSearchInput = z.object({ query: z .string() .min(1, "Query is required") - .max(1000, "Query must be 1000 characters or fewer") + .max(500, "Query must be 500 characters or fewer") .describe("The search query string"), max_results: z .number() @@ -416,9 +416,9 @@ export const webSearchInput = z.object({ .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() + .enum(["serper-search", "brave-search", "perplexity-search", "exa-search", "tavily-search"]) .optional() - .describe("Specific search provider to use (serper, brave, perplexity, exa, tavily)"), + .describe("Specific search provider to use"), }); export const webSearchOutput = z.object({ diff --git a/open-sse/mcp-server/server.ts b/open-sse/mcp-server/server.ts index 234934af..6b39075b 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, @@ -500,6 +501,39 @@ async function handleListModelsCatalog(args: { provider?: string; capability?: s } } +async function handleWebSearch(args: { + query: string; + max_results?: number; + search_type?: "web" | "news"; + provider?: + | "serper-search" + | "brave-search" + | "perplexity-search" + | "exa-search" + | "tavily-search"; +}) { + 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 result = await omniRouteFetch("/v1/search", { + method: "POST", + body: JSON.stringify(body), + }); + await logToolCall("omniroute_web_search", 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_web_search", args, null, Date.now() - start, false, msg); + return { content: [{ type: "text" as const, text: `Error: ${msg}` }], isError: true }; + } +} + // ============ MCP Server Setup ============ /** @@ -725,6 +759,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)) + ) + ); + // ── Memory Tools ────────────────────────────── Object.values(memoryTools).forEach((toolDef) => { server.registerTool( diff --git a/package.json b/package.json index aa15747d..a2dfe77c 100644 --- a/package.json +++ b/package.json @@ -70,7 +70,7 @@ "test:integration": "node --import tsx/esm --test tests/integration/*.test.mjs", "test:e2e": "node scripts/run-playwright-tests.mjs test tests/e2e/*.spec.ts", "test:protocols:e2e": "node scripts/run-protocol-clients-tests.mjs", - "test:vitest": "vitest run open-sse/mcp-server/__tests__/*.test.ts open-sse/services/autoCombo/__tests__/*.test.ts", + "test:vitest": "vitest run --config vitest.mcp.config.ts", "test:ecosystem": "node scripts/run-ecosystem-tests.mjs", "test:coverage": "c8 --exclude=tests/** --exclude=**/*.test.* --reporter=text-summary --reporter=html --reporter=json-summary --reporter=lcov --check-coverage --statements 55 --lines 55 --functions 55 --branches 60 node --import tsx/esm --test tests/unit/*.test.mjs", "test:coverage:legacy": "c8 --exclude=open-sse --check-coverage --lines 50 --functions 50 --branches 50 node --import tsx/esm --test tests/unit/*.test.mjs", diff --git a/vitest.mcp.config.ts b/vitest.mcp.config.ts new file mode 100644 index 00000000..711cf5dd --- /dev/null +++ b/vitest.mcp.config.ts @@ -0,0 +1,19 @@ +import { defineConfig } from "vitest/config"; +import path from "path"; + +export default defineConfig({ + test: { + environment: "node", + globals: true, + include: [ + "open-sse/mcp-server/__tests__/**/*.test.ts", + "open-sse/services/autoCombo/__tests__/**/*.test.ts", + ], + exclude: ["**/node_modules/**", "**/.git/**"], + }, + resolve: { + alias: { + "@": path.resolve(__dirname, "./src"), + }, + }, +});