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 +72 -262
- package/dist/cache.d.ts +1 -1
- package/dist/cache.js +5 -5
- package/dist/error-handler.d.ts +1 -0
- package/dist/error-handler.js +35 -2
- package/dist/http-security.d.ts +15 -0
- package/dist/http-security.js +52 -0
- package/dist/http-server.js +37 -7
- package/dist/index.d.ts +1 -1
- package/dist/index.js +8 -10
- package/dist/logging.js +8 -12
- package/dist/proxy.d.ts +2 -1
- package/dist/proxy.js +26 -2
- package/dist/resources.js +13 -1
- package/dist/search.js +13 -19
- package/dist/tls-config.d.ts +19 -0
- package/dist/tls-config.js +49 -0
- package/dist/url-reader.js +54 -7
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# SearXNG MCP Server
|
|
2
2
|
|
|
3
|
-
An [MCP server](https://modelcontextprotocol.io/introduction)
|
|
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://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
|
-
##
|
|
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
|
-
|
|
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
|
-
|
|
19
|
-
|
|
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
|
-
##
|
|
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>
|
|
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
|
-
|
|
102
|
+
<details>
|
|
103
|
+
<summary>Docker</summary>
|
|
185
104
|
|
|
186
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
137
|
+
Use the same config above, replacing `isokoliuk/mcp-searxng:latest` with `mcp-searxng:latest`.
|
|
262
138
|
|
|
263
|
-
|
|
139
|
+
</details>
|
|
264
140
|
|
|
265
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
169
|
+
</details>
|
|
300
170
|
|
|
301
|
-
|
|
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
|
-
**
|
|
318
|
-
|
|
319
|
-
|
|
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
|
-
|
|
199
|
+
</details>
|
|
200
|
+
|
|
201
|
+
## Configuration
|
|
328
202
|
|
|
329
|
-
|
|
203
|
+
Set `SEARXNG_URL` to your SearXNG instance URL. All other variables are optional.
|
|
330
204
|
|
|
331
|
-
|
|
205
|
+
Full environment variable reference: [CONFIGURATION.md](CONFIGURATION.md)
|
|
206
|
+
|
|
207
|
+
## Troubleshooting
|
|
332
208
|
|
|
333
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
232
|
+
See [CONTRIBUTING.md](CONTRIBUTING.md)
|
|
423
233
|
|
|
424
234
|
## License
|
|
425
235
|
|
|
426
|
-
|
|
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
|
|
9
|
+
startCleanup(cleanupIntervalMs) {
|
|
10
|
+
// Clean up expired entries every cleanupIntervalMs milliseconds (default 30s)
|
|
11
11
|
this.cleanupInterval = setInterval(() => {
|
|
12
12
|
this.cleanupExpired();
|
|
13
|
-
},
|
|
13
|
+
}, cleanupIntervalMs);
|
|
14
14
|
}
|
|
15
15
|
cleanupExpired() {
|
|
16
16
|
const now = Date.now();
|
package/dist/error-handler.d.ts
CHANGED
|
@@ -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;
|
package/dist/error-handler.js
CHANGED
|
@@ -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
|
|
27
|
-
|
|
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
|
+
}
|
package/dist/http-server.js
CHANGED
|
@@ -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:
|
|
14
|
-
|
|
15
|
-
|
|
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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
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.
|
|
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
|
-
|
|
226
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
|
15
|
-
if (
|
|
16
|
-
logMessage(mcpServer, "error", "
|
|
17
|
-
throw
|
|
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
|
|
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
|
-
|
|
49
|
-
|
|
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: !!
|
|
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
|
+
}
|
package/dist/url-reader.js
CHANGED
|
@@ -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 {
|
|
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
|
|
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
|
-
|
|
136
|
-
|
|
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: !!
|
|
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.
|
|
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.
|
|
54
|
+
"undici": "^7.24.0"
|
|
55
55
|
},
|
|
56
56
|
"devDependencies": {
|
|
57
57
|
"@types/node": "^22.17.2",
|