@kassol/mcp-searxng 1.0.3-custom.0
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/LICENSE +21 -0
- package/README.md +255 -0
- package/dist/cache.d.ts +26 -0
- package/dist/cache.js +68 -0
- package/dist/error-handler.d.ts +29 -0
- package/dist/error-handler.js +148 -0
- package/dist/headers.d.ts +3 -0
- package/dist/headers.js +77 -0
- package/dist/http-security.d.ts +15 -0
- package/dist/http-security.js +52 -0
- package/dist/http-server.d.ts +3 -0
- package/dist/http-server.js +185 -0
- package/dist/index.d.ts +17 -0
- package/dist/index.js +252 -0
- package/dist/logging.d.ts +6 -0
- package/dist/logging.js +35 -0
- package/dist/proxy.d.ts +40 -0
- package/dist/proxy.js +215 -0
- package/dist/resources.d.ts +2 -0
- package/dist/resources.js +114 -0
- package/dist/search.d.ts +2 -0
- package/dist/search.js +133 -0
- package/dist/tls-config.d.ts +19 -0
- package/dist/tls-config.js +49 -0
- package/dist/types.d.ts +18 -0
- package/dist/types.js +87 -0
- package/dist/url-reader.d.ts +10 -0
- package/dist/url-reader.js +276 -0
- package/package.json +72 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2024 IS
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
# SearXNG MCP Server
|
|
2
|
+
|
|
3
|
+
An [MCP server](https://modelcontextprotocol.io/introduction) that integrates the [SearXNG](https://docs.searxng.org) API, giving AI assistants web search capabilities.
|
|
4
|
+
|
|
5
|
+
This fork is published as `@kassol/mcp-searxng` and includes support for custom outgoing headers via `SEARXNG_HEADERS` and `URL_READER_HEADERS`.
|
|
6
|
+
|
|
7
|
+
[](https://www.npmjs.com/package/mcp-searxng)
|
|
8
|
+
|
|
9
|
+
[](https://hub.docker.com/r/isokoliuk/mcp-searxng)
|
|
10
|
+
|
|
11
|
+
<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>
|
|
12
|
+
|
|
13
|
+
## Quick Start
|
|
14
|
+
|
|
15
|
+
Add to your MCP client configuration (e.g. `claude_desktop_config.json`):
|
|
16
|
+
|
|
17
|
+
```json
|
|
18
|
+
{
|
|
19
|
+
"mcpServers": {
|
|
20
|
+
"searxng": {
|
|
21
|
+
"command": "npx",
|
|
22
|
+
"args": ["-y", "@kassol/mcp-searxng"],
|
|
23
|
+
"env": {
|
|
24
|
+
"SEARXNG_URL": "YOUR_SEARXNG_INSTANCE_URL"
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Replace `YOUR_SEARXNG_INSTANCE_URL` with the URL of your SearXNG instance (e.g. `https://search.example.com`).
|
|
32
|
+
|
|
33
|
+
## Features
|
|
34
|
+
|
|
35
|
+
- **Web Search**: General queries, news, articles, with pagination.
|
|
36
|
+
- **URL Content Reading**: Advanced content extraction with pagination, section filtering, and heading extraction.
|
|
37
|
+
- **Intelligent Caching**: URL content is cached with TTL (Time-To-Live) to improve performance and reduce redundant requests.
|
|
38
|
+
- **Pagination**: Control which page of results to retrieve.
|
|
39
|
+
- **Time Filtering**: Filter results by time range (day, month, year).
|
|
40
|
+
- **Language Selection**: Filter results by preferred language.
|
|
41
|
+
- **Safe Search**: Control content filtering level for search results.
|
|
42
|
+
|
|
43
|
+
## How It Works
|
|
44
|
+
|
|
45
|
+
`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.
|
|
46
|
+
|
|
47
|
+
> **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`.
|
|
48
|
+
|
|
49
|
+
```
|
|
50
|
+
AI Assistant (e.g. Claude)
|
|
51
|
+
│ MCP protocol
|
|
52
|
+
▼
|
|
53
|
+
mcp-searxng (this project — Node.js process)
|
|
54
|
+
│ HTTP JSON API (SEARXNG_URL)
|
|
55
|
+
▼
|
|
56
|
+
SearXNG instance
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## Tools
|
|
60
|
+
|
|
61
|
+
- **searxng_web_search**
|
|
62
|
+
- Execute web searches with pagination
|
|
63
|
+
- Inputs:
|
|
64
|
+
- `query` (string): The search query. This string is passed to external search services.
|
|
65
|
+
- `pageno` (number, optional): Search page number, starts at 1 (default 1)
|
|
66
|
+
- `time_range` (string, optional): Filter results by time range - one of: "day", "month", "year" (default: none)
|
|
67
|
+
- `language` (string, optional): Language code for results (e.g., "en", "fr", "de") or "all" (default: "all")
|
|
68
|
+
- `safesearch` (number, optional): Safe search filter level (0: None, 1: Moderate, 2: Strict) (default: instance setting)
|
|
69
|
+
|
|
70
|
+
- **web_url_read**
|
|
71
|
+
- Read and convert the content from a URL to markdown with advanced content extraction options
|
|
72
|
+
- Inputs:
|
|
73
|
+
- `url` (string): The URL to fetch and process
|
|
74
|
+
- `startChar` (number, optional): Starting character position for content extraction (default: 0)
|
|
75
|
+
- `maxLength` (number, optional): Maximum number of characters to return
|
|
76
|
+
- `section` (string, optional): Extract content under a specific heading (searches for heading text)
|
|
77
|
+
- `paragraphRange` (string, optional): Return specific paragraph ranges (e.g., '1-5', '3', '10-')
|
|
78
|
+
- `readHeadings` (boolean, optional): Return only a list of headings instead of full content
|
|
79
|
+
|
|
80
|
+
## Installation
|
|
81
|
+
|
|
82
|
+
<details>
|
|
83
|
+
<summary>NPM (global install)</summary>
|
|
84
|
+
|
|
85
|
+
```bash
|
|
86
|
+
npm install -g @kassol/mcp-searxng
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
```json
|
|
90
|
+
{
|
|
91
|
+
"mcpServers": {
|
|
92
|
+
"searxng": {
|
|
93
|
+
"command": "mcp-searxng",
|
|
94
|
+
"env": {
|
|
95
|
+
"SEARXNG_URL": "YOUR_SEARXNG_INSTANCE_URL"
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
</details>
|
|
103
|
+
|
|
104
|
+
<details>
|
|
105
|
+
<summary>Docker</summary>
|
|
106
|
+
|
|
107
|
+
**Pre-built image:**
|
|
108
|
+
|
|
109
|
+
```bash
|
|
110
|
+
docker pull isokoliuk/mcp-searxng:latest
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
```json
|
|
114
|
+
{
|
|
115
|
+
"mcpServers": {
|
|
116
|
+
"searxng": {
|
|
117
|
+
"command": "docker",
|
|
118
|
+
"args": [
|
|
119
|
+
"run", "-i", "--rm",
|
|
120
|
+
"-e", "SEARXNG_URL",
|
|
121
|
+
"isokoliuk/mcp-searxng:latest"
|
|
122
|
+
],
|
|
123
|
+
"env": {
|
|
124
|
+
"SEARXNG_URL": "YOUR_SEARXNG_INSTANCE_URL"
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
To pass additional env vars, add `-e VAR_NAME` to `args` and the variable to `env`.
|
|
132
|
+
|
|
133
|
+
**Build locally:**
|
|
134
|
+
|
|
135
|
+
```bash
|
|
136
|
+
docker build -t mcp-searxng:latest -f Dockerfile .
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
Use the same config above, replacing `isokoliuk/mcp-searxng:latest` with `mcp-searxng:latest`.
|
|
140
|
+
|
|
141
|
+
</details>
|
|
142
|
+
|
|
143
|
+
<details>
|
|
144
|
+
<summary>Docker Compose</summary>
|
|
145
|
+
|
|
146
|
+
`docker-compose.yml`:
|
|
147
|
+
|
|
148
|
+
```yaml
|
|
149
|
+
services:
|
|
150
|
+
mcp-searxng:
|
|
151
|
+
image: isokoliuk/mcp-searxng:latest
|
|
152
|
+
stdin_open: true
|
|
153
|
+
environment:
|
|
154
|
+
- SEARXNG_URL=YOUR_SEARXNG_INSTANCE_URL
|
|
155
|
+
# Add optional variables as needed — see CONFIGURATION.md
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
MCP client config:
|
|
159
|
+
|
|
160
|
+
```json
|
|
161
|
+
{
|
|
162
|
+
"mcpServers": {
|
|
163
|
+
"searxng": {
|
|
164
|
+
"command": "docker-compose",
|
|
165
|
+
"args": ["run", "--rm", "mcp-searxng"]
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
</details>
|
|
172
|
+
|
|
173
|
+
<details>
|
|
174
|
+
<summary>HTTP Transport</summary>
|
|
175
|
+
|
|
176
|
+
By default the server uses STDIO. Set `MCP_HTTP_PORT` to enable HTTP mode:
|
|
177
|
+
|
|
178
|
+
```json
|
|
179
|
+
{
|
|
180
|
+
"mcpServers": {
|
|
181
|
+
"searxng-http": {
|
|
182
|
+
"command": "mcp-searxng",
|
|
183
|
+
"env": {
|
|
184
|
+
"SEARXNG_URL": "YOUR_SEARXNG_INSTANCE_URL",
|
|
185
|
+
"MCP_HTTP_PORT": "3000"
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
**Endpoints:** `POST/GET/DELETE /mcp` (MCP protocol), `GET /health` (health check)
|
|
193
|
+
|
|
194
|
+
**Test it:**
|
|
195
|
+
|
|
196
|
+
```bash
|
|
197
|
+
MCP_HTTP_PORT=3000 SEARXNG_URL=http://localhost:8080 mcp-searxng
|
|
198
|
+
curl http://localhost:3000/health
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
</details>
|
|
202
|
+
|
|
203
|
+
## Configuration
|
|
204
|
+
|
|
205
|
+
Set `SEARXNG_URL` to your SearXNG instance URL. All other variables are optional.
|
|
206
|
+
|
|
207
|
+
Protected SearXNG instances can receive extra search request headers through `SEARXNG_HEADERS`:
|
|
208
|
+
|
|
209
|
+
```json
|
|
210
|
+
{
|
|
211
|
+
"mcpServers": {
|
|
212
|
+
"searxng": {
|
|
213
|
+
"command": "npx",
|
|
214
|
+
"args": ["-y", "@kassol/mcp-searxng"],
|
|
215
|
+
"env": {
|
|
216
|
+
"SEARXNG_URL": "https://search.example.com",
|
|
217
|
+
"SEARXNG_HEADERS": "{\"CF-Access-Client-Id\":\"your-client-id.access\",\"CF-Access-Client-Secret\":\"your-client-secret\"}"
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
Full environment variable reference: [CONFIGURATION.md](CONFIGURATION.md)
|
|
225
|
+
|
|
226
|
+
## Troubleshooting
|
|
227
|
+
|
|
228
|
+
### 403 Forbidden from SearXNG
|
|
229
|
+
|
|
230
|
+
Your SearXNG instance likely has JSON format disabled. Edit `settings.yml` (usually `/etc/searxng/settings.yml`):
|
|
231
|
+
|
|
232
|
+
```yaml
|
|
233
|
+
search:
|
|
234
|
+
formats:
|
|
235
|
+
- html
|
|
236
|
+
- json
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
Restart SearXNG (`docker restart searxng`) then verify:
|
|
240
|
+
|
|
241
|
+
```bash
|
|
242
|
+
curl 'http://localhost:8080/search?q=test&format=json'
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
You should receive a JSON response. If not, confirm the file is correctly mounted and YAML indentation is valid.
|
|
246
|
+
|
|
247
|
+
See also: [SearXNG settings docs](https://docs.searxng.org/admin/settings/settings.html) · [discussion](https://github.com/searxng/searxng/discussions/1789)
|
|
248
|
+
|
|
249
|
+
## Contributing
|
|
250
|
+
|
|
251
|
+
See [CONTRIBUTING.md](CONTRIBUTING.md)
|
|
252
|
+
|
|
253
|
+
## License
|
|
254
|
+
|
|
255
|
+
MIT — see [LICENSE](LICENSE) for details.
|
package/dist/cache.d.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
interface CacheEntry {
|
|
2
|
+
htmlContent: string;
|
|
3
|
+
markdownContent: string;
|
|
4
|
+
timestamp: number;
|
|
5
|
+
}
|
|
6
|
+
declare class SimpleCache {
|
|
7
|
+
private cache;
|
|
8
|
+
private readonly ttlMs;
|
|
9
|
+
private cleanupInterval;
|
|
10
|
+
constructor(ttlMs?: number, cleanupIntervalMs?: number);
|
|
11
|
+
private startCleanup;
|
|
12
|
+
private cleanupExpired;
|
|
13
|
+
get(url: string): CacheEntry | null;
|
|
14
|
+
set(url: string, htmlContent: string, markdownContent: string): void;
|
|
15
|
+
clear(): void;
|
|
16
|
+
destroy(): void;
|
|
17
|
+
getStats(): {
|
|
18
|
+
size: number;
|
|
19
|
+
entries: Array<{
|
|
20
|
+
url: string;
|
|
21
|
+
age: number;
|
|
22
|
+
}>;
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
export declare const urlCache: SimpleCache;
|
|
26
|
+
export { SimpleCache };
|
package/dist/cache.js
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
class SimpleCache {
|
|
2
|
+
cache = new Map();
|
|
3
|
+
ttlMs;
|
|
4
|
+
cleanupInterval = null;
|
|
5
|
+
constructor(ttlMs = 60000, cleanupIntervalMs = 30000) {
|
|
6
|
+
this.ttlMs = ttlMs;
|
|
7
|
+
this.startCleanup(cleanupIntervalMs);
|
|
8
|
+
}
|
|
9
|
+
startCleanup(cleanupIntervalMs) {
|
|
10
|
+
// Clean up expired entries every cleanupIntervalMs milliseconds (default 30s)
|
|
11
|
+
this.cleanupInterval = setInterval(() => {
|
|
12
|
+
this.cleanupExpired();
|
|
13
|
+
}, cleanupIntervalMs);
|
|
14
|
+
}
|
|
15
|
+
cleanupExpired() {
|
|
16
|
+
const now = Date.now();
|
|
17
|
+
for (const [key, entry] of this.cache.entries()) {
|
|
18
|
+
if (now - entry.timestamp > this.ttlMs) {
|
|
19
|
+
this.cache.delete(key);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
get(url) {
|
|
24
|
+
const entry = this.cache.get(url);
|
|
25
|
+
if (!entry) {
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
// Check if expired
|
|
29
|
+
if (Date.now() - entry.timestamp > this.ttlMs) {
|
|
30
|
+
this.cache.delete(url);
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
return entry;
|
|
34
|
+
}
|
|
35
|
+
set(url, htmlContent, markdownContent) {
|
|
36
|
+
this.cache.set(url, {
|
|
37
|
+
htmlContent,
|
|
38
|
+
markdownContent,
|
|
39
|
+
timestamp: Date.now()
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
clear() {
|
|
43
|
+
this.cache.clear();
|
|
44
|
+
}
|
|
45
|
+
destroy() {
|
|
46
|
+
if (this.cleanupInterval) {
|
|
47
|
+
clearInterval(this.cleanupInterval);
|
|
48
|
+
this.cleanupInterval = null;
|
|
49
|
+
}
|
|
50
|
+
this.clear();
|
|
51
|
+
}
|
|
52
|
+
// Get cache statistics for debugging
|
|
53
|
+
getStats() {
|
|
54
|
+
const now = Date.now();
|
|
55
|
+
const entries = Array.from(this.cache.entries()).map(([url, entry]) => ({
|
|
56
|
+
url,
|
|
57
|
+
age: now - entry.timestamp
|
|
58
|
+
}));
|
|
59
|
+
return {
|
|
60
|
+
size: this.cache.size,
|
|
61
|
+
entries
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
// Global cache instance
|
|
66
|
+
export const urlCache = new SimpleCache();
|
|
67
|
+
// Export for testing and cleanup
|
|
68
|
+
export { SimpleCache };
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Concise error handling for MCP SearXNG server
|
|
3
|
+
* Provides clear, focused error messages that identify the root cause
|
|
4
|
+
*/
|
|
5
|
+
export interface ErrorContext {
|
|
6
|
+
url?: string;
|
|
7
|
+
searxngUrl?: string;
|
|
8
|
+
proxyAgent?: boolean;
|
|
9
|
+
username?: string;
|
|
10
|
+
timeout?: number;
|
|
11
|
+
query?: string;
|
|
12
|
+
}
|
|
13
|
+
export declare class MCPSearXNGError extends Error {
|
|
14
|
+
constructor(message: string);
|
|
15
|
+
}
|
|
16
|
+
export declare function createConfigurationError(message: string): MCPSearXNGError;
|
|
17
|
+
export declare function createNetworkError(error: any, context: ErrorContext): MCPSearXNGError;
|
|
18
|
+
export declare function createServerError(status: number, statusText: string, responseBody: string, context: ErrorContext): MCPSearXNGError;
|
|
19
|
+
export declare function createJSONError(responseText: string, context: ErrorContext): MCPSearXNGError;
|
|
20
|
+
export declare function createDataError(data: any, context: ErrorContext): MCPSearXNGError;
|
|
21
|
+
export declare function createNoResultsMessage(query: string): string;
|
|
22
|
+
export declare function createURLFormatError(url: string): MCPSearXNGError;
|
|
23
|
+
export declare function createURLSecurityPolicyError(url: string): MCPSearXNGError;
|
|
24
|
+
export declare function createContentError(message: string, url: string): MCPSearXNGError;
|
|
25
|
+
export declare function createConversionError(error: any, url: string, htmlContent: string): MCPSearXNGError;
|
|
26
|
+
export declare function createTimeoutError(timeout: number, url: string): MCPSearXNGError;
|
|
27
|
+
export declare function createEmptyContentWarning(url: string, htmlLength: number, htmlPreview: string): string;
|
|
28
|
+
export declare function createUnexpectedError(error: any, context: ErrorContext): MCPSearXNGError;
|
|
29
|
+
export declare function validateEnvironment(): string | null;
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Concise error handling for MCP SearXNG server
|
|
3
|
+
* Provides clear, focused error messages that identify the root cause
|
|
4
|
+
*/
|
|
5
|
+
export class MCPSearXNGError extends Error {
|
|
6
|
+
constructor(message) {
|
|
7
|
+
super(message);
|
|
8
|
+
this.name = 'MCPSearXNGError';
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
export function createConfigurationError(message) {
|
|
12
|
+
return new MCPSearXNGError(`🔧 Configuration Error: ${message}`);
|
|
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
|
+
}
|
|
41
|
+
export function createNetworkError(error, context) {
|
|
42
|
+
const target = context.searxngUrl ? 'SearXNG server' : 'website';
|
|
43
|
+
if (error.code === 'ECONNREFUSED') {
|
|
44
|
+
return new MCPSearXNGError(`🌐 Connection Error: ${target} is not responding (${context.url})`);
|
|
45
|
+
}
|
|
46
|
+
if (error.code === 'ENOTFOUND' || error.code === 'EAI_NONAME') {
|
|
47
|
+
const hostname = context.url ? new URL(context.url).hostname : 'unknown';
|
|
48
|
+
return new MCPSearXNGError(`🌐 DNS Error: Cannot resolve hostname "${hostname}"`);
|
|
49
|
+
}
|
|
50
|
+
if (error.code === 'ETIMEDOUT') {
|
|
51
|
+
return new MCPSearXNGError(`🌐 Timeout Error: ${target} is too slow to respond`);
|
|
52
|
+
}
|
|
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());
|
|
57
|
+
}
|
|
58
|
+
// For generic fetch failures, provide root cause guidance
|
|
59
|
+
const errorMsg = error.message || error.code || 'Connection failed';
|
|
60
|
+
if (errorMsg === 'fetch failed' || errorMsg === 'Connection failed') {
|
|
61
|
+
const guidance = context.searxngUrl
|
|
62
|
+
? 'Check if the SEARXNG_URL is correct and the SearXNG server is available'
|
|
63
|
+
: 'Check if the website URL is accessible';
|
|
64
|
+
return new MCPSearXNGError(`🌐 Network Error: ${errorMsg}. ${guidance}`);
|
|
65
|
+
}
|
|
66
|
+
return new MCPSearXNGError(`🌐 Network Error: ${errorMsg}`);
|
|
67
|
+
}
|
|
68
|
+
export function createServerError(status, statusText, responseBody, context) {
|
|
69
|
+
const target = context.searxngUrl ? 'SearXNG server' : 'Website';
|
|
70
|
+
if (status === 403) {
|
|
71
|
+
const reason = context.searxngUrl ? 'Authentication required or IP blocked' : 'Access blocked (bot detection or geo-restriction)';
|
|
72
|
+
return new MCPSearXNGError(`🚫 ${target} Error (${status}): ${reason}`);
|
|
73
|
+
}
|
|
74
|
+
if (status === 404) {
|
|
75
|
+
const reason = context.searxngUrl ? 'Search endpoint not found' : 'Page not found';
|
|
76
|
+
return new MCPSearXNGError(`🚫 ${target} Error (${status}): ${reason}`);
|
|
77
|
+
}
|
|
78
|
+
if (status === 429) {
|
|
79
|
+
return new MCPSearXNGError(`🚫 ${target} Error (${status}): Rate limit exceeded`);
|
|
80
|
+
}
|
|
81
|
+
if (status >= 500) {
|
|
82
|
+
return new MCPSearXNGError(`🚫 ${target} Error (${status}): Internal server error`);
|
|
83
|
+
}
|
|
84
|
+
return new MCPSearXNGError(`🚫 ${target} Error (${status}): ${statusText}`);
|
|
85
|
+
}
|
|
86
|
+
export function createJSONError(responseText, context) {
|
|
87
|
+
const preview = responseText.substring(0, 100).replace(/\n/g, ' ');
|
|
88
|
+
return new MCPSearXNGError(`🔍 SearXNG Response Error: Invalid JSON format. Response: "${preview}..."`);
|
|
89
|
+
}
|
|
90
|
+
export function createDataError(data, context) {
|
|
91
|
+
return new MCPSearXNGError(`🔍 SearXNG Data Error: Missing results array in response`);
|
|
92
|
+
}
|
|
93
|
+
export function createNoResultsMessage(query) {
|
|
94
|
+
return `🔍 No results found for "${query}". Try different search terms or check if SearXNG search engines are working.`;
|
|
95
|
+
}
|
|
96
|
+
export function createURLFormatError(url) {
|
|
97
|
+
return new MCPSearXNGError(`🔧 URL Format Error: Invalid URL "${url}"`);
|
|
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
|
+
}
|
|
103
|
+
export function createContentError(message, url) {
|
|
104
|
+
return new MCPSearXNGError(`📄 Content Error: ${message} (${url})`);
|
|
105
|
+
}
|
|
106
|
+
export function createConversionError(error, url, htmlContent) {
|
|
107
|
+
return new MCPSearXNGError(`🔄 Conversion Error: Cannot convert HTML to Markdown (${url})`);
|
|
108
|
+
}
|
|
109
|
+
export function createTimeoutError(timeout, url) {
|
|
110
|
+
const hostname = new URL(url).hostname;
|
|
111
|
+
return new MCPSearXNGError(`⏱️ Timeout Error: ${hostname} took longer than ${timeout}ms to respond`);
|
|
112
|
+
}
|
|
113
|
+
export function createEmptyContentWarning(url, htmlLength, htmlPreview) {
|
|
114
|
+
return `📄 Content Warning: Page fetched but appears empty after conversion (${url}). May contain only media or require JavaScript.`;
|
|
115
|
+
}
|
|
116
|
+
export function createUnexpectedError(error, context) {
|
|
117
|
+
return new MCPSearXNGError(`❓ Unexpected Error: ${error.message || String(error)}`);
|
|
118
|
+
}
|
|
119
|
+
export function validateEnvironment() {
|
|
120
|
+
const issues = [];
|
|
121
|
+
const searxngUrl = process.env.SEARXNG_URL;
|
|
122
|
+
if (!searxngUrl) {
|
|
123
|
+
issues.push("SEARXNG_URL not set");
|
|
124
|
+
}
|
|
125
|
+
else {
|
|
126
|
+
try {
|
|
127
|
+
const url = new URL(searxngUrl);
|
|
128
|
+
if (!['http:', 'https:'].includes(url.protocol)) {
|
|
129
|
+
issues.push(`SEARXNG_URL invalid protocol: ${url.protocol}`);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
catch (error) {
|
|
133
|
+
issues.push(`SEARXNG_URL invalid format: ${searxngUrl}`);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
const authUsername = process.env.AUTH_USERNAME;
|
|
137
|
+
const authPassword = process.env.AUTH_PASSWORD;
|
|
138
|
+
if (authUsername && !authPassword) {
|
|
139
|
+
issues.push("AUTH_USERNAME set but AUTH_PASSWORD missing");
|
|
140
|
+
}
|
|
141
|
+
else if (!authUsername && authPassword) {
|
|
142
|
+
issues.push("AUTH_PASSWORD set but AUTH_USERNAME missing");
|
|
143
|
+
}
|
|
144
|
+
if (issues.length === 0) {
|
|
145
|
+
return null;
|
|
146
|
+
}
|
|
147
|
+
return `⚠️ Configuration Issues: ${issues.join(', ')}. Set SEARXNG_URL (e.g., http://localhost:8080 or https://search.example.com)`;
|
|
148
|
+
}
|
package/dist/headers.js
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { createConfigurationError } from "./error-handler.js";
|
|
2
|
+
const HEADER_NAME_REGEX = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/;
|
|
3
|
+
const RESERVED_HEADER_NAMES = new Set(["__proto__", "constructor", "prototype"]);
|
|
4
|
+
function createHeaderRecord() {
|
|
5
|
+
return Object.create(null);
|
|
6
|
+
}
|
|
7
|
+
function normalizeHeaderName(envVarName, name) {
|
|
8
|
+
const normalizedName = name.trim().toLowerCase();
|
|
9
|
+
if (normalizedName === "") {
|
|
10
|
+
throw createConfigurationError(`${envVarName} contains an empty header name`);
|
|
11
|
+
}
|
|
12
|
+
if (!HEADER_NAME_REGEX.test(normalizedName)) {
|
|
13
|
+
throw createConfigurationError(`${envVarName} contains invalid header name "${name}"`);
|
|
14
|
+
}
|
|
15
|
+
if (RESERVED_HEADER_NAMES.has(normalizedName)) {
|
|
16
|
+
throw createConfigurationError(`${envVarName} contains reserved header name "${name}"`);
|
|
17
|
+
}
|
|
18
|
+
return normalizedName;
|
|
19
|
+
}
|
|
20
|
+
function setHeader(headers, envVarName, name, value) {
|
|
21
|
+
headers[normalizeHeaderName(envVarName, name)] = value;
|
|
22
|
+
}
|
|
23
|
+
function normalizeHeaders(headers) {
|
|
24
|
+
const normalizedHeaders = createHeaderRecord();
|
|
25
|
+
if (!headers) {
|
|
26
|
+
return normalizedHeaders;
|
|
27
|
+
}
|
|
28
|
+
if (headers instanceof Headers) {
|
|
29
|
+
for (const [name, value] of headers.entries()) {
|
|
30
|
+
setHeader(normalizedHeaders, "headers", name, value);
|
|
31
|
+
}
|
|
32
|
+
return normalizedHeaders;
|
|
33
|
+
}
|
|
34
|
+
if (Array.isArray(headers)) {
|
|
35
|
+
for (const [name, value] of headers) {
|
|
36
|
+
setHeader(normalizedHeaders, "headers", name, value);
|
|
37
|
+
}
|
|
38
|
+
return normalizedHeaders;
|
|
39
|
+
}
|
|
40
|
+
for (const [name, value] of Object.entries(headers)) {
|
|
41
|
+
setHeader(normalizedHeaders, "headers", name, value);
|
|
42
|
+
}
|
|
43
|
+
return normalizedHeaders;
|
|
44
|
+
}
|
|
45
|
+
export function parseHeadersFromEnv(envVarName) {
|
|
46
|
+
const rawHeaders = process.env[envVarName];
|
|
47
|
+
if (!rawHeaders) {
|
|
48
|
+
return createHeaderRecord();
|
|
49
|
+
}
|
|
50
|
+
let parsedHeaders;
|
|
51
|
+
try {
|
|
52
|
+
parsedHeaders = JSON.parse(rawHeaders);
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
throw createConfigurationError(`${envVarName} must be valid JSON`);
|
|
56
|
+
}
|
|
57
|
+
if (typeof parsedHeaders !== "object" ||
|
|
58
|
+
parsedHeaders === null ||
|
|
59
|
+
Array.isArray(parsedHeaders)) {
|
|
60
|
+
throw createConfigurationError(`${envVarName} must be a JSON object`);
|
|
61
|
+
}
|
|
62
|
+
const headers = createHeaderRecord();
|
|
63
|
+
for (const [name, value] of Object.entries(parsedHeaders)) {
|
|
64
|
+
if (typeof value !== "string") {
|
|
65
|
+
throw createConfigurationError(`${envVarName}.${name} must be a string`);
|
|
66
|
+
}
|
|
67
|
+
setHeader(headers, envVarName, name, value);
|
|
68
|
+
}
|
|
69
|
+
return headers;
|
|
70
|
+
}
|
|
71
|
+
export function mergeHeaders(headers, additionalHeaders) {
|
|
72
|
+
const mergedHeaders = normalizeHeaders(headers);
|
|
73
|
+
for (const [name, value] of Object.entries(additionalHeaders)) {
|
|
74
|
+
setHeader(mergedHeaders, "headers", name, value);
|
|
75
|
+
}
|
|
76
|
+
return mergedHeaders;
|
|
77
|
+
}
|
|
@@ -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;
|