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:
19
searxng/docker-compose.yml
Normal file
19
searxng/docker-compose.yml
Normal file
@@ -0,0 +1,19 @@
|
||||
services:
|
||||
searxng:
|
||||
image: docker.io/searxng/searxng:latest
|
||||
container_name: searxng
|
||||
volumes:
|
||||
- /mnt/ssd/ai/searxng/config/:/etc/searxng/
|
||||
- /mnt/ssd/ai/searxng/data/:/var/cache/searxng/
|
||||
restart: always
|
||||
ports:
|
||||
- "11437:8080"
|
||||
|
||||
searxng-mcp:
|
||||
build: ./mcp
|
||||
container_name: searxng-mcp
|
||||
network_mode: host
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- PORT=3102
|
||||
- SEARXNG_URL=http://localhost:11437
|
||||
6
searxng/mcp/Dockerfile
Normal file
6
searxng/mcp/Dockerfile
Normal file
@@ -0,0 +1,6 @@
|
||||
FROM node:22-slim
|
||||
WORKDIR /app
|
||||
COPY package.json ./
|
||||
RUN npm install --omit=dev
|
||||
COPY server.js ./
|
||||
CMD ["node", "server.js"]
|
||||
11
searxng/mcp/package.json
Normal file
11
searxng/mcp/package.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"name": "searxng-mcp",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"main": "server.js",
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.12.0",
|
||||
"express": "^4.19.0",
|
||||
"zod": "^3.23.0"
|
||||
}
|
||||
}
|
||||
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