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:
kang-heewon
2026-04-01 11:00:12 +09:00
parent f250cd246c
commit a4d2b8862b
4 changed files with 176 additions and 2 deletions

View File

@@ -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");
});
});
});

View File

@@ -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,

View File

@@ -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(

View File

@@ -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;