mcp-searxng 0.10.5 → 1.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # SearXNG MCP Server
2
2
 
3
- An [MCP server](https://modelcontextprotocol.io/introduction) implementation that integrates the [SearXNG](https://docs.searxng.org) API, providing web search capabilities.
3
+ An [MCP server](https://modelcontextprotocol.io/introduction) that integrates the [SearXNG](https://docs.searxng.org) API, giving AI assistants web search capabilities.
4
4
 
5
5
  [![https://nodei.co/npm/mcp-searxng.png?downloads=true&downloadRank=true&stars=true](https://nodei.co/npm/mcp-searxng.png?downloads=true&downloadRank=true&stars=true)](https://www.npmjs.com/package/mcp-searxng)
6
6
 
@@ -8,21 +8,25 @@ An [MCP server](https://modelcontextprotocol.io/introduction) implementation tha
8
8
 
9
9
  <a href="https://glama.ai/mcp/servers/0j7jjyt7m9"><img width="380" height="200" src="https://glama.ai/mcp/servers/0j7jjyt7m9/badge" alt="SearXNG Server MCP server" /></a>
10
10
 
11
- ## How It Works
12
-
13
- `mcp-searxng` is an **MCP (Model Context Protocol) server** — it is a separate process that AI assistants (such as Claude) connect to in order to perform web searches. It communicates with a SearXNG instance over SearXNG's HTTP JSON API.
11
+ ## Quick Start
14
12
 
15
- > **Not a SearXNG plugin:** This project cannot be installed as a native SearXNG plugin (i.e., a Python module loaded inside the SearXNG process). It is a standalone MCP server that runs alongside your SearXNG instance and queries it via its API. You point it at any existing SearXNG instance by setting the `SEARXNG_URL` environment variable.
13
+ Add to your MCP client configuration (e.g. `claude_desktop_config.json`):
16
14
 
15
+ ```json
16
+ {
17
+ "mcpServers": {
18
+ "searxng": {
19
+ "command": "npx",
20
+ "args": ["-y", "mcp-searxng"],
21
+ "env": {
22
+ "SEARXNG_URL": "YOUR_SEARXNG_INSTANCE_URL"
23
+ }
24
+ }
25
+ }
26
+ }
17
27
  ```
18
- AI Assistant (e.g. Claude)
19
- │ MCP protocol
20
-
21
- mcp-searxng (this project — Node.js process)
22
- │ HTTP JSON API (SEARXNG_URL)
23
-
24
- SearXNG instance
25
- ```
28
+
29
+ Replace `YOUR_SEARXNG_INSTANCE_URL` with the URL of your SearXNG instance (e.g. `https://search.example.com`).
26
30
 
27
31
  ## Features
28
32
 
@@ -34,6 +38,22 @@ AI Assistant (e.g. Claude)
34
38
  - **Language Selection**: Filter results by preferred language.
35
39
  - **Safe Search**: Control content filtering level for search results.
36
40
 
41
+ ## How It Works
42
+
43
+ `mcp-searxng` is a standalone MCP server — a separate Node.js process that your AI assistant connects to for web search. It queries any SearXNG instance via its HTTP JSON API.
44
+
45
+ > **Not a SearXNG plugin:** This project cannot be installed as a native SearXNG plugin. Point it at any existing SearXNG instance by setting `SEARXNG_URL`.
46
+
47
+ ```
48
+ AI Assistant (e.g. Claude)
49
+ │ MCP protocol
50
+
51
+ mcp-searxng (this project — Node.js process)
52
+ │ HTTP JSON API (SEARXNG_URL)
53
+
54
+ SearXNG instance
55
+ ```
56
+
37
57
  ## Tools
38
58
 
39
59
  - **searxng_web_search**
@@ -55,88 +75,10 @@ AI Assistant (e.g. Claude)
55
75
  - `paragraphRange` (string, optional): Return specific paragraph ranges (e.g., '1-5', '3', '10-')
56
76
  - `readHeadings` (boolean, optional): Return only a list of headings instead of full content
57
77
 
58
- ## Configuration
59
-
60
- ### Environment Variables
61
-
62
- #### Required
63
- - **`SEARXNG_URL`**: SearXNG instance URL (default: `http://localhost:8080`)
64
- - Format: `<protocol>://<hostname>[:<port>]`
65
- - Example: `https://search.example.com`
66
-
67
- #### Optional
68
- - **`AUTH_USERNAME`** / **`AUTH_PASSWORD`**: HTTP Basic Auth credentials for `searxng_web_search` (password-protected SearXNG instances)
69
- - **`USER_AGENT`**: Global default User-Agent header used by both `searxng_web_search` and `web_url_read` (e.g., `MyBot/1.0`)
70
- - **`URL_READER_USER_AGENT`**: Custom User-Agent specifically for the `web_url_read` tool (overrides `USER_AGENT` for URL reading requests)
71
- - **`HTTP_PROXY`** / **`HTTPS_PROXY`**: Global proxy URLs for routing traffic (fallback for both interfaces)
72
- - Format: `http://[username:password@]proxy.host:port`
73
- - **`NO_PROXY`**: Comma-separated bypass list (e.g., `localhost,.internal,example.com`)
74
-
75
- ##### Interface-Specific Proxies (Optional)
76
- - **`SEARCH_HTTP_PROXY`** / **`SEARCH_HTTPS_PROXY`**: Proxy for `searxng_web_search` tool only
77
- - **`URL_READER_HTTP_PROXY`** / **`URL_READER_HTTPS_PROXY`**: Proxy for `web_url_read` tool only
78
- - These take priority over `HTTP_PROXY`/`HTTPS_PROXY` for their respective interfaces
79
-
80
- #### Advanced Configuration
81
-
82
- ```bash
83
- # Separate proxies for search and URL reading
84
- SEARCH_HTTP_PROXY=http://search-proxy:8080
85
- URL_READER_HTTP_PROXY=http://reader-proxy:8080
86
-
87
- # Custom user_agent for URL reader
88
- URL_READER_USER_AGENT="Mozilla/5.0 (compatible; Bot/1.0)"
89
- ```
90
-
91
- ## Installation & Configuration
92
-
93
- ### [NPX](https://www.npmjs.com/package/mcp-searxng)
94
-
95
- ```json
96
- {
97
- "mcpServers": {
98
- "searxng": {
99
- "command": "npx",
100
- "args": ["-y", "mcp-searxng"],
101
- "env": {
102
- "SEARXNG_URL": "YOUR_SEARXNG_INSTANCE_URL"
103
- }
104
- }
105
- }
106
- }
107
- ```
78
+ ## Installation
108
79
 
109
80
  <details>
110
- <summary>Full Configuration Example (All Options)</summary>
111
-
112
- ```json
113
- {
114
- "mcpServers": {
115
- "searxng": {
116
- "command": "npx",
117
- "args": ["-y", "mcp-searxng"],
118
- "env": {
119
- "SEARXNG_URL": "YOUR_SEARXNG_INSTANCE_URL",
120
- "AUTH_USERNAME": "your_username",
121
- "AUTH_PASSWORD": "your_password",
122
- "USER_AGENT": "MyBot/1.0",
123
- "URL_READER_USER_AGENT": "Mozilla/5.0 (compatible; MyBot/1.0)",
124
- "SEARCH_HTTP_PROXY": "http://search-proxy.company.com:8080",
125
- "URL_READER_HTTP_PROXY": "http://reader-proxy.company.com:8080",
126
- "HTTP_PROXY": "http://global-proxy.company.com:8080",
127
- "HTTPS_PROXY": "http://global-proxy.company.com:8080",
128
- "NO_PROXY": "localhost,127.0.0.1,.local,.internal"
129
- }
130
- }
131
- }
132
- }
133
- ```
134
-
135
- **Note:** Mix and match environment variables as needed. All optional variables can be used independently or together.
136
-
137
- </details>
138
-
139
- ### [NPM](https://www.npmjs.com/package/mcp-searxng)
81
+ <summary>NPM (global install)</summary>
140
82
 
141
83
  ```bash
142
84
  npm install -g mcp-searxng
@@ -155,35 +97,12 @@ npm install -g mcp-searxng
155
97
  }
156
98
  ```
157
99
 
158
- <details>
159
- <summary>Full Configuration Example (All Options)</summary>
160
-
161
- ```json
162
- {
163
- "mcpServers": {
164
- "searxng": {
165
- "command": "mcp-searxng",
166
- "env": {
167
- "SEARXNG_URL": "YOUR_SEARXNG_INSTANCE_URL",
168
- "AUTH_USERNAME": "your_username",
169
- "AUTH_PASSWORD": "your_password",
170
- "USER_AGENT": "MyBot/1.0",
171
- "URL_READER_USER_AGENT": "Mozilla/5.0 (compatible; MyBot/1.0)",
172
- "SEARCH_HTTP_PROXY": "http://search-proxy.company.com:8080",
173
- "URL_READER_HTTP_PROXY": "http://reader-proxy.company.com:8080",
174
- "HTTP_PROXY": "http://global-proxy.company.com:8080",
175
- "HTTPS_PROXY": "http://global-proxy.company.com:8080",
176
- "NO_PROXY": "localhost,127.0.0.1,.local,.internal"
177
- }
178
- }
179
- }
180
- }
181
- ```
182
100
  </details>
183
101
 
184
- ### Docker
102
+ <details>
103
+ <summary>Docker</summary>
185
104
 
186
- #### Using [Pre-built Image from Docker Hub](https://hub.docker.com/r/isokoliuk/mcp-searxng)
105
+ **Pre-built image:**
187
106
 
188
107
  ```bash
189
108
  docker pull isokoliuk/mcp-searxng:latest
@@ -207,62 +126,22 @@ docker pull isokoliuk/mcp-searxng:latest
207
126
  }
208
127
  ```
209
128
 
210
- <details>
211
- <summary>Full Configuration Example (All Options)</summary>
129
+ To pass additional env vars, add `-e VAR_NAME` to `args` and the variable to `env`.
212
130
 
213
- ```json
214
- {
215
- "mcpServers": {
216
- "searxng": {
217
- "command": "docker",
218
- "args": [
219
- "run", "-i", "--rm",
220
- "-e", "SEARXNG_URL",
221
- "-e", "AUTH_USERNAME",
222
- "-e", "AUTH_PASSWORD",
223
- "-e", "USER_AGENT",
224
- "-e", "URL_READER_USER_AGENT",
225
- "-e", "SEARCH_HTTP_PROXY",
226
- "-e", "SEARCH_HTTPS_PROXY",
227
- "-e", "URL_READER_HTTP_PROXY",
228
- "-e", "URL_READER_HTTPS_PROXY",
229
- "-e", "HTTP_PROXY",
230
- "-e", "HTTPS_PROXY",
231
- "-e", "NO_PROXY",
232
- "isokoliuk/mcp-searxng:latest"
233
- ],
234
- "env": {
235
- "SEARXNG_URL": "YOUR_SEARXNG_INSTANCE_URL",
236
- "AUTH_USERNAME": "your_username",
237
- "AUTH_PASSWORD": "your_password",
238
- "USER_AGENT": "MyBot/1.0",
239
- "URL_READER_USER_AGENT": "Mozilla/5.0 (compatible; MyBot/1.0)",
240
- "SEARCH_HTTP_PROXY": "http://search-proxy.company.com:8080",
241
- "URL_READER_HTTP_PROXY": "http://reader-proxy.company.com:8080",
242
- "HTTP_PROXY": "http://global-proxy.company.com:8080",
243
- "HTTPS_PROXY": "http://global-proxy.company.com:8080",
244
- "NO_PROXY": "localhost,127.0.0.1,.local,.internal"
245
- }
246
- }
247
- }
248
- }
249
- ```
250
-
251
- **Note:** Add only the `-e` flags and env variables you need.
252
-
253
- </details>
254
-
255
- #### Build Locally
131
+ **Build locally:**
256
132
 
257
133
  ```bash
258
134
  docker build -t mcp-searxng:latest -f Dockerfile .
259
135
  ```
260
136
 
261
- Use the same configuration as above, replacing `isokoliuk/mcp-searxng:latest` with `mcp-searxng:latest`.
137
+ Use the same config above, replacing `isokoliuk/mcp-searxng:latest` with `mcp-searxng:latest`.
262
138
 
263
- #### Docker Compose
139
+ </details>
264
140
 
265
- Create a `docker-compose.yml` file:
141
+ <details>
142
+ <summary>Docker Compose</summary>
143
+
144
+ `docker-compose.yml`:
266
145
 
267
146
  ```yaml
268
147
  services:
@@ -271,19 +150,10 @@ services:
271
150
  stdin_open: true
272
151
  environment:
273
152
  - SEARXNG_URL=YOUR_SEARXNG_INSTANCE_URL
274
- # Add any optional variables as needed:
275
- # - AUTH_USERNAME=your_username
276
- # - AUTH_PASSWORD=your_password
277
- # - USER_AGENT=MyBot/1.0
278
- # - URL_READER_USER_AGENT=Mozilla/5.0 (compatible; MyBot/1.0)
279
- # - SEARCH_HTTP_PROXY=http://search-proxy.company.com:8080
280
- # - URL_READER_HTTP_PROXY=http://reader-proxy.company.com:8080
281
- # - HTTP_PROXY=http://global-proxy.company.com:8080
282
- # - HTTPS_PROXY=http://proxy.company.com:8080
283
- # - NO_PROXY=localhost,127.0.0.1,.local,.internal
153
+ # Add optional variables as needed — see CONFIGURATION.md
284
154
  ```
285
155
 
286
- Then configure your MCP client:
156
+ MCP client config:
287
157
 
288
158
  ```json
289
159
  {
@@ -296,9 +166,12 @@ Then configure your MCP client:
296
166
  }
297
167
  ```
298
168
 
299
- ### HTTP Transport (Optional)
169
+ </details>
300
170
 
301
- The server supports both STDIO (default) and HTTP transports. Set `MCP_HTTP_PORT` to enable HTTP mode.
171
+ <details>
172
+ <summary>HTTP Transport</summary>
173
+
174
+ By default the server uses STDIO. Set `MCP_HTTP_PORT` to enable HTTP mode:
302
175
 
303
176
  ```json
304
177
  {
@@ -314,23 +187,28 @@ The server supports both STDIO (default) and HTTP transports. Set `MCP_HTTP_PORT
314
187
  }
315
188
  ```
316
189
 
317
- **HTTP Endpoints:**
318
- - **MCP Protocol**: `POST/GET/DELETE /mcp`
319
- - **Health Check**: `GET /health`
190
+ **Endpoints:** `POST/GET/DELETE /mcp` (MCP protocol), `GET /health` (health check)
191
+
192
+ **Test it:**
320
193
 
321
- **Testing:**
322
194
  ```bash
323
195
  MCP_HTTP_PORT=3000 SEARXNG_URL=http://localhost:8080 mcp-searxng
324
196
  curl http://localhost:3000/health
325
197
  ```
326
198
 
327
- ## Troubleshooting
199
+ </details>
200
+
201
+ ## Configuration
328
202
 
329
- ### 403 Forbidden Error from SearXNG
203
+ Set `SEARXNG_URL` to your SearXNG instance URL. All other variables are optional.
330
204
 
331
- If you receive a `403 Forbidden` error when using `mcp-searxng`, it is likely because your SearXNG instance does not have JSON format enabled. This server requests results in JSON format (`format=json`), which must be explicitly allowed in SearXNG's configuration.
205
+ Full environment variable reference: [CONFIGURATION.md](CONFIGURATION.md)
206
+
207
+ ## Troubleshooting
332
208
 
333
- **To fix this**, edit your SearXNG `settings.yml` (commonly located at `/etc/searxng/settings.yml`) and add `json` to the list of allowed formats:
209
+ ### 403 Forbidden from SearXNG
210
+
211
+ Your SearXNG instance likely has JSON format disabled. Edit `settings.yml` (usually `/etc/searxng/settings.yml`):
334
212
 
335
213
  ```yaml
336
214
  search:
@@ -339,88 +217,20 @@ search:
339
217
  - json
340
218
  ```
341
219
 
342
- After saving the file, restart your SearXNG instance. For example, if running with Docker:
343
-
344
- ```bash
345
- docker restart searxng
346
- ```
347
-
348
- You can verify JSON format is working by running:
220
+ Restart SearXNG (`docker restart searxng`) then verify:
349
221
 
350
222
  ```bash
351
223
  curl 'http://localhost:8080/search?q=test&format=json'
352
224
  ```
353
225
 
354
- You should receive a JSON response. If you still get a 403 error, double-check that:
355
- - The `settings.yml` file is correctly mounted into your Docker container
356
- - The YAML indentation is correct
357
- - The SearXNG instance was fully restarted after the configuration change
358
-
359
- For more details, see the [SearXNG settings documentation](https://docs.searxng.org/admin/settings/settings.html) and [this discussion](https://github.com/searxng/searxng/discussions/1789).
360
-
361
- ## Running evals
362
-
363
- ```bash
364
- SEARXNG_URL=YOUR_URL OPENAI_API_KEY=your-key npx mcp-eval evals.ts src/index.ts
365
- ```
226
+ You should receive a JSON response. If not, confirm the file is correctly mounted and YAML indentation is valid.
366
227
 
367
- ## For Developers
368
-
369
- ### Contributing
370
-
371
- We welcome contributions! Follow these guidelines:
372
-
373
- **Coding Standards:**
374
- - Use TypeScript with strict type safety
375
- - Follow existing error handling patterns
376
- - Write concise, informative error messages
377
- - Include unit tests for new functionality
378
- - Maintain 90%+ test coverage
379
- - Test with MCP inspector before submitting
380
- - Run evals to verify functionality
381
-
382
- **Workflow:**
383
-
384
- 1. **Fork and clone:**
385
- ```bash
386
- git clone https://github.com/YOUR_USERNAME/mcp-searxng.git
387
- cd mcp-searxng
388
- git remote add upstream https://github.com/ihor-sokoliuk/mcp-searxng.git
389
- ```
390
-
391
- 2. **Setup:**
392
- ```bash
393
- npm install
394
- npm run watch # Development mode with file watching
395
- ```
396
-
397
- 3. **Development:**
398
- ```bash
399
- git checkout -b feature/your-feature-name
400
- # Make changes in src/
401
- npm run build
402
- npm test
403
- npm run test:coverage
404
- npm run inspector
405
- ```
406
-
407
- 4. **Submit:**
408
- ```bash
409
- git commit -m "feat: description"
410
- git push origin feature/your-feature-name
411
- # Create PR on GitHub
412
- ```
413
-
414
- ### Testing
228
+ See also: [SearXNG settings docs](https://docs.searxng.org/admin/settings/settings.html) · [discussion](https://github.com/searxng/searxng/discussions/1789)
415
229
 
416
- ```bash
417
- npm test # Run all tests
418
- npm run test:coverage # Generate coverage report
419
- npm run test:watch # Watch mode
420
- ```
230
+ ## Contributing
421
231
 
422
- **Coverage:** 100% success rate with comprehensive unit tests covering error handling, types, proxy configs, resources, and logging.
232
+ See [CONTRIBUTING.md](CONTRIBUTING.md)
423
233
 
424
234
  ## License
425
235
 
426
- This MCP server is licensed under the MIT License. This means you are free to use, modify, and distribute the software, subject to the terms and conditions of the MIT License. For more details, please see the LICENSE file in the project repository.
236
+ MIT see [LICENSE](LICENSE) for details.
package/dist/cache.d.ts CHANGED
@@ -7,7 +7,7 @@ declare class SimpleCache {
7
7
  private cache;
8
8
  private readonly ttlMs;
9
9
  private cleanupInterval;
10
- constructor(ttlMs?: number);
10
+ constructor(ttlMs?: number, cleanupIntervalMs?: number);
11
11
  private startCleanup;
12
12
  private cleanupExpired;
13
13
  get(url: string): CacheEntry | null;
package/dist/cache.js CHANGED
@@ -2,15 +2,15 @@ class SimpleCache {
2
2
  cache = new Map();
3
3
  ttlMs;
4
4
  cleanupInterval = null;
5
- constructor(ttlMs = 60000) {
5
+ constructor(ttlMs = 60000, cleanupIntervalMs = 30000) {
6
6
  this.ttlMs = ttlMs;
7
- this.startCleanup();
7
+ this.startCleanup(cleanupIntervalMs);
8
8
  }
9
- startCleanup() {
10
- // Clean up expired entries every 30 seconds
9
+ startCleanup(cleanupIntervalMs) {
10
+ // Clean up expired entries every cleanupIntervalMs milliseconds (default 30s)
11
11
  this.cleanupInterval = setInterval(() => {
12
12
  this.cleanupExpired();
13
- }, 30000);
13
+ }, cleanupIntervalMs);
14
14
  }
15
15
  cleanupExpired() {
16
16
  const now = Date.now();
@@ -20,6 +20,7 @@ export declare function createJSONError(responseText: string, context: ErrorCont
20
20
  export declare function createDataError(data: any, context: ErrorContext): MCPSearXNGError;
21
21
  export declare function createNoResultsMessage(query: string): string;
22
22
  export declare function createURLFormatError(url: string): MCPSearXNGError;
23
+ export declare function createURLSecurityPolicyError(url: string): MCPSearXNGError;
23
24
  export declare function createContentError(message: string, url: string): MCPSearXNGError;
24
25
  export declare function createConversionError(error: any, url: string, htmlContent: string): MCPSearXNGError;
25
26
  export declare function createTimeoutError(timeout: number, url: string): MCPSearXNGError;
@@ -11,6 +11,33 @@ export class MCPSearXNGError extends Error {
11
11
  export function createConfigurationError(message) {
12
12
  return new MCPSearXNGError(`🔧 Configuration Error: ${message}`);
13
13
  }
14
+ const TLS_ERROR_CODES = new Set([
15
+ 'UNABLE_TO_GET_ISSUER_CERT_LOCALLY', 'UNABLE_TO_VERIFY_LEAF_SIGNATURE',
16
+ 'CERT_UNTRUSTED', 'CERT_HAS_EXPIRED', 'DEPTH_ZERO_SELF_SIGNED_CERT',
17
+ 'SELF_SIGNED_CERT_IN_CHAIN', 'UNABLE_TO_GET_ISSUER_CERT',
18
+ 'CERT_CHAIN_TOO_LONG', 'INVALID_CA',
19
+ ]);
20
+ function isTLSError(error) {
21
+ if (TLS_ERROR_CODES.has(error?.code))
22
+ return true;
23
+ if (TLS_ERROR_CODES.has(error?.cause?.code))
24
+ return true;
25
+ if (error?.message?.includes('certificate'))
26
+ return true;
27
+ if (error?.cause?.message?.includes('certificate'))
28
+ return true;
29
+ return false;
30
+ }
31
+ function getTLSRemediationMessage() {
32
+ const { platform } = process;
33
+ if (platform === 'win32') {
34
+ return 'Set NODE_EXTRA_CA_CERTS=C:\\path\\to\\ca-bundle.pem before starting the server.';
35
+ }
36
+ if (platform === 'darwin') {
37
+ return 'Run: sudo security add-trusted-cert -d -r trustRoot -k /Library/Keychains/System.keychain /path/to/ca.crt';
38
+ }
39
+ return 'Run: sudo cp /path/to/ca.crt /usr/local/share/ca-certificates/ && sudo update-ca-certificates';
40
+ }
14
41
  export function createNetworkError(error, context) {
15
42
  const target = context.searxngUrl ? 'SearXNG server' : 'website';
16
43
  if (error.code === 'ECONNREFUSED') {
@@ -23,8 +50,10 @@ export function createNetworkError(error, context) {
23
50
  if (error.code === 'ETIMEDOUT') {
24
51
  return new MCPSearXNGError(`🌐 Timeout Error: ${target} is too slow to respond`);
25
52
  }
26
- if (error.message?.includes('certificate')) {
27
- return new MCPSearXNGError(`🌐 SSL Error: Certificate problem with ${target}`);
53
+ if (isTLSError(error)) {
54
+ const causeCode = error?.cause?.code || error?.code || 'CERT_ERROR';
55
+ return new MCPSearXNGError(`🔒 SSL/TLS Error: Certificate verification failed for ${target} (${causeCode}). ` +
56
+ getTLSRemediationMessage());
28
57
  }
29
58
  // For generic fetch failures, provide root cause guidance
30
59
  const errorMsg = error.message || error.code || 'Connection failed';
@@ -67,6 +96,10 @@ export function createNoResultsMessage(query) {
67
96
  export function createURLFormatError(url) {
68
97
  return new MCPSearXNGError(`🔧 URL Format Error: Invalid URL "${url}"`);
69
98
  }
99
+ export function createURLSecurityPolicyError(url) {
100
+ return new MCPSearXNGError(`🔒 URL blocked by security policy: ${url}. ` +
101
+ "Enable MCP_HTTP_ALLOW_PRIVATE_URLS=true only if internal URL reads are intentional.");
102
+ }
70
103
  export function createContentError(message, url) {
71
104
  return new MCPSearXNGError(`📄 Content Error: ${message} (${url})`);
72
105
  }
@@ -0,0 +1,15 @@
1
+ export interface HttpSecurityConfig {
2
+ harden: boolean;
3
+ requireAuth: boolean;
4
+ authToken?: string;
5
+ restrictOrigins: boolean;
6
+ allowedOrigins: string[];
7
+ enableDnsRebindingProtection: boolean;
8
+ allowedHosts: string[];
9
+ exposeFullConfig: boolean;
10
+ allowPrivateUrls: boolean;
11
+ }
12
+ export declare function getHttpSecurityConfig(): HttpSecurityConfig;
13
+ export declare function validateHttpSecurityConfig(config: HttpSecurityConfig): void;
14
+ export declare function isRequestAuthorized(headerValue: string | undefined, config: HttpSecurityConfig): boolean;
15
+ export declare function isOriginAllowed(origin: string | undefined, config: HttpSecurityConfig): boolean;
@@ -0,0 +1,52 @@
1
+ function isEnabled(value) {
2
+ return value === "true";
3
+ }
4
+ function parseCsv(value) {
5
+ return (value || "")
6
+ .split(",")
7
+ .map((item) => item.trim())
8
+ .filter(Boolean);
9
+ }
10
+ export function getHttpSecurityConfig() {
11
+ const harden = isEnabled(process.env.MCP_HTTP_HARDEN);
12
+ const authToken = process.env.MCP_HTTP_AUTH_TOKEN;
13
+ const allowedOrigins = parseCsv(process.env.MCP_HTTP_ALLOWED_ORIGINS);
14
+ const allowedHosts = parseCsv(process.env.MCP_HTTP_ALLOWED_HOSTS);
15
+ return {
16
+ harden,
17
+ requireAuth: harden,
18
+ authToken,
19
+ restrictOrigins: harden,
20
+ allowedOrigins,
21
+ enableDnsRebindingProtection: harden,
22
+ allowedHosts: allowedHosts.length > 0 ? allowedHosts : ["127.0.0.1", "localhost"],
23
+ exposeFullConfig: isEnabled(process.env.MCP_HTTP_EXPOSE_FULL_CONFIG),
24
+ allowPrivateUrls: isEnabled(process.env.MCP_HTTP_ALLOW_PRIVATE_URLS),
25
+ };
26
+ }
27
+ export function validateHttpSecurityConfig(config) {
28
+ if (!config.harden) {
29
+ return;
30
+ }
31
+ if (!config.authToken) {
32
+ throw new Error("MCP_HTTP_HARDEN=true requires MCP_HTTP_AUTH_TOKEN to be set.");
33
+ }
34
+ if (config.allowedOrigins.length === 0) {
35
+ throw new Error("MCP_HTTP_HARDEN=true requires MCP_HTTP_ALLOWED_ORIGINS to be set.");
36
+ }
37
+ }
38
+ export function isRequestAuthorized(headerValue, config) {
39
+ if (!config.requireAuth) {
40
+ return true;
41
+ }
42
+ return headerValue === `Bearer ${config.authToken}` || headerValue === config.authToken;
43
+ }
44
+ export function isOriginAllowed(origin, config) {
45
+ if (!config.restrictOrigins) {
46
+ return true;
47
+ }
48
+ if (!origin) {
49
+ return true;
50
+ }
51
+ return config.allowedOrigins.includes(origin);
52
+ }
@@ -5,19 +5,42 @@ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/
5
5
  import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
6
6
  import { logMessage } from "./logging.js";
7
7
  import { packageVersion } from "./index.js";
8
+ import { getHttpSecurityConfig, isOriginAllowed, isRequestAuthorized, validateHttpSecurityConfig, } from "./http-security.js";
8
9
  export async function createHttpServer(mcpServer) {
9
10
  const app = express();
11
+ const security = getHttpSecurityConfig();
12
+ validateHttpSecurityConfig(security);
10
13
  app.use(express.json());
11
14
  // Add CORS support for web clients
12
15
  app.use(cors({
13
- origin: '*', // Configure appropriately for production
14
- exposedHeaders: ['Mcp-Session-Id'],
15
- allowedHeaders: ['Content-Type', 'mcp-session-id'],
16
+ origin: (origin, callback) => {
17
+ if (isOriginAllowed(origin || undefined, security)) {
18
+ callback(null, true);
19
+ return;
20
+ }
21
+ callback(null, false);
22
+ },
23
+ exposedHeaders: ["Mcp-Session-Id"],
24
+ allowedHeaders: ["Content-Type", "mcp-session-id", "authorization"],
16
25
  }));
26
+ function rejectUnauthorized(res) {
27
+ res.status(401).json({
28
+ jsonrpc: "2.0",
29
+ error: {
30
+ code: -32001,
31
+ message: "Unauthorized: missing or invalid HTTP auth token",
32
+ },
33
+ id: null,
34
+ });
35
+ }
17
36
  // Map to store transports by session ID
18
37
  const transports = {};
19
38
  // Handle POST requests for client-to-server communication
20
39
  app.post('/mcp', async (req, res) => {
40
+ if (!isRequestAuthorized(req.headers.authorization, security)) {
41
+ rejectUnauthorized(res);
42
+ return;
43
+ }
21
44
  const sessionId = req.headers['mcp-session-id'];
22
45
  let transport;
23
46
  if (sessionId && transports[sessionId]) {
@@ -34,10 +57,9 @@ export async function createHttpServer(mcpServer) {
34
57
  transports[sessionId] = transport;
35
58
  logMessage(mcpServer, "debug", `Session initialized: ${sessionId}`);
36
59
  },
37
- // DNS rebinding protection disabled by default for backwards compatibility
38
- // For production, consider enabling:
39
- // enableDnsRebindingProtection: true,
40
- // allowedHosts: ['127.0.0.1', 'localhost'],
60
+ enableDnsRebindingProtection: security.enableDnsRebindingProtection,
61
+ allowedHosts: security.allowedHosts,
62
+ allowedOrigins: security.allowedOrigins,
41
63
  });
42
64
  // Clean up transport when closed
43
65
  transport.onclose = () => {
@@ -89,6 +111,10 @@ export async function createHttpServer(mcpServer) {
89
111
  });
90
112
  // Handle GET requests for server-to-client notifications via SSE
91
113
  app.get('/mcp', async (req, res) => {
114
+ if (!isRequestAuthorized(req.headers.authorization, security)) {
115
+ rejectUnauthorized(res);
116
+ return;
117
+ }
92
118
  const sessionId = req.headers['mcp-session-id'];
93
119
  if (!sessionId || !transports[sessionId]) {
94
120
  console.warn(`⚠️ GET request rejected - missing or invalid session ID:`, {
@@ -114,6 +140,10 @@ export async function createHttpServer(mcpServer) {
114
140
  });
115
141
  // Handle DELETE requests for session termination
116
142
  app.delete('/mcp', async (req, res) => {
143
+ if (!isRequestAuthorized(req.headers.authorization, security)) {
144
+ rejectUnauthorized(res);
145
+ return;
146
+ }
117
147
  const sessionId = req.headers['mcp-session-id'];
118
148
  if (!sessionId || !transports[sessionId]) {
119
149
  console.warn(`⚠️ DELETE request rejected - missing or invalid session ID:`, {
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- declare const packageVersion = "0.10.5";
2
+ declare const packageVersion = "1.0.2";
3
3
  export { packageVersion };
4
4
  export declare function isWebUrlReadArgs(args: unknown): args is {
5
5
  url: string;
package/dist/index.js CHANGED
@@ -9,9 +9,8 @@ import { performWebSearch } from "./search.js";
9
9
  import { fetchAndConvertToMarkdown } from "./url-reader.js";
10
10
  import { createConfigResource, createHelpResource } from "./resources.js";
11
11
  import { createHttpServer } from "./http-server.js";
12
- import { validateEnvironment as validateEnv } from "./error-handler.js";
13
12
  // Use a static version string that will be updated by the version script
14
- const packageVersion = "0.10.5";
13
+ const packageVersion = "1.0.2";
15
14
  // Export the version for use in other modules
16
15
  export { packageVersion };
17
16
  // Global state for logging level
@@ -150,6 +149,7 @@ server.setRequestHandler(ListResourcesRequestSchema, async () => {
150
149
  };
151
150
  });
152
151
  // List resource templates handler
152
+ // Returns empty list — required by MCP spec even when no templates exist
153
153
  server.setRequestHandler(ListResourceTemplatesRequestSchema, async () => {
154
154
  logMessage(mcpServer, "debug", "Handling list_resource_templates request");
155
155
  return { resourceTemplates: [] };
@@ -185,12 +185,6 @@ server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
185
185
  });
186
186
  // Main function
187
187
  async function main() {
188
- // Environment validation
189
- const validationError = validateEnv();
190
- if (validationError) {
191
- console.error(`❌ ${validationError}`);
192
- process.exit(1);
193
- }
194
188
  // Check for HTTP transport mode
195
189
  const httpPort = process.env.MCP_HTTP_PORT;
196
190
  if (httpPort) {
@@ -222,8 +216,12 @@ async function main() {
222
216
  // Show helpful message when running in terminal
223
217
  if (process.stdin.isTTY) {
224
218
  console.error(`🔍 MCP SearXNG Server v${packageVersion} - Ready`);
225
- console.error("✅ Configuration valid");
226
- console.error(`🌐 SearXNG URL: ${process.env.SEARXNG_URL}`);
219
+ if (process.env.SEARXNG_URL) {
220
+ console.error(`🌐 SearXNG URL: ${process.env.SEARXNG_URL}`);
221
+ }
222
+ else {
223
+ console.error("⚠️ SEARXNG_URL not set — configure it before using search tools");
224
+ }
227
225
  console.error("📡 Waiting for MCP client connection via STDIO...\n");
228
226
  }
229
227
  const transport = new StdioServerTransport();
package/dist/logging.js CHANGED
@@ -1,29 +1,25 @@
1
1
  // Logging state
2
2
  let currentLogLevel = "info";
3
+ // Shared handler for sendLoggingMessage errors
4
+ function handleSendError(error) {
5
+ if (error instanceof Error && error.message !== "Not connected") {
6
+ console.error("Logging error:", error);
7
+ }
8
+ }
3
9
  // Logging helper function
4
10
  export function logMessage(mcpServer, level, message, data) {
5
11
  if (shouldLog(level)) {
6
12
  try {
7
- // Merge message and data together for the notification body
8
13
  const notificationData = data !== undefined
9
14
  ? (typeof data === 'object' && data !== null ? { message, ...data } : { message, data })
10
15
  : { message };
11
16
  mcpServer.sendLoggingMessage({
12
17
  level,
13
18
  data: notificationData
14
- }).catch((error) => {
15
- // Silently ignore "Not connected" errors during server startup
16
- // This can happen when logging occurs before the transport is fully connected
17
- if (error instanceof Error && error.message !== "Not connected") {
18
- console.error("Logging error:", error);
19
- }
20
- });
19
+ }).catch(handleSendError);
21
20
  }
22
21
  catch (error) {
23
- // Handle synchronous errors as well
24
- if (error instanceof Error && error.message !== "Not connected") {
25
- console.error("Logging error:", error);
26
- }
22
+ handleSendError(error);
27
23
  }
28
24
  }
29
25
  }
package/dist/proxy.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { ProxyAgent } from "undici";
1
+ import { Agent, ProxyAgent } from "undici";
2
2
  /**
3
3
  * Proxy configuration type for separating search and URL reader proxies.
4
4
  */
@@ -37,3 +37,4 @@ export type ProxyType = typeof ProxyType[keyof typeof ProxyType];
37
37
  * @returns ProxyAgent dispatcher for fetch, or undefined if no proxy configured or bypassed
38
38
  */
39
39
  export declare function createProxyAgent(targetUrl?: string, type?: ProxyType): ProxyAgent | undefined;
40
+ export declare function createDefaultAgent(): Agent | undefined;
package/dist/proxy.js CHANGED
@@ -1,4 +1,5 @@
1
- import { ProxyAgent } from "undici";
1
+ import { Agent, ProxyAgent } from "undici";
2
+ import { getConnectOptions } from "./tls-config.js";
2
3
  /**
3
4
  * Checks if a target URL should bypass the proxy based on NO_PROXY environment variable.
4
5
  *
@@ -187,5 +188,28 @@ export function createProxyAgent(targetUrl, type) {
187
188
  '';
188
189
  const normalizedProxyUrl = `${parsedProxyUrl.protocol}//${auth}${parsedProxyUrl.host}`;
189
190
  // Create and return Undici ProxyAgent compatible with fetch's dispatcher option
190
- return new ProxyAgent(normalizedProxyUrl);
191
+ return new ProxyAgent({ uri: normalizedProxyUrl, connect: getConnectOptions() });
192
+ }
193
+ /**
194
+ * Returns a singleton undici Agent with system CA certificates in the connect
195
+ * options. Used as a dispatcher when no proxy is configured, to ensure
196
+ * undici's fetch uses system CAs instead of only Node's compiled-in bundle.
197
+ *
198
+ * The agent (and the CA bundle disk read) is created once and reused across
199
+ * requests to avoid repeated synchronous I/O and connection pool proliferation.
200
+ *
201
+ * Returns undefined if no system CA bundle is found — callers should treat
202
+ * undefined as "use Node's default behavior".
203
+ */
204
+ let _defaultAgentInitialized = false;
205
+ let _defaultAgent;
206
+ export function createDefaultAgent() {
207
+ if (!_defaultAgentInitialized) {
208
+ _defaultAgentInitialized = true;
209
+ const connectOpts = getConnectOptions();
210
+ if (Object.keys(connectOpts).length > 0) {
211
+ _defaultAgent = new Agent({ connect: connectOpts });
212
+ }
213
+ }
214
+ return _defaultAgent;
191
215
  }
package/dist/resources.js CHANGED
@@ -1,6 +1,9 @@
1
1
  import { getCurrentLogLevel } from "./logging.js";
2
2
  import { packageVersion } from "./index.js";
3
+ import { getHttpSecurityConfig } from "./http-security.js";
3
4
  export function createConfigResource() {
5
+ const security = getHttpSecurityConfig();
6
+ const showFullConfig = !security.harden || security.exposeFullConfig;
4
7
  const config = {
5
8
  serverInfo: {
6
9
  name: "ihor-sokoliuk/mcp-searxng",
@@ -8,7 +11,9 @@ export function createConfigResource() {
8
11
  description: "MCP server for SearXNG integration"
9
12
  },
10
13
  environment: {
11
- searxngUrl: process.env.SEARXNG_URL || "(not configured)",
14
+ ...(showFullConfig
15
+ ? { searxngUrl: process.env.SEARXNG_URL || "(not configured)" }
16
+ : { searxngUrlConfigured: !!process.env.SEARXNG_URL }),
12
17
  hasAuth: !!(process.env.AUTH_USERNAME && process.env.AUTH_PASSWORD),
13
18
  hasProxy: !!(process.env.HTTP_PROXY || process.env.HTTPS_PROXY || process.env.http_proxy || process.env.https_proxy),
14
19
  hasNoProxy: !!(process.env.NO_PROXY || process.env.no_proxy),
@@ -67,6 +72,13 @@ Standard input/output transport for desktop clients like Claude Desktop.
67
72
  ### HTTP (Optional)
68
73
  RESTful HTTP transport for web applications. Set \`MCP_HTTP_PORT\` to enable.
69
74
 
75
+ ### Hardened HTTP Mode (Optional)
76
+ Default behavior remains compatible for existing deployments.
77
+ For network-exposed HTTP transport, enable:
78
+ - \`MCP_HTTP_HARDEN\`
79
+ - \`MCP_HTTP_AUTH_TOKEN\`
80
+ - \`MCP_HTTP_ALLOWED_ORIGINS\`
81
+
70
82
  ## Usage Examples
71
83
 
72
84
  ### Search for recent news
package/dist/search.js CHANGED
@@ -1,6 +1,6 @@
1
- import { createProxyAgent, ProxyType } from "./proxy.js";
1
+ import { createProxyAgent, createDefaultAgent, ProxyType } from "./proxy.js";
2
2
  import { logMessage } from "./logging.js";
3
- import { createConfigurationError, createNetworkError, createServerError, createJSONError, createDataError, createNoResultsMessage } from "./error-handler.js";
3
+ import { MCPSearXNGError, validateEnvironment, createNetworkError, createServerError, createJSONError, createDataError, createNoResultsMessage } from "./error-handler.js";
4
4
  export async function performWebSearch(mcpServer, query, pageno = 1, time_range, language = "all", safesearch) {
5
5
  const startTime = Date.now();
6
6
  // Build detailed log message with all parameters
@@ -11,19 +11,13 @@ export async function performWebSearch(mcpServer, query, pageno = 1, time_range,
11
11
  safesearch ? `safesearch: ${safesearch}` : null
12
12
  ].filter(Boolean).join(", ");
13
13
  logMessage(mcpServer, "info", `Starting web search: "${query}" (${searchParams})`);
14
- const searxngUrl = process.env.SEARXNG_URL;
15
- if (!searxngUrl) {
16
- logMessage(mcpServer, "error", "SEARXNG_URL not configured");
17
- throw createConfigurationError("SEARXNG_URL not set. Set it to your SearXNG instance (e.g., http://localhost:8080 or https://search.example.com)");
18
- }
19
- // Validate that searxngUrl is a valid URL
20
- let parsedUrl;
21
- try {
22
- parsedUrl = new URL(searxngUrl.endsWith('/') ? searxngUrl : searxngUrl + '/');
23
- }
24
- catch (error) {
25
- throw createConfigurationError(`Invalid SEARXNG_URL format: ${searxngUrl}. Use format: http://localhost:8080`);
14
+ const validationError = validateEnvironment();
15
+ if (validationError) {
16
+ logMessage(mcpServer, "error", "Configuration invalid");
17
+ throw new MCPSearXNGError(validationError);
26
18
  }
19
+ const searxngUrl = process.env.SEARXNG_URL;
20
+ const parsedUrl = new URL(searxngUrl.endsWith('/') ? searxngUrl : searxngUrl + '/');
27
21
  const url = new URL('search', parsedUrl);
28
22
  url.searchParams.set("q", query);
29
23
  url.searchParams.set("format", "json");
@@ -42,11 +36,11 @@ export async function performWebSearch(mcpServer, query, pageno = 1, time_range,
42
36
  const requestOptions = {
43
37
  method: "GET"
44
38
  };
45
- // Add proxy dispatcher if proxy is configured
46
- // Node.js fetch uses 'dispatcher' option for proxy, not 'agent'
39
+ // Add proxy or default dispatcher (includes system CA certs for TLS)
47
40
  const proxyAgent = createProxyAgent(url.toString(), ProxyType.SEARCH);
48
- if (proxyAgent) {
49
- requestOptions.dispatcher = proxyAgent;
41
+ const dispatcher = proxyAgent ?? createDefaultAgent();
42
+ if (dispatcher) {
43
+ requestOptions.dispatcher = dispatcher;
50
44
  }
51
45
  // Add basic authentication if credentials are provided
52
46
  const username = process.env.AUTH_USERNAME;
@@ -77,7 +71,7 @@ export async function performWebSearch(mcpServer, query, pageno = 1, time_range,
77
71
  const context = {
78
72
  url: url.toString(),
79
73
  searxngUrl,
80
- proxyAgent: !!proxyAgent,
74
+ proxyAgent: !!dispatcher,
81
75
  username
82
76
  };
83
77
  throw createNetworkError(error, context);
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Reads system CA certificates from well-known bundle paths.
3
+ * Returns null on Windows (no universal file path) or if no bundle is found.
4
+ *
5
+ * On Windows, users should set NODE_EXTRA_CA_CERTS pointing to a PEM file.
6
+ */
7
+ export declare function getSystemCACerts(): string | null;
8
+ /**
9
+ * Returns undici `connect` options with system CA certs, or an empty object
10
+ * if no system CA bundle is found (undici uses Node's compiled-in Mozilla
11
+ * bundle in that case).
12
+ *
13
+ * Usage:
14
+ * new Agent({ connect: getConnectOptions() })
15
+ * new ProxyAgent({ uri: proxyUrl, connect: getConnectOptions() })
16
+ */
17
+ export declare function getConnectOptions(): {
18
+ ca: string;
19
+ } | Record<string, never>;
@@ -0,0 +1,49 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { platform } from "node:process";
3
+ /**
4
+ * Ordered list of well-known system CA bundle paths.
5
+ * Checked in order; first path that exists and is readable wins.
6
+ */
7
+ const CA_BUNDLE_PATHS = [
8
+ "/etc/ssl/certs/ca-certificates.crt", // Debian/Ubuntu/WSL2
9
+ "/etc/pki/tls/certs/ca-bundle.crt", // RHEL/CentOS/Fedora
10
+ "/etc/ssl/ca-bundle.pem", // OpenSUSE
11
+ "/etc/ssl/cert.pem", // Alpine, macOS
12
+ ];
13
+ /**
14
+ * Reads system CA certificates from well-known bundle paths.
15
+ * Returns null on Windows (no universal file path) or if no bundle is found.
16
+ *
17
+ * On Windows, users should set NODE_EXTRA_CA_CERTS pointing to a PEM file.
18
+ */
19
+ export function getSystemCACerts() {
20
+ // Windows has no universal CA bundle path; skip auto-detection
21
+ if (platform === "win32") {
22
+ return null;
23
+ }
24
+ for (const caPath of CA_BUNDLE_PATHS) {
25
+ if (existsSync(caPath)) {
26
+ try {
27
+ return readFileSync(caPath, "utf8");
28
+ }
29
+ catch {
30
+ // File exists but is unreadable (permissions); try next
31
+ continue;
32
+ }
33
+ }
34
+ }
35
+ return null;
36
+ }
37
+ /**
38
+ * Returns undici `connect` options with system CA certs, or an empty object
39
+ * if no system CA bundle is found (undici uses Node's compiled-in Mozilla
40
+ * bundle in that case).
41
+ *
42
+ * Usage:
43
+ * new Agent({ connect: getConnectOptions() })
44
+ * new ProxyAgent({ uri: proxyUrl, connect: getConnectOptions() })
45
+ */
46
+ export function getConnectOptions() {
47
+ const ca = getSystemCACerts();
48
+ return ca !== null ? { ca } : {};
49
+ }
@@ -1,8 +1,54 @@
1
+ import { isIP } from "node:net";
1
2
  import { NodeHtmlMarkdown } from "node-html-markdown";
2
- import { createProxyAgent, ProxyType } from "./proxy.js";
3
+ import { createProxyAgent, createDefaultAgent, ProxyType } from "./proxy.js";
3
4
  import { logMessage } from "./logging.js";
4
5
  import { urlCache } from "./cache.js";
5
- import { createURLFormatError, createNetworkError, createServerError, createContentError, createConversionError, createTimeoutError, createEmptyContentWarning, createUnexpectedError } from "./error-handler.js";
6
+ import { getHttpSecurityConfig } from "./http-security.js";
7
+ import { createURLFormatError, createURLSecurityPolicyError, createNetworkError, createServerError, createContentError, createConversionError, createTimeoutError, createEmptyContentWarning, createUnexpectedError } from "./error-handler.js";
8
+ function isPrivateHostname(hostname) {
9
+ const lower = hostname.toLowerCase().replace(/\.+$/, "");
10
+ return lower === "localhost" || lower.endsWith(".localhost");
11
+ }
12
+ function isPrivateIpv4(hostname) {
13
+ if (isIP(hostname) !== 4) {
14
+ return false;
15
+ }
16
+ return (hostname.startsWith("10.") ||
17
+ hostname.startsWith("127.") ||
18
+ hostname.startsWith("192.168.") ||
19
+ /^172\.(1[6-9]|2\d|3[0-1])\./.test(hostname) ||
20
+ hostname.startsWith("169.254."));
21
+ }
22
+ function isPrivateIPv6(hostname) {
23
+ // url.hostname wraps IPv6 in brackets (e.g. "[::1]") — strip them first
24
+ const addr = (hostname.startsWith("[") && hostname.endsWith("]")
25
+ ? hostname.slice(1, -1)
26
+ : hostname).toLowerCase();
27
+ if (isIP(addr) !== 6)
28
+ return false;
29
+ if (addr === "::1")
30
+ return true; // loopback
31
+ if (addr === "::")
32
+ return true; // unspecified
33
+ if (/^f[cd]/i.test(addr))
34
+ return true; // ULA fc00::/7
35
+ if (/^fe[89ab][0-9a-f]:/i.test(addr))
36
+ return true; // link-local fe80::/10
37
+ // IPv4-mapped ::ffff:<ipv4> — delegate to the IPv4 check
38
+ const mapped = addr.match(/^::ffff:(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/);
39
+ if (mapped)
40
+ return isPrivateIpv4(mapped[1]);
41
+ return false;
42
+ }
43
+ function assertUrlAllowed(url) {
44
+ const security = getHttpSecurityConfig();
45
+ if (!security.harden || security.allowPrivateUrls) {
46
+ return;
47
+ }
48
+ if (isPrivateHostname(url.hostname) || isPrivateIpv4(url.hostname) || isPrivateIPv6(url.hostname)) {
49
+ throw createURLSecurityPolicyError(url.toString());
50
+ }
51
+ }
6
52
  function applyCharacterPagination(content, startChar = 0, maxLength) {
7
53
  if (startChar >= content.length) {
8
54
  return "";
@@ -121,6 +167,7 @@ export async function fetchAndConvertToMarkdown(mcpServer, url, timeoutMs = 1000
121
167
  logMessage(mcpServer, "error", `Invalid URL format: ${url}`);
122
168
  throw createURLFormatError(url);
123
169
  }
170
+ assertUrlAllowed(parsedUrl);
124
171
  // Create an AbortController instance
125
172
  const controller = new AbortController();
126
173
  const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
@@ -129,11 +176,11 @@ export async function fetchAndConvertToMarkdown(mcpServer, url, timeoutMs = 1000
129
176
  const requestOptions = {
130
177
  signal: controller.signal,
131
178
  };
132
- // Add proxy dispatcher if proxy is configured
133
- // Node.js fetch uses 'dispatcher' option for proxy, not 'agent'
179
+ // Add proxy or default dispatcher (includes system CA certs for TLS)
134
180
  const proxyAgent = createProxyAgent(url, ProxyType.URL_READER);
135
- if (proxyAgent) {
136
- requestOptions.dispatcher = proxyAgent;
181
+ const dispatcher = proxyAgent ?? createDefaultAgent();
182
+ if (dispatcher) {
183
+ requestOptions.dispatcher = dispatcher;
137
184
  }
138
185
  // Add User-Agent header if configured (URL_READER_USER_AGENT takes priority over USER_AGENT)
139
186
  const userAgent = process.env.URL_READER_USER_AGENT || process.env.USER_AGENT;
@@ -151,7 +198,7 @@ export async function fetchAndConvertToMarkdown(mcpServer, url, timeoutMs = 1000
151
198
  catch (error) {
152
199
  const context = {
153
200
  url,
154
- proxyAgent: !!proxyAgent,
201
+ proxyAgent: !!dispatcher,
155
202
  timeout: timeoutMs
156
203
  };
157
204
  throw createNetworkError(error, context);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mcp-searxng",
3
- "version": "0.10.5",
3
+ "version": "1.0.2",
4
4
  "mcpName": "io.github.ihor-sokoliuk/mcp-searxng",
5
5
  "description": "MCP server for SearXNG integration",
6
6
  "license": "MIT",
@@ -51,7 +51,7 @@
51
51
  "cors": "^2.8.6",
52
52
  "express": "^5.2.1",
53
53
  "node-html-markdown": "^2.0.0",
54
- "undici": "^7.0.0"
54
+ "undici": "^7.24.0"
55
55
  },
56
56
  "devDependencies": {
57
57
  "@types/node": "^22.17.2",