polysearch/src/api.js
amancca 1b56f32c72 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
2026-06-12 04:19:35 +03:00

255 lines
8.6 KiB
JavaScript

import { createServer } from "node:http";
import { loadConfig } from "./config.js";
import { HttpClient } from "./http/client.js";
import { ProxyPool } from "./http/proxy.js";
import { SearchRunner } from "./run.js";
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" });
function unauthorized(res, msg = "Unauthorized") {
res.writeHead(401, { "Content-Type": "application/json" });
res.end(JSON.stringify({ success: false, error: { code: "UNAUTHORIZED", message: msg } }));
}
function badRequest(res, msg) {
res.writeHead(400, { "Content-Type": "application/json" });
res.end(JSON.stringify({ success: false, error: { code: "BAD_REQUEST", message: msg } }));
}
function serverError(res, msg) {
if (res.headersSent) return;
res.writeHead(500, { "Content-Type": "application/json" });
res.end(JSON.stringify({ success: false, error: { code: "INTERNAL_ERROR", message: msg } }));
}
function parseBody(req) {
return new Promise((resolve, reject) => {
let data = "";
req.on("data", chunk => data += chunk);
req.on("end", () => {
try { resolve(JSON.parse(data)); }
catch { reject(new Error("Invalid JSON")); }
});
req.on("error", reject);
});
}
function authenticate(req) {
const apiKey = loadApiKey();
if (!apiKey) return true;
const auth = req.headers["authorization"] || "";
const token = auth.startsWith("Bearer ") ? auth.slice(7) : "";
return token === apiKey;
}
export async function startServer(port = 9876) {
const config = await loadConfig();
if (config.http.user_agents) setUserAgents(config.http.user_agents);
const httpClient = new HttpClient(config.http);
const proxyPool = new ProxyPool(config.proxies, config.proxy);
if (config.proxy.enabled) {
httpClient.setProxyPool(proxyPool);
log.info({ proxyCount: config.proxies.length }, "proxy pool attached to API server");
}
const runner = new SearchRunner({ httpClient, config });
const handleMCP = createMCPHandler(runner);
const server = createServer(async (req, res) => {
res.setHeader("Access-Control-Allow-Origin", "*");
res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
if (req.method === "OPTIONS") {
res.writeHead(204);
res.end();
return;
}
try {
const url = new URL(req.url, `http://${req.headers.host || "localhost"}`);
const path = url.pathname;
// Health check — no auth required
if (path === "/health" && req.method === "GET") {
res.writeHead(200, { "Content-Type": "application/json" });
const m = proxyPool.getMetrics();
res.end(JSON.stringify({
success: true,
status: "ok",
uptime: process.uptime(),
proxyCount: config.proxies.length,
proxyPool: {
total: m.totalProxies,
alive: m.alive,
dead: m.dead,
circuitOpen: m.circuitOpen,
requestsTotal: m.requestsTotal,
successRate: m.successRate,
hourlyUsage: m.hourlyUsageCurrent
}
}));
return;
}
// Metrics — requires auth
if (path === "/metrics" && req.method === "GET") {
if (!authenticate(req)) return unauthorized(res);
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({
success: true,
metrics: proxyPool.getMetrics(),
proxies: proxyPool.getProxyDetail()
}));
return;
}
// Auth required for search endpoints
if (!authenticate(req)) return unauthorized(res);
if (path === "/search" && req.method === "POST") {
const body = await parseBody(req);
const { query, type = "image", limit = 10 } = body;
if (!query) return badRequest(res, "Missing 'query' field");
const start = Date.now();
const data = await runner.run({ query, type, limit });
const response = JSON.parse(formatSearchResponse(data));
response.execution_time_ms = Date.now() - start;
res.writeHead(data.image?.results?.length > 0 || data.web?.results?.length > 0 ? 200 : 404, {
"Content-Type": "application/json"
});
res.end(JSON.stringify(response));
return;
}
if (path === "/batch" && req.method === "POST") {
const body = await parseBody(req);
const { queries = [] } = body;
if (!Array.isArray(queries) || queries.length === 0) {
return badRequest(res, "Missing or empty 'queries' array");
}
if (queries.length > 50) {
return badRequest(res, "Maximum 50 queries per batch");
}
const logBatch = childLogger({ component: "batch", batchSize: queries.length });
const batchStart = Date.now();
logBatch.info("batch started");
const batchResults = await Promise.allSettled(
queries.map(async (q, i) => {
const qStart = Date.now();
try {
const data = await runner.run({
query: q.query || "",
type: q.type || "image",
limit: q.limit || 10
});
return {
index: i,
query: q.query,
type: q.type || "image",
success: true,
execution_time_ms: Date.now() - qStart,
results: data,
errors: data.errors || []
};
} catch (err) {
return {
index: i,
query: q.query,
type: q.type || "image",
success: false,
execution_time_ms: Date.now() - qStart,
error: err.message
};
}
})
);
const totalMs = Date.now() - batchStart;
const ok = batchResults.filter(r => r.status === "fulfilled" && r.value.success).length;
const fail = batchResults.filter(r => r.status === "fulfilled" && !r.value.success).length;
logBatch.info({ total: queries.length, ok, fail, totalMs }, "batch completed");
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({
success: true,
batch_size: queries.length,
execution_time_ms: totalMs,
ok,
fail,
results: batchResults.map(r => r.status === "fulfilled" ? r.value : {
index: -1,
query: "unknown",
success: false,
error: r.reason?.message || "Promise rejected"
}),
metrics: {
pool: proxyPool.getMetrics(),
hourly_usage: proxyPool.getMetrics().hourlyUsageCurrent
}
}));
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}` } }));
} catch (err) {
log.error({ error: err.message }, "API error");
serverError(res, err.message);
}
});
server.listen(port, () => {
log.info({ port }, "API server started");
console.log(`\n PolySearch API running on http://localhost:${port}`);
console.log(` Health: http://localhost:${port}/health`);
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 <key>`);
console.log(` Key: ${key.substring(0, 8)}...${key.slice(-4)}\n`);
} else {
console.log(` Auth: none (no API key configured)\n`);
}
});
const shutdown = () => {
log.info("shutting down API server");
proxyPool.destroy();
server.close(() => process.exit(0));
};
process.on("SIGINT", shutdown);
process.on("SIGTERM", shutdown);
return server;
}