# Directory Structure
```
├── .dockerignore
├── .gitignore
├── Dockerfile
├── LICENSE
├── package-lock.json
├── package.json
├── README.md
├── src
│ └── index.ts
└── tsconfig.json
```
# Files
--------------------------------------------------------------------------------
/.dockerignore:
--------------------------------------------------------------------------------
```
1 | # Dependencies
2 | node_modules
3 | npm-debug.log
4 | yarn-debug.log
5 | yarn-error.log
6 |
7 | # Build output
8 | build
9 | dist
10 |
11 | # Version control
12 | .git
13 | .gitignore
14 |
15 | # Environment files
16 | .env
17 | .env.local
18 | .env.development.local
19 | .env.test.local
20 | .env.production.local
21 |
22 | # Editor directories and files
23 | .idea
24 | .vscode
25 | *.suo
26 | *.ntvs*
27 | *.njsproj
28 | *.sln
29 | *.sw?
30 |
31 | # System Files
32 | .DS_Store
33 | Thumbs.db
```
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
```
1 | # Dependency directories
2 | node_modules/
3 | jspm_packages/
4 |
5 | # Build outputs
6 | build/
7 | dist/
8 | lib/
9 | *.tsbuildinfo
10 |
11 | # Logs
12 | logs
13 | *.log
14 | npm-debug.log*
15 | yarn-debug.log*
16 | yarn-error.log*
17 | lerna-debug.log*
18 |
19 | # Environment variables
20 | .env
21 | .env.local
22 | .env.development.local
23 | .env.test.local
24 | .env.production.local
25 |
26 | # Cache directories
27 | .npm
28 | .eslintcache
29 | .node_repl_history
30 |
31 | # Coverage directories
32 | coverage/
33 | .nyc_output
34 |
35 | # Editor directories and files
36 | .idea/
37 | .vscode/
38 | *.suo
39 | *.ntvs*
40 | *.njsproj
41 | *.sln
42 | *.sw?
43 | .DS_Store
44 |
45 | # Other
46 | .pnp.*
47 | .yarn/*
48 | !.yarn/patches
49 | !.yarn/plugins
50 | !.yarn/releases
51 | !.yarn/sdks
52 | !.yarn/versions
```
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
```markdown
1 | # SearXNG Model Context Protocol Server
2 |
3 | A Model Context Protocol (MCP) server for interfacing language models with SearXNG search engine.
4 |
5 | ## Description
6 |
7 | This server enables language models to perform web searches through SearXNG using the Model Context Protocol standard. It provides a clean interface for language models to send search queries to SearXNG and receive formatted results.
8 |
9 | ## Installation
10 |
11 | ```bash
12 | # Clone the repository
13 | git clone https://github.com/aeon-seraph/searxng-mcp.git
14 | cd searxng-mcp
15 |
16 | # Install dependencies
17 | npm install
18 |
19 | # Build the project
20 | npm run build
21 | ```
22 |
23 | ## Requirements
24 |
25 | - Node.js 16+
26 | - A running SearXNG instance (by default at http://localhost:8888)
27 |
28 | ## Usage
29 |
30 | ```bash
31 | # Run the server
32 | node build/index.js
33 | ```
34 |
35 | The server will run on stdio, making it suitable for integration with MCP-compatible language models.
36 |
37 | ## Configuration
38 |
39 | The server can be configured using environment variables:
40 |
41 | | Variable | Description | Default |
42 | |----------|-------------|---------|
43 | | SEARXNG_PROTOCOL | Protocol to use (http/https) | http |
44 | | SEARXNG_HOST | SearXNG host | localhost |
45 | | SEARXNG_PORT | SearXNG port | 8888 |
46 | | CACHE_TTL | Cache time-to-live in milliseconds | 600000 (10 minutes) |
47 | | MAX_CACHE_SIZE | Maximum number of cached queries | 100 |
48 |
49 | Example:
50 | ```bash
51 | SEARXNG_HOST=mysearx.example.com SEARXNG_PORT=443 SEARXNG_PROTOCOL=https node build/index.js
52 | ```
53 |
54 | ## Docker
55 |
56 | The project includes a Dockerfile for easy deployment:
57 |
58 | ```bash
59 | # Build the Docker image
60 | docker build -t searxng-mcp .
61 |
62 | # Run the container
63 | docker run -e SEARXNG_HOST=mysearx.example.com -e SEARXNG_PROTOCOL=https searxng-mcp
64 | ```
65 |
66 | ## Search Parameters
67 |
68 | The search function supports the following parameters:
69 |
70 | - `query` (required): The search query string
71 | - `categories`: Comma-separated list of search categories
72 | - `pageno`: Search page number (default: 1)
73 | - `time_range`: Time range for results ("day", "week", "month", "year")
74 | - `raw_json`: Return raw JSON response instead of formatted text (default: false)
75 |
76 | ## License
77 |
78 | MIT
```
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
```json
1 | {
2 | "compilerOptions": {
3 | "target": "ES2022",
4 | "module": "Node16",
5 | "moduleResolution": "Node16",
6 | "outDir": "./build",
7 | "rootDir": "./src",
8 | "strict": true,
9 | "esModuleInterop": true,
10 | "skipLibCheck": true,
11 | "forceConsistentCasingInFileNames": true
12 | },
13 | "include": ["src/**/*"],
14 | "exclude": ["node_modules"]
15 | }
16 |
```
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
```dockerfile
1 | FROM node:20-alpine
2 |
3 | WORKDIR /app
4 |
5 | COPY package*.json ./
6 |
7 | RUN npm ci
8 |
9 | COPY tsconfig.json ./
10 | COPY src/ ./src/
11 |
12 | RUN npm run build
13 |
14 | # SearXNG connection config
15 | # Can be overridden at runtime with -e SEARXNG_PROTOCOL=<protocol> -e SEARXNG_HOST=<host> -e SEARXNG_PORT=<port>
16 | ENV SEARXNG_PROTOCOL=http
17 | ENV SEARXNG_HOST=localhost
18 | ENV SEARXNG_PORT=8888
19 |
20 | # Cache config
21 | ENV CACHE_TTL=600000
22 | ENV MAX_CACHE_SIZE=100
23 |
24 | EXPOSE 8888
25 |
26 | CMD ["node", "build/index.js"]
27 |
```
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
```json
1 | {
2 | "type": "module",
3 | "bin": {
4 | "searxng-mcp": "build/index.js"
5 | },
6 | "name": "searxng-mcp",
7 | "version": "1.0.0",
8 | "main": "index.js",
9 | "scripts": {
10 | "build": "tsc && node --input-type=module -e \"import { promises as fs } from 'fs'; await fs.chmod('build/index.js', '755');\"",
11 | "prepare": "npm run build",
12 | "watch": "tsc --watch"
13 | },
14 | "keywords": [],
15 | "author": "aeon-seraph",
16 | "license": "MIT",
17 | "description": "",
18 | "dependencies": {
19 | "@modelcontextprotocol/sdk": "^1.6.1",
20 | "zod": "^3.24.2"
21 | },
22 | "devDependencies": {
23 | "@types/node": "^22.13.9",
24 | "typescript": "^5.8.2"
25 | }
26 | }
27 |
```
--------------------------------------------------------------------------------
/src/index.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { Server } from "@modelcontextprotocol/sdk/server/index.js";
2 | import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
3 | import {
4 | CallToolRequestSchema,
5 | ListToolsRequestSchema,
6 | } from "@modelcontextprotocol/sdk/types.js";
7 | import { z } from "zod";
8 | import { zodToJsonSchema } from "zod-to-json-schema";
9 |
10 | interface CacheEntry {
11 | data: SearchResult;
12 | timestamp: number;
13 | }
14 |
15 | // Simple cache to store recent searches
16 | const searchCache: Record<string, CacheEntry> = {};
17 | const CACHE_TTL = process.env.CACHE_TTL
18 | ? parseInt(process.env.CACHE_TTL)
19 | : 10 * 60 * 1000;
20 | const MAX_CACHE_SIZE = process.env.MAX_CACHE_SIZE
21 | ? parseInt(process.env.MAX_CACHE_SIZE)
22 | : 100;
23 |
24 | const SearchSchema = z.object({
25 | query: z
26 | .string()
27 | .describe("The search query string passed to external search services"),
28 | categories: z
29 | .string()
30 | .optional()
31 | .describe("Comma separated list of active search categories"),
32 | pageno: z.coerce
33 | .number()
34 | .int()
35 | .positive()
36 | .default(1)
37 | .describe("Search page number"),
38 | time_range: z
39 | .enum(["day", "week", "month", "year"])
40 | .optional()
41 | .describe("Time range for search results"),
42 | raw_json: z
43 | .boolean()
44 | .optional()
45 | .default(false)
46 | .describe(
47 | "If true, returns the raw JSON response instead of formatted text",
48 | ),
49 | });
50 |
51 | type SearchArgs = z.infer<typeof SearchSchema>;
52 |
53 | interface SearchResult {
54 | query: string;
55 | number_of_results: number;
56 | results: Array<{
57 | title: string;
58 | url: string;
59 | content: string;
60 | engine: string;
61 | score?: number;
62 | category?: string;
63 | pretty_url?: string;
64 | publishedDate?: string;
65 | }>;
66 | suggestions: string[];
67 | answers: string[];
68 | corrections: string[];
69 | infoboxes: any[];
70 | engines: Record<string, any>;
71 | timing: Record<string, number>;
72 | version: string;
73 | }
74 |
75 | async function search(searchArgs: SearchArgs) {
76 | const { query, categories, pageno, time_range, raw_json } =
77 | searchArgs;
78 |
79 | // Cache key based on search params
80 | const cacheKey = JSON.stringify({
81 | query,
82 | categories,
83 | pageno,
84 | time_range,
85 | });
86 |
87 | // Check if search is already cached
88 | const now = Date.now();
89 | const cachedEntry = searchCache[cacheKey];
90 |
91 | if (cachedEntry && now - cachedEntry.timestamp < CACHE_TTL) {
92 | console.error(`Cache hit for query: ${query}`);
93 |
94 | const responseText = raw_json
95 | ? JSON.stringify(cachedEntry.data, null, 2)
96 | : formatResultsForLLM(cachedEntry.data);
97 |
98 | return {
99 | content: [
100 | {
101 | type: "text",
102 | text: responseText,
103 | },
104 | ],
105 | };
106 | }
107 |
108 | // console.error(`Cache miss for query: ${query}`);
109 |
110 | const searxngHost = process.env.SEARXNG_HOST || "localhost";
111 | const searxngPort = process.env.SEARXNG_PORT || "8888";
112 | const searxngProtocol = process.env.SEARXNG_PROTOCOL || "http";
113 | const searxngBaseUrl = `${searxngProtocol}://${searxngHost}:${searxngPort}`;
114 |
115 | const url = new URL("/search", searxngBaseUrl);
116 |
117 | url.searchParams.append("q", query);
118 | url.searchParams.append("format", "json");
119 |
120 | if (categories) url.searchParams.append("categories", categories);
121 | if (pageno) url.searchParams.append("pageno", pageno.toString());
122 | if (time_range) url.searchParams.append("time_range", time_range);
123 |
124 | try {
125 | const response = await fetch(url.toString(), {
126 | method: "GET",
127 | headers: {
128 | Accept: "application/json",
129 | },
130 | });
131 |
132 | if (!response.ok) {
133 | throw new Error(`Search failed with status: ${response.status}`);
134 | }
135 |
136 | const data: SearchResult = await response.json();
137 |
138 | // Store in cache
139 | searchCache[cacheKey] = {
140 | data,
141 | timestamp: now,
142 | };
143 |
144 | // Manage cache size by removing oldest entries if needed
145 | const cacheKeys = Object.keys(searchCache);
146 | if (cacheKeys.length > MAX_CACHE_SIZE) {
147 | const oldestKeys = cacheKeys
148 | .map((key) => ({ key, timestamp: searchCache[key].timestamp }))
149 | .sort((a, b) => a.timestamp - b.timestamp)
150 | .slice(0, cacheKeys.length - MAX_CACHE_SIZE)
151 | .map((entry) => entry.key);
152 |
153 | oldestKeys.forEach((key) => delete searchCache[key]);
154 | }
155 |
156 | const responseText = raw_json
157 | ? JSON.stringify(data, null, 2)
158 | : formatResultsForLLM(data);
159 |
160 | return {
161 | content: [
162 | {
163 | type: "text",
164 | text: responseText,
165 | },
166 | ],
167 | };
168 | } catch (error) {
169 | return {
170 | content: [
171 | {
172 | type: "text",
173 | text: `Error searching SearXNG: ${error}`,
174 | },
175 | ],
176 | };
177 | }
178 | }
179 |
180 | function formatResultsForLLM(data: SearchResult): string {
181 | const { results, suggestions, answers, corrections, infoboxes } = data;
182 |
183 | let formattedText = `Search Results for: "${data.query}" (${data.number_of_results} results found)\n\n`;
184 |
185 | if (answers && answers.length > 0) {
186 | formattedText += "Direct Answers:\n";
187 | answers.forEach((answer, index) => {
188 | formattedText += `${index + 1}. ${answer}\n`;
189 | });
190 | formattedText += "\n";
191 | }
192 |
193 | if (infoboxes && infoboxes.length > 0) {
194 | formattedText += "Information Boxes:\n";
195 | infoboxes.forEach((infobox, index) => {
196 | formattedText += `Infobox ${index + 1}: ${JSON.stringify(infobox)}\n`;
197 | });
198 | formattedText += "\n";
199 | }
200 |
201 | if (corrections && corrections.length > 0) {
202 | formattedText += "Did you mean:\n";
203 | corrections.forEach((correction, index) => {
204 | formattedText += `${index + 1}. ${correction}\n`;
205 | });
206 | formattedText += "\n";
207 | }
208 |
209 | if (suggestions && suggestions.length > 0) {
210 | formattedText += "Search Suggestions:\n";
211 | suggestions.forEach((suggestion, index) => {
212 | formattedText += `${index + 1}. ${suggestion}\n`;
213 | });
214 | formattedText += "\n";
215 | }
216 |
217 | // Format main results
218 | if (results && results.length > 0) {
219 | formattedText += "Web Results:\n";
220 |
221 | results.forEach((result, index) => {
222 | const publishedDate = result.publishedDate
223 | ? ` (${result.publishedDate})`
224 | : "";
225 | formattedText += `${index + 1}. ${result.title}${publishedDate}\n`;
226 | formattedText += ` URL: ${result.url}\n`;
227 | formattedText += ` Engine: ${result.engine}\n`;
228 | formattedText += ` Summary: ${result.content.trim()}\n\n`;
229 | });
230 | }
231 |
232 | return formattedText;
233 | }
234 |
235 | const server = new Server(
236 | {
237 | name: "searxng-mcp",
238 | version: "1.0.0",
239 | },
240 | {
241 | capabilities: {
242 | tools: {},
243 | },
244 | },
245 | );
246 |
247 | server.setRequestHandler(ListToolsRequestSchema, async () => {
248 | return {
249 | tools: [
250 | {
251 | name: "search",
252 | description: "Search the internet using a variety of search engines.",
253 | inputSchema: zodToJsonSchema(SearchSchema),
254 | },
255 | ],
256 | };
257 | });
258 |
259 | server.setRequestHandler(CallToolRequestSchema, async (request) => {
260 | try {
261 | if (!request.params.arguments) {
262 | throw new Error("Arguments are required");
263 | }
264 |
265 | if (request.params.name === "search") {
266 | const args = SearchSchema.parse(request.params.arguments);
267 | const result = await search(args);
268 | return result;
269 | }
270 |
271 | throw new Error(`Unknown tool: ${request.params.name}`);
272 | } catch (error) {
273 | console.error("Error in CallToolRequestSchema handler:", error);
274 |
275 | if (error instanceof z.ZodError) {
276 | throw new Error(`Invalid input: ${JSON.stringify(error.errors)}`);
277 | }
278 |
279 | throw error;
280 | }
281 | });
282 |
283 | async function runServer() {
284 | const transport = new StdioServerTransport();
285 | await server.connect(transport);
286 | console.error("SearXNG MCP Server running on stdio");
287 | }
288 |
289 | runServer().catch((error) => {
290 | console.error("Fatal error in runServer():", error);
291 | process.exit(1);
292 | });
293 |
```