#
tokens: 4631/50000 7/7 files
lines: on (toggle) GitHub
raw markdown copy reset
# 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 | 
```