From 1b56f32c72fa6803db1f2565b38082f8650c80b5 Mon Sep 17 00:00:00 2001 From: amancca Date: Fri, 12 Jun 2026 04:05:57 +0300 Subject: [PATCH] Add MCP JSON-RPC endpoint for web_search and image_search tools MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces an HTTP-based MCP handler (POST /mcp) that exposes polysearch's search capabilities to AI agents via the Model Context Protocol. Supports tools/list, tools/call (web_search, image_search), and initialize methods. The endpoint shares the existing SearchRunner, proxy pool, and Bearer auth, so no additional infrastructure or credentials are needed. 💘 Generated with Crush Assisted-by: Crush:deepseek-v4-flash-free --- src/api.js | 13 +++++ src/mcp.js | 148 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 161 insertions(+) create mode 100644 src/mcp.js diff --git a/src/api.js b/src/api.js index bc03f6c..8a5f047 100644 --- a/src/api.js +++ b/src/api.js @@ -7,6 +7,7 @@ import { setUserAgents } from "./utils/ua.js"; import { loadApiKey } from "./api-key.js"; import { logger, childLogger } from "./utils/logger.js"; import { formatSearchResponse, formatErrorResponse } from "./output/agent.js"; +import { createMCPHandler } from "./mcp.js"; const log = childLogger({ component: "api-server" }); @@ -60,6 +61,7 @@ export async function startServer(port = 9876) { } const runner = new SearchRunner({ httpClient, config }); + const handleMCP = createMCPHandler(runner); const server = createServer(async (req, res) => { res.setHeader("Access-Control-Allow-Origin", "*"); @@ -204,6 +206,16 @@ export async function startServer(port = 9876) { return; } + // MCP JSON-RPC endpoint + if (path === "/mcp" && req.method === "POST") { + const body = await parseBody(req); + const mcpResp = await handleMCP(body); + const status = mcpResp.error ? 400 : 200; + res.writeHead(status, { "Content-Type": "application/json" }); + res.end(JSON.stringify(mcpResp)); + return; + } + // 404 res.writeHead(404, { "Content-Type": "application/json" }); res.end(JSON.stringify({ success: false, error: { code: "NOT_FOUND", message: `No endpoint: ${req.method} ${path}` } })); @@ -221,6 +233,7 @@ export async function startServer(port = 9876) { console.log(` Search: POST http://localhost:${port}/search`); console.log(` Batch: POST http://localhost:${port}/batch`); console.log(` Metrics: GET http://localhost:${port}/metrics`); + console.log(` MCP: POST http://localhost:${port}/mcp`); const key = loadApiKey(); if (key) { console.log(` Auth: Authorization: Bearer `); diff --git a/src/mcp.js b/src/mcp.js new file mode 100644 index 0000000..6debae9 --- /dev/null +++ b/src/mcp.js @@ -0,0 +1,148 @@ +import { childLogger } from "./utils/logger.js"; + +const log = childLogger({ component: "mcp" }); + +const TOOLS = [ + { + name: "web_search", + description: "Search the web via DuckDuckGo. Returns organic results with title, URL, snippet, and domain.", + inputSchema: { + type: "object", + properties: { + query: { type: "string", description: "Search query" }, + limit: { type: "number", description: "Max results (1-50)", default: 10 } + }, + required: ["query"] + } + }, + { + name: "image_search", + description: "Search for images via DuckDuckGo. Returns results with title, image_url, source_url, and dimensions.", + inputSchema: { + type: "object", + properties: { + query: { type: "string", description: "Image search query" }, + limit: { type: "number", description: "Max results (1-50)", default: 10 } + }, + required: ["query"] + } + } +]; + +export function createMCPHandler(runner) { + return async function handleMCP(body) { + if (!body || body.jsonrpc !== "2.0") { + return mcpError(null, -32600, "Invalid Request: must be JSON-RPC 2.0"); + } + + const { id, method, params } = body; + + switch (method) { + case "tools/list": + return { + jsonrpc: "2.0", + id, + result: { tools: TOOLS } + }; + + case "tools/call": + return handleToolCall(id, params, runner); + + case "initialize": + return { + jsonrpc: "2.0", + id, + result: { + protocolVersion: "2024-11-05", + capabilities: { tools: {} }, + serverInfo: { name: "polysearch", version: "2.0.0" } + } + }; + + default: + return mcpError(id, -32601, `Method not found: ${method}`); + } + }; +} + +async function handleToolCall(id, params, runner) { + if (!params || !params.name || !params.arguments) { + return mcpError(id, -32602, "Invalid params: name and arguments required"); + } + + const { name, arguments: args } = params; + + switch (name) { + case "web_search": + case "image_search": { + const query = args.query; + if (!query || typeof query !== "string" || query.trim().length === 0) { + return mcpError(id, -32602, "Missing required argument: query"); + } + + const type = name === "image_search" ? "image" : "web"; + const limit = Math.min(Math.max(parseInt(args.limit, 10) || 10, 1), 50); + + log.info({ query, type, limit }, "MCP tool call"); + + try { + const data = await runner.run({ query, type, limit }); + const content = formatMCPResult(data, type); + return { + jsonrpc: "2.0", + id, + result: { content, isError: false } + }; + } catch (err) { + log.error({ error: err.message, name }, "MCP tool failed"); + return { + jsonrpc: "2.0", + id, + result: { + content: [{ type: "text", text: JSON.stringify({ error: err.message }) }], + isError: true + } + }; + } + } + + default: + return mcpError(id, -32601, `Unknown tool: ${name}`); + } +} + +function formatMCPResult(data, type) { + const section = data[type]; + if (!section || !section.results || section.results.length === 0) { + return [{ type: "text", text: JSON.stringify({ query: data.query, total: 0, results: [] }) }]; + } + + const results = section.results.map(r => { + const base = { title: r.title, domain: r.domain || "unknown" }; + if (type === "image") { + return { ...base, image_url: r.image_url, source_url: r.source_url, width: r.width, height: r.height }; + } + return { ...base, url: r.url, snippet: r.snippet }; + }); + + return [{ + type: "text", + text: JSON.stringify({ + query: data.query, + total: section.total, + engine: section.engine, + execution_time_ms: data.executionTimeMs, + results + }, null, 2) + }]; +} + +function mcpError(id, code, message) { + return { + jsonrpc: "2.0", + id, + error: { code, message } + }; +} + +export { mcpError };