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>
This commit is contained in:
98
searxng/mcp/server.js
Normal file
98
searxng/mcp/server.js
Normal file
@@ -0,0 +1,98 @@
|
||||
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}`));
|
||||
Reference in New Issue
Block a user