Files
AgapHost/searxng/mcp/server.js
Alvis 7ef3ffa00e refactor: split openai/ into ollama/, openwebui/, searxng/
Move services out of the monolithic openai/docker-compose.yml:
- ollama/ — ollama GPU + CPU inference servers
- openwebui/ — open-webui chat UI (uses env var for ANTHROPIC_API_KEY)
- searxng/ — SearXNG container + searxng-mcp MCP server (port 3102)

openai/ now contains only: litellm, langfuse, qdrant, faster-whisper,
silero-tts, pipecat.

searxng-mcp exposes a single searxng_search tool via MCP HTTP on :3102.
Registered in ~/.claude.json as the "searxng" MCP server.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 05:05:45 +00:00

99 lines
3.7 KiB
JavaScript

import express from 'express';
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
import { z } from 'zod';
const PORT = parseInt(process.env.PORT || '3102');
const SEARXNG_URL = (process.env.SEARXNG_URL || 'http://localhost:11437').replace(/\/$/, '');
function createServer() {
const server = new McpServer({ name: 'searxng-mcp', version: '1.0.0' });
server.tool(
'searxng_search',
'Search the web using the self-hosted SearXNG meta-search engine. Returns titles, URLs, and content snippets from multiple search engines.',
{
query: z.string().describe('Search query'),
categories: z.enum(['general', 'news', 'images', 'videos', 'science', 'files', 'social_media', 'it'])
.optional()
.describe('Search category, default: general'),
language: z.string().optional().describe('Language code (e.g. "ru", "en", "auto"), default: auto'),
time_range: z.enum(['day', 'week', 'month', 'year']).optional().describe('Limit results to time range'),
limit: z.number().optional().describe('Max results to return, default 10'),
},
async ({ query, categories, language, time_range, limit = 10 }) => {
try {
const params = new URLSearchParams({
q: query,
format: 'json',
categories: categories || 'general',
language: language || 'auto',
});
if (time_range) params.set('time_range', time_range);
const res = await fetch(`${SEARXNG_URL}/search?${params}`);
if (!res.ok) {
return { content: [{ type: 'text', text: `SearXNG returned HTTP ${res.status}` }], isError: true };
}
const data = await res.json();
const results = (data.results || []).slice(0, limit).map(r => ({
title: r.title || null,
url: r.url || null,
content: (r.content || '').slice(0, 500) || null,
engine: r.engine || null,
score: r.score != null ? Math.round(r.score * 100) / 100 : null,
publishedDate: r.publishedDate || null,
}));
const out = {
query: data.query,
totalResults: data.number_of_results,
count: results.length,
results,
};
return { content: [{ type: 'text', text: JSON.stringify(out, null, 2) }] };
} catch (e) {
return { content: [{ type: 'text', text: `Error: ${e.message}` }], isError: true };
}
}
);
return server;
}
const app = express();
app.use(express.json());
const sseTransports = new Map();
app.all('/mcp', async (req, res) => {
try {
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
res.on('close', () => transport.close());
await createServer().connect(transport);
await transport.handleRequest(req, res, req.body);
} catch (e) {
if (!res.headersSent) res.status(500).json({ error: e.message });
}
});
app.get('/sse', async (req, res) => {
const transport = new SSEServerTransport('/messages', res);
sseTransports.set(transport.sessionId, transport);
res.on('close', () => sseTransports.delete(transport.sessionId));
await createServer().connect(transport);
});
app.post('/messages', async (req, res) => {
const transport = sseTransports.get(req.query.sessionId);
if (!transport) return res.status(400).send('Unknown session');
await transport.handlePostMessage(req, res);
});
app.get('/health', (_, res) => res.json({ status: 'ok', searxng: SEARXNG_URL }));
app.listen(PORT, () => console.log(`searxng-mcp listening on :${PORT}`));