Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 75 additions & 2 deletions open-sse/mcp-server/__tests__/essentialTools.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down Expand Up @@ -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");
});
});
Comment on lines +140 to +211

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

These tests do not actually execute the handleWebSearch function or the MCP tool logic. They mock fetch and then call the mock directly (e.g., at line 168), which only verifies that the mock returns what it was given. Furthermore, the tests use GET requests with query parameters, while the actual implementation in server.ts uses a POST request with a JSON body. To provide meaningful coverage, the tests should call the handler (which would require exporting it) or use the server.executeTool method on a server instance.

});
54 changes: 54 additions & 0 deletions open-sse/mcp-server/schemas/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)"),
Comment on lines +412 to +415

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The provider field would benefit from being a Zod enum rather than a generic string. This provides better validation and enables AI agents (like Cursor or Claude) to offer autocomplete or better understand the available options based on the gateway's supported providers.

Suggested change
provider: z
.string()
.optional()
.describe("Specific search provider to use (serper, brave, perplexity, exa, tavily)"),
provider: z
.enum(["serper", "brave", "perplexity", "exa", "tavily"])
.optional()
.describe("Specific search provider to use"),

});

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),
}),
});
Comment on lines +418 to +436

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The webSearchOutput schema is missing the answer field. Several search providers supported by the gateway (such as Perplexity and Exa) return a synthesized answer in addition to search results. Including this in the schema allows AI agents to leverage these direct answers which are often more useful than raw snippets.

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(),
    })
  ),
  answer: z
    .object({
      text: z.string().nullable(),
      source: z.string().optional(),
    })
    .nullable()
    .optional(),
  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 ---
Expand Down Expand Up @@ -881,6 +934,7 @@ export const MCP_TOOLS = [
routeRequestTool,
costReportTool,
listModelsCatalogTool,
webSearchTool,
simulateRouteTool,
setBudgetGuardTool,
setRoutingStrategyTool,
Expand Down
44 changes: 44 additions & 0 deletions open-sse/mcp-server/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
routeRequestInput,
costReportInput,
listModelsCatalogInput,
webSearchInput,
simulateRouteInput,
setBudgetGuardInput,
setRoutingStrategyInput,
Expand Down Expand Up @@ -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;
}
Comment on lines +504 to +511

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The nullish coalescing operators (?? 5 and ?? "web") are redundant here because handleWebSearch is called with the result of webSearchInput.parse(args) (see line 638). Zod will have already applied the default values defined in the schema. Additionally, the provider can be added more concisely.

    const body: Record<string, unknown> = {
      query: args.query,
      max_results: args.max_results,
      search_type: args.search_type,
      ...(args.provider ? { 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);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Logging the entire data object (which contains all search results and snippets) might lead to excessively large logs. It is better to log a summary of the results (e.g., result count and provider), consistent with how other tools like omniroute_list_models_catalog are logged in this file.

Suggested change
await logToolCall("omniroute_web_search", args, data, Date.now() - start, true);
await logToolCall("omniroute_web_search", args, { resultCount: (data as any).results?.length, provider: (data as any).provider }, 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 ============

/**
Expand Down Expand Up @@ -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(
Expand Down
3 changes: 3 additions & 0 deletions src/shared/constants/mcpScopes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export const MCP_SCOPE_LIST = [
"read:usage",
"read:models",
"execute:completions",
"execute:search",
"write:budget",
"write:resilience",
] as const;
Expand All @@ -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"],

Expand Down Expand Up @@ -74,6 +76,7 @@ export const MCP_SCOPE_PRESETS = {
"read:usage",
"read:models",
"execute:completions",
"execute:search",
] as const satisfies readonly McpScope[],
} as const;

Expand Down