Add MCP JSON-RPC endpoint for web_search and image_search tools
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
This commit is contained in:
parent
5bd3b9d06b
commit
1b56f32c72
2 changed files with 161 additions and 0 deletions
13
src/api.js
13
src/api.js
|
|
@ -7,6 +7,7 @@ import { setUserAgents } from "./utils/ua.js";
|
||||||
import { loadApiKey } from "./api-key.js";
|
import { loadApiKey } from "./api-key.js";
|
||||||
import { logger, childLogger } from "./utils/logger.js";
|
import { logger, childLogger } from "./utils/logger.js";
|
||||||
import { formatSearchResponse, formatErrorResponse } from "./output/agent.js";
|
import { formatSearchResponse, formatErrorResponse } from "./output/agent.js";
|
||||||
|
import { createMCPHandler } from "./mcp.js";
|
||||||
|
|
||||||
const log = childLogger({ component: "api-server" });
|
const log = childLogger({ component: "api-server" });
|
||||||
|
|
||||||
|
|
@ -60,6 +61,7 @@ export async function startServer(port = 9876) {
|
||||||
}
|
}
|
||||||
|
|
||||||
const runner = new SearchRunner({ httpClient, config });
|
const runner = new SearchRunner({ httpClient, config });
|
||||||
|
const handleMCP = createMCPHandler(runner);
|
||||||
|
|
||||||
const server = createServer(async (req, res) => {
|
const server = createServer(async (req, res) => {
|
||||||
res.setHeader("Access-Control-Allow-Origin", "*");
|
res.setHeader("Access-Control-Allow-Origin", "*");
|
||||||
|
|
@ -204,6 +206,16 @@ export async function startServer(port = 9876) {
|
||||||
return;
|
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
|
// 404
|
||||||
res.writeHead(404, { "Content-Type": "application/json" });
|
res.writeHead(404, { "Content-Type": "application/json" });
|
||||||
res.end(JSON.stringify({ success: false, error: { code: "NOT_FOUND", message: `No endpoint: ${req.method} ${path}` } }));
|
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(` Search: POST http://localhost:${port}/search`);
|
||||||
console.log(` Batch: POST http://localhost:${port}/batch`);
|
console.log(` Batch: POST http://localhost:${port}/batch`);
|
||||||
console.log(` Metrics: GET http://localhost:${port}/metrics`);
|
console.log(` Metrics: GET http://localhost:${port}/metrics`);
|
||||||
|
console.log(` MCP: POST http://localhost:${port}/mcp`);
|
||||||
const key = loadApiKey();
|
const key = loadApiKey();
|
||||||
if (key) {
|
if (key) {
|
||||||
console.log(` Auth: Authorization: Bearer <key>`);
|
console.log(` Auth: Authorization: Bearer <key>`);
|
||||||
|
|
|
||||||
148
src/mcp.js
Normal file
148
src/mcp.js
Normal file
|
|
@ -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 };
|
||||||
Loading…
Add table
Reference in a new issue