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>
99 lines
3.7 KiB
JavaScript
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}`));
|