desearch-mcp-server 0.0.1 → 0.1.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/CHANGELOG.md +52 -0
- package/README.md +175 -4
- package/build/http.d.ts +32 -0
- package/build/http.d.ts.map +1 -0
- package/build/http.js +254 -0
- package/build/index.js +24 -118
- package/build/server.d.ts +103 -0
- package/build/server.d.ts.map +1 -0
- package/build/server.js +447 -0
- package/build/tool-sources.d.ts +33 -0
- package/build/tool-sources.d.ts.map +1 -0
- package/build/tool-sources.js +50 -0
- package/package.json +15 -5
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.1.2
|
|
4
|
+
|
|
5
|
+
Migration from the published npm package `desearch-mcp-server@0.0.1`.
|
|
6
|
+
|
|
7
|
+
### npm 0.0.1 versus this release
|
|
8
|
+
|
|
9
|
+
`0.0.1` exposed two tools and called desearch-js methods that 1.5.0 does not have (`AISearch`, `twitterSearch`). Those calls throw. `0.1.2` depends on desearch-js 1.5.0 and calls `aiSearch` and `xSearch`.
|
|
10
|
+
|
|
11
|
+
`0.0.1` did not include `web-links-search` or the other tools below. npm users are not losing a source list they already had.
|
|
12
|
+
|
|
13
|
+
### Tools
|
|
14
|
+
|
|
15
|
+
| Tool | In 0.0.1 |
|
|
16
|
+
| --- | --- |
|
|
17
|
+
| `ai-search` | Present, under the broken `AISearch` call. Arguments now include date range, `result_type`, and domain filters. `tools` uses short ids (`web`, `twitter`, and the other sources this tool already exposed). Older labels such as `"Web Search"` are accepted and rewritten to the short id before the API call. Default is `["web", "twitter"]`. |
|
|
18
|
+
| `x-search` | Present, under the broken `twitterSearch` call. Optional filters were added later (`user`, dates, language, verification, media, engagement). Sort stays Top. |
|
|
19
|
+
| `web-search` | New. |
|
|
20
|
+
| `web-links-search` | New. `tools` accepts only `web` and defaults to `["web"]`. |
|
|
21
|
+
| `x-links-search` | New. |
|
|
22
|
+
| `x-posts-by-urls`, `x-post-by-id`, `x-posts-by-user`, `x-post-retweeters`, `x-user-posts`, `x-user-replies`, `x-post-replies` | New. |
|
|
23
|
+
| `extract`, `web-crawl` | New. Prefer `extract`. |
|
|
24
|
+
| `x-trends` | New. |
|
|
25
|
+
|
|
26
|
+
The same process can serve MCP Streamable HTTP (`--http` or `MCP_TRANSPORT=http`). Stdio is unchanged: it reads `DESEARCH_API_KEY`.
|
|
27
|
+
|
|
28
|
+
### `web-links-search` sources
|
|
29
|
+
|
|
30
|
+
Hosted `https://mcp.desearch.ai/mcp` already lists `web-links-search`. After this release, that tool's schema accepts only `web`. `"Web Search"` is rewritten to `web`. Callers that send `hackernews`, `reddit`, `wikipedia`, `youtube`, or `arxiv` fail input validation. The live `POST /desearch/ai/search/links/web` route rejects those ids with HTTP 422 (`supported tools are Web Search`). npm `0.0.1` never shipped this tool, so this is a break for hosted MCP clients, not for existing npm installs.
|
|
31
|
+
|
|
32
|
+
### Link payloads
|
|
33
|
+
|
|
34
|
+
When `web-links-search` or `ai-search` gets a body with no links (billing fields only, or an empty link list), the tool text includes `message: "no links in response"` and the billing fields. A non-empty link list is returned as the API sent it. The API still sometimes omits links; that fix is in desearch-public-api.
|
|
35
|
+
|
|
36
|
+
### Docker
|
|
37
|
+
|
|
38
|
+
The image command is `node build/index.js` (stdio). Streamable HTTP is `node build/index.js --http` or `MCP_TRANSPORT=http`.
|
|
39
|
+
|
|
40
|
+
### Runtime
|
|
41
|
+
|
|
42
|
+
Node.js `>=20.18.1`. Node 22 is supported. Node 18 is not. `desearch-js` loads `undici` at runtime with a range of `>=5`, which a lockfile-free install resolves to undici 8 (Node `>=22.19`, and it crashes on Node 20). This package depends on `undici@^7.29.1` so a clean install stays on undici 7.
|
|
43
|
+
|
|
44
|
+
### Tool annotations
|
|
45
|
+
|
|
46
|
+
`tools/list` sets MCP tool annotations on every tool:
|
|
47
|
+
|
|
48
|
+
- `readOnlyHint: true` — the tool reads Desearch, the web, or X and does not write caller state
|
|
49
|
+
- `destructiveHint: false` — it does not perform a destructive update
|
|
50
|
+
- `openWorldHint: true` — results come from live external sources
|
|
51
|
+
|
|
52
|
+
These are hints. A call still bills the Desearch API key. Repeating a call is not free.
|
package/README.md
CHANGED
|
@@ -2,19 +2,34 @@
|
|
|
2
2
|
|
|
3
3
|
[](https://www.npmjs.com/package/desearch-mcp-server)
|
|
4
4
|
|
|
5
|
-
A Model Context Protocol (MCP) server lets clients like Claude or Cursor use
|
|
5
|
+
A Model Context Protocol (MCP) server lets clients like Claude or Cursor use Desearch for real-time AI search, X search, web search, page extraction, and X trends.
|
|
6
6
|
|
|
7
7
|
## Tools
|
|
8
8
|
|
|
9
9
|
The Desearch MCP server includes the following tools:
|
|
10
10
|
|
|
11
|
-
- **AI Search
|
|
12
|
-
- **X Search
|
|
11
|
+
- **AI Search** (`ai-search`): Performs real-time AI Twitter and web searches with relevant links and summary. `tools` uses short source ids (`web`, `twitter`, `arxiv`, `wikipedia`, `youtube`, `hackernews`, `reddit`). Older labels such as `Web Search` are still accepted and sent to the API as the short id. Default is `["web", "twitter"]`.
|
|
12
|
+
- **X Search** (`x-search`): Real-time tweet search on X. Arguments: `query` (required), `count` (optional, default 20). Sort stays Top. Optional filters: `user`, `start_date`, `end_date` (YYYY-MM-DD), `lang`, `verified`, `blue_verified`, `is_quote`, `is_video`, `is_image`, `min_retweets`, `min_replies`, `min_likes`.
|
|
13
|
+
- **Web Search** (`web-search`): SERP-style web search. Arguments: `query` (required), `start` (optional pagination offset).
|
|
14
|
+
- **Web Links Search** (`web-links-search`): Web link search. Arguments: `prompt` (required), `tools` (optional, only `web`, default `["web"]`; `Web Search` is accepted and rewritten to `web`), `count` (optional, 10–200). The links/web API rejects other sources, so they are not in the enum.
|
|
15
|
+
- **Extract** (`extract`): Read a public URL as text or HTML. Preferred over crawl. Arguments: `url` (required), `format` (optional, `html` or `text`), `js` (optional), `wait` (optional milliseconds).
|
|
16
|
+
- **Web Crawl** (`web-crawl`): Same arguments as `extract`, on the legacy `/web/crawl` route. The SDK marks `webCrawl` deprecated in favor of `extract`; this tool stays so that route remains reachable. Prefer `extract` for new integrations.
|
|
17
|
+
- **X Links Search** (`x-links-search`): AI search for X post links. Arguments: `prompt` (required), `count` (optional, 10–200).
|
|
18
|
+
- **X Posts By URLs** (`x-posts-by-urls`): Full posts for a list of URLs. Argument: `urls` (required).
|
|
19
|
+
- **X Post By ID** (`x-post-by-id`): One post by ID. Argument: `id` (required).
|
|
20
|
+
- **X Posts By User** (`x-posts-by-user`): Posts by a user. Arguments: `user` (required), `query` (optional), `count` (optional, 1–100).
|
|
21
|
+
- **X Post Retweeters** (`x-post-retweeters`): Users who retweeted a post. Arguments: `id` (required), `cursor` (optional).
|
|
22
|
+
- **X User Posts** (`x-user-posts`): A user's timeline. Arguments: `username` (required), `cursor` (optional).
|
|
23
|
+
- **X User Replies** (`x-user-replies`): Posts and replies by a user. Arguments: `user` (required), `count` (optional, 1–100), `query` (optional).
|
|
24
|
+
- **X Post Replies** (`x-post-replies`): Replies to a post. Arguments: `post_id` (required), `count` (optional, 1–100), `query` (optional).
|
|
25
|
+
- **X Trends** (`x-trends`): Trending topics for a location. Arguments: `woeid` (required), `count` (optional, 30–100).
|
|
26
|
+
|
|
27
|
+
The full SDK method → endpoint → MCP tool map is in [docs/API_MCP_PARITY.md](docs/API_MCP_PARITY.md). Every public `desearch-js` 1.5 method is a tool. `latestTweets` was removed from the SDK (`GET /twitter/latest` in 1.0.1) and is not exposed.
|
|
13
28
|
|
|
14
29
|
## Prerequisites 📋
|
|
15
30
|
|
|
16
31
|
- An [Desearch API Key](https://console.desearch.ai/api-keys)
|
|
17
|
-
- [Node.js](https://nodejs.org/) (
|
|
32
|
+
- [Node.js](https://nodejs.org/) (v20.18.1 or higher; Node 22 is supported. Node 18 is not.)
|
|
18
33
|
- [Claude Desktop](https://claude.ai/download) installed
|
|
19
34
|
- [Cursor IDE](https://www.cursor.com/)
|
|
20
35
|
|
|
@@ -22,10 +37,50 @@ The Desearch MCP server includes the following tools:
|
|
|
22
37
|
|
|
23
38
|
### NPM Installation
|
|
24
39
|
|
|
40
|
+
The package name is `desearch-mcp-server`. The first npm publish of this tree is `0.1.2` (the registry still has `0.0.1`). See [CHANGELOG.md](CHANGELOG.md) for the 0.0.1 → 0.1.2 migration. The stdio entry is the `desearch-mcp-server` bin (`build/index.js`), which requires `DESEARCH_API_KEY`.
|
|
41
|
+
|
|
25
42
|
```bash
|
|
26
43
|
npm install -g desearch-mcp-server
|
|
27
44
|
```
|
|
28
45
|
|
|
46
|
+
Or run it without a global install:
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
npx -y desearch-mcp-server
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Cursor or Claude can start that bin directly:
|
|
53
|
+
|
|
54
|
+
```json
|
|
55
|
+
{
|
|
56
|
+
"mcpServers": {
|
|
57
|
+
"desearch": {
|
|
58
|
+
"command": "npx",
|
|
59
|
+
"args": ["-y", "desearch-mcp-server"],
|
|
60
|
+
"env": {
|
|
61
|
+
"DESEARCH_API_KEY": "your-api-key"
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
`command: "desearch-mcp-server"` (no `args`) is the same entry after the global install above.
|
|
69
|
+
|
|
70
|
+
### Using Smithery
|
|
71
|
+
|
|
72
|
+
To install the Desearch MCP server for Claude Desktop automatically via [Smithery](https://smithery.ai/server/@Desearch-ai/desearch):
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
npx -y @smithery/cli install @Desearch-ai/desearch --client claude
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Or for Cursor IDE:
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
npx -y @smithery/cli install @Desearch-ai/desearch --client cursor
|
|
82
|
+
```
|
|
83
|
+
|
|
29
84
|
## Configuration ⚙️
|
|
30
85
|
|
|
31
86
|
### 1. Configure Cursor IDE to run the Desearch MCP server
|
|
@@ -105,6 +160,121 @@ For the changes to take effect:
|
|
|
105
160
|
2. Start Claude Desktop again
|
|
106
161
|
3. You can verify the server by checking status in Settings > Developer > desearch
|
|
107
162
|
|
|
163
|
+
## Remote Streamable HTTP
|
|
164
|
+
|
|
165
|
+
The same server can run over MCP Streamable HTTP for a remote client. Local stdio (`desearch-mcp-server`, Smithery) is unchanged and still reads `DESEARCH_API_KEY` from the environment.
|
|
166
|
+
|
|
167
|
+
Remote requests do not use that environment variable. Each request must carry the caller's own Desearch API key, the same key from [console.desearch.ai/api-keys](https://console.desearch.ai/api-keys):
|
|
168
|
+
|
|
169
|
+
- `Authorization: Bearer <DESEARCH_API_KEY>` (preferred)
|
|
170
|
+
- `x-api-key: <DESEARCH_API_KEY>`
|
|
171
|
+
|
|
172
|
+
A bare `Authorization: <DESEARCH_API_KEY>` value is also accepted. The key is not read from the query string. There is no shared server secret: the hosted process forwards the per-request key to the Desearch API.
|
|
173
|
+
|
|
174
|
+
The MCP endpoint is `POST /mcp`. Responses are JSON (stateless Streamable HTTP). `GET` and `DELETE` on `/mcp` return `405` because the server does not keep a session or push server-to-client messages. `GET /` and `GET /health` are unauthenticated health checks.
|
|
175
|
+
|
|
176
|
+
## Hosted endpoint
|
|
177
|
+
|
|
178
|
+
The public Streamable HTTP endpoint is `https://mcp.desearch.ai/mcp`. Send your Desearch API key on each request in the `x-api-key` header. `Authorization: Bearer <key>` is also accepted. Use the key from [console.desearch.ai/api-keys](https://console.desearch.ai/api-keys). The server does not read a key from the query string. Remote requests do not use a process-level `DESEARCH_API_KEY`.
|
|
179
|
+
|
|
180
|
+
Cursor, or any remote MCP client:
|
|
181
|
+
|
|
182
|
+
```json
|
|
183
|
+
{
|
|
184
|
+
"mcpServers": {
|
|
185
|
+
"desearch": {
|
|
186
|
+
"url": "https://mcp.desearch.ai/mcp",
|
|
187
|
+
"headers": {
|
|
188
|
+
"x-api-key": "your-api-key"
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
### Use with Claude (custom connector)
|
|
196
|
+
|
|
197
|
+
Desearch is not in the Claude Connectors Directory yet. You can add the hosted server as a custom connector with your Desearch API key.
|
|
198
|
+
|
|
199
|
+
Sources: [Custom remote MCP connectors](https://claude.com/docs/connectors/custom/remote-mcp) and [connector authentication](https://claude.com/docs/connectors/building/authentication). Request-header authentication is a beta feature in Claude.
|
|
200
|
+
|
|
201
|
+
**Claude.ai / Claude Desktop (organization admin)**
|
|
202
|
+
|
|
203
|
+
1. Open **Organization settings > Connectors**.
|
|
204
|
+
2. Select **Add**, then **Custom**. If asked for the connector type, choose **Web**.
|
|
205
|
+
3. Server URL: `https://mcp.desearch.ai/mcp`
|
|
206
|
+
4. Sign-in option: **No sign-in**.
|
|
207
|
+
5. Under **Request headers**, add `x-api-key` with your Desearch API key as the value.
|
|
208
|
+
6. Select **Add**.
|
|
209
|
+
|
|
210
|
+
The header value is stored once and shared by everyone in the organization who uses the connector.
|
|
211
|
+
|
|
212
|
+
**Claude Code**
|
|
213
|
+
|
|
214
|
+
```bash
|
|
215
|
+
claude mcp add --transport http desearch https://mcp.desearch.ai/mcp \
|
|
216
|
+
--header "x-api-key: YOUR_DESEARCH_API_KEY"
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
### Run locally
|
|
220
|
+
|
|
221
|
+
```bash
|
|
222
|
+
npm install
|
|
223
|
+
npm run build
|
|
224
|
+
npm run start:http
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
This listens on `0.0.0.0:3000` (`PORT` and `HOST` override that). `MCP_TRANSPORT=http` is the same as `--http`.
|
|
228
|
+
|
|
229
|
+
```bash
|
|
230
|
+
curl -sS http://127.0.0.1:3000/mcp \
|
|
231
|
+
-H 'Content-Type: application/json' \
|
|
232
|
+
-H 'Accept: application/json, text/event-stream' \
|
|
233
|
+
-H 'Authorization: Bearer your-api-key' \
|
|
234
|
+
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"smoke","version":"0.0.1"}}}'
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
Cursor (or any remote MCP client):
|
|
238
|
+
|
|
239
|
+
```json
|
|
240
|
+
{
|
|
241
|
+
"mcpServers": {
|
|
242
|
+
"desearch": {
|
|
243
|
+
"url": "http://127.0.0.1:3000/mcp",
|
|
244
|
+
"headers": {
|
|
245
|
+
"Authorization": "Bearer your-api-key"
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
The image default is stdio MCP (`node build/index.js`). Registries such as Glama start the container and speak MCP on stdin/stdout, so the image does not pass `--http` unless you override it. Stdio requires `DESEARCH_API_KEY`. Smithery does not use this image command; `smithery.yaml` starts `node build/index.js` and injects `DESEARCH_API_KEY` itself.
|
|
253
|
+
|
|
254
|
+
Streamable HTTP is an override. Replace the command with `--http`, or set `MCP_TRANSPORT=http` and keep the default command. The image still exposes port 3000 for that mode.
|
|
255
|
+
|
|
256
|
+
```bash
|
|
257
|
+
docker build -t desearch-mcp .
|
|
258
|
+
# stdio (image default)
|
|
259
|
+
docker run --rm -e DESEARCH_API_KEY=your-api-key -i desearch-mcp
|
|
260
|
+
# Streamable HTTP
|
|
261
|
+
docker run --rm -p 3000:3000 desearch-mcp node build/index.js --http
|
|
262
|
+
# same HTTP mode via env, without replacing the command
|
|
263
|
+
docker run --rm -e MCP_TRANSPORT=http -p 3000:3000 desearch-mcp
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
### Deploy on Vercel
|
|
267
|
+
|
|
268
|
+
Vercel fits this server because the handler is stateless and answers each JSON-RPC call in one response. `vercel.json` builds the project, serves `POST /mcp`, and allows tool calls up to 60 seconds (Orbit searches run about 30 seconds). Hobby plans cap function duration lower than that, so AI Search tool calls need a plan that allows at least 60 seconds. `initialize` and `tools/list` are short either way.
|
|
269
|
+
|
|
270
|
+
No server-side Desearch API key is required in the Vercel project. After deploy, the endpoint is:
|
|
271
|
+
|
|
272
|
+
`https://<project>.vercel.app/mcp`
|
|
273
|
+
|
|
274
|
+
`https://mcp.desearch.ai/mcp` is the public hostname. This repo does not create DNS records. Clients send `x-api-key`, or `Authorization: Bearer <key>`.
|
|
275
|
+
|
|
276
|
+
The same `node build/index.js --http` process is the fallback if you would rather run a long-lived Node host instead of Vercel. The Docker image defaults to stdio; pass `--http` or set `MCP_TRANSPORT=http` to serve Streamable HTTP from it.
|
|
277
|
+
|
|
108
278
|
## Troubleshooting 🔧
|
|
109
279
|
|
|
110
280
|
### Common Issues
|
|
@@ -119,6 +289,7 @@ For the changes to take effect:
|
|
|
119
289
|
- Confirm your `DESEARCH_API_KEY` is valid
|
|
120
290
|
- Check the `DESEARCH_API_KEY` is correctly set in the Cursor or Claude Desktop config
|
|
121
291
|
- Verify that there are no spaces around the API key
|
|
292
|
+
- For the remote HTTP server, send `Authorization: Bearer <key>` or `x-api-key`. A hosted `DESEARCH_API_KEY` environment variable is not used for those requests.
|
|
122
293
|
|
|
123
294
|
3. **Connection Issues**
|
|
124
295
|
|
package/build/http.d.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
export interface HttpListenOptions {
|
|
2
|
+
port?: number;
|
|
3
|
+
host?: string;
|
|
4
|
+
}
|
|
5
|
+
export interface RunningHttpServer {
|
|
6
|
+
port: number;
|
|
7
|
+
host: string;
|
|
8
|
+
close: () => Promise<void>;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Desearch API key from the remote client.
|
|
12
|
+
* Prefer `Authorization: Bearer <key>` (what MCP clients send). `x-api-key` is
|
|
13
|
+
* accepted as well. A bare `Authorization: <key>` value is accepted so the same
|
|
14
|
+
* string used as `DESEARCH_API_KEY` for stdio can be sent without a scheme.
|
|
15
|
+
* Query strings are ignored so keys do not land in access logs.
|
|
16
|
+
*/
|
|
17
|
+
/**
|
|
18
|
+
* Copy a request onto another path. Used by the Vercel functions, which see
|
|
19
|
+
* the rewritten URL (`/api/mcp`) rather than the public path (`/mcp`).
|
|
20
|
+
*/
|
|
21
|
+
export declare function requestWithPathname(request: Request, pathname: string): Request;
|
|
22
|
+
export declare function extractDesearchApiKey(request: Request): string | undefined;
|
|
23
|
+
/**
|
|
24
|
+
* Stateless Streamable HTTP handler.
|
|
25
|
+
* Each POST builds a fresh MCP server tied to that request's API key.
|
|
26
|
+
* JSON responses (not a long-lived SSE session) so the same handler runs
|
|
27
|
+
* in a local Node process and in a Vercel function.
|
|
28
|
+
* GET and DELETE return 405: this server does not push messages or store sessions.
|
|
29
|
+
*/
|
|
30
|
+
export declare function handleMcpHttpRequest(request: Request): Promise<Response>;
|
|
31
|
+
export declare function startHttpServer(options?: HttpListenOptions): Promise<RunningHttpServer>;
|
|
32
|
+
//# sourceMappingURL=http.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"http.d.ts","sourceRoot":"","sources":["../http.ts"],"names":[],"mappings":"AAsBA,MAAM,WAAW,iBAAiB;IAC9B,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,IAAI,CAAC,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,WAAW,iBAAiB;IAC9B,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC;CAC9B;AAED;;;;;;GAMG;AACH;;;GAGG;AACH,wBAAgB,mBAAmB,CAAC,OAAO,EAAE,OAAO,EAAE,QAAQ,EAAE,MAAM,GAAG,OAAO,CAa/E;AAED,wBAAgB,qBAAqB,CAAC,OAAO,EAAE,OAAO,GAAG,MAAM,GAAG,SAAS,CAkB1E;AAoED;;;;;;GAMG;AACH,wBAAsB,oBAAoB,CAAC,OAAO,EAAE,OAAO,GAAG,OAAO,CAAC,QAAQ,CAAC,CAyD9E;AAoDD,wBAAgB,eAAe,CAAC,OAAO,GAAE,iBAAsB,GAAG,OAAO,CAAC,iBAAiB,CAAC,CAiD3F"}
|
package/build/http.js
ADDED
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
import { createServer } from "node:http";
|
|
2
|
+
import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
|
|
3
|
+
import { createDesearchMcpServer, SERVER_NAME, SERVER_VERSION } from "./server.js";
|
|
4
|
+
const CORS_HEADERS = {
|
|
5
|
+
"Access-Control-Allow-Origin": "*",
|
|
6
|
+
"Access-Control-Allow-Methods": "GET, POST, DELETE, OPTIONS",
|
|
7
|
+
"Access-Control-Allow-Headers": "Content-Type, Accept, Authorization, x-api-key, Mcp-Session-Id, Mcp-Protocol-Version, Last-Event-ID",
|
|
8
|
+
"Access-Control-Expose-Headers": "Mcp-Session-Id",
|
|
9
|
+
"Access-Control-Max-Age": "86400",
|
|
10
|
+
};
|
|
11
|
+
const HOP_BY_HOP = new Set([
|
|
12
|
+
"connection",
|
|
13
|
+
"keep-alive",
|
|
14
|
+
"transfer-encoding",
|
|
15
|
+
"upgrade",
|
|
16
|
+
"host",
|
|
17
|
+
"content-length",
|
|
18
|
+
]);
|
|
19
|
+
/**
|
|
20
|
+
* Desearch API key from the remote client.
|
|
21
|
+
* Prefer `Authorization: Bearer <key>` (what MCP clients send). `x-api-key` is
|
|
22
|
+
* accepted as well. A bare `Authorization: <key>` value is accepted so the same
|
|
23
|
+
* string used as `DESEARCH_API_KEY` for stdio can be sent without a scheme.
|
|
24
|
+
* Query strings are ignored so keys do not land in access logs.
|
|
25
|
+
*/
|
|
26
|
+
/**
|
|
27
|
+
* Copy a request onto another path. Used by the Vercel functions, which see
|
|
28
|
+
* the rewritten URL (`/api/mcp`) rather than the public path (`/mcp`).
|
|
29
|
+
*/
|
|
30
|
+
export function requestWithPathname(request, pathname) {
|
|
31
|
+
const url = new URL(request.url);
|
|
32
|
+
url.pathname = pathname;
|
|
33
|
+
const method = request.method;
|
|
34
|
+
const init = {
|
|
35
|
+
method,
|
|
36
|
+
headers: request.headers,
|
|
37
|
+
};
|
|
38
|
+
if (method !== "GET" && method !== "HEAD") {
|
|
39
|
+
init.body = request.body;
|
|
40
|
+
init.duplex = "half";
|
|
41
|
+
}
|
|
42
|
+
return new Request(url, init);
|
|
43
|
+
}
|
|
44
|
+
export function extractDesearchApiKey(request) {
|
|
45
|
+
const authorization = request.headers.get("authorization")?.trim();
|
|
46
|
+
if (authorization) {
|
|
47
|
+
const bearer = /^Bearer\s+(\S+)$/i.exec(authorization);
|
|
48
|
+
if (bearer?.[1]) {
|
|
49
|
+
return bearer[1];
|
|
50
|
+
}
|
|
51
|
+
if (!/^Bearer\b/i.test(authorization) && !/\s/.test(authorization)) {
|
|
52
|
+
return authorization;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
const headerKey = request.headers.get("x-api-key")?.trim();
|
|
56
|
+
if (headerKey) {
|
|
57
|
+
return headerKey;
|
|
58
|
+
}
|
|
59
|
+
return undefined;
|
|
60
|
+
}
|
|
61
|
+
function normalizePath(pathname) {
|
|
62
|
+
if (pathname.length > 1 && pathname.endsWith("/")) {
|
|
63
|
+
return pathname.slice(0, -1);
|
|
64
|
+
}
|
|
65
|
+
return pathname;
|
|
66
|
+
}
|
|
67
|
+
function isMcpPath(pathname) {
|
|
68
|
+
return pathname === "/mcp" || pathname === "/api/mcp";
|
|
69
|
+
}
|
|
70
|
+
function isHealthPath(pathname) {
|
|
71
|
+
return pathname === "/" || pathname === "/health";
|
|
72
|
+
}
|
|
73
|
+
function withCors(response) {
|
|
74
|
+
const headers = new Headers(response.headers);
|
|
75
|
+
for (const [key, value] of Object.entries(CORS_HEADERS)) {
|
|
76
|
+
headers.set(key, value);
|
|
77
|
+
}
|
|
78
|
+
return new Response(response.body, {
|
|
79
|
+
status: response.status,
|
|
80
|
+
statusText: response.statusText,
|
|
81
|
+
headers,
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
function jsonRpcError(status, code, message, extra) {
|
|
85
|
+
const headers = new Headers({ "Content-Type": "application/json", ...extra });
|
|
86
|
+
return new Response(JSON.stringify({
|
|
87
|
+
jsonrpc: "2.0",
|
|
88
|
+
error: { code, message },
|
|
89
|
+
id: null,
|
|
90
|
+
}), { status, headers });
|
|
91
|
+
}
|
|
92
|
+
function healthResponse() {
|
|
93
|
+
return new Response(JSON.stringify({
|
|
94
|
+
ok: true,
|
|
95
|
+
name: SERVER_NAME,
|
|
96
|
+
version: SERVER_VERSION,
|
|
97
|
+
transport: "streamable-http",
|
|
98
|
+
endpoint: "/mcp",
|
|
99
|
+
auth: "Authorization: Bearer <DESEARCH_API_KEY> or x-api-key: <DESEARCH_API_KEY>",
|
|
100
|
+
}), {
|
|
101
|
+
status: 200,
|
|
102
|
+
headers: { "Content-Type": "application/json" },
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
function unauthorized() {
|
|
106
|
+
// No OAuth challenge. Desearch auth is a per-user API key, and a
|
|
107
|
+
// WWW-Authenticate discovery hint makes some MCP clients start an OAuth flow.
|
|
108
|
+
return jsonRpcError(401, -32001, "Unauthorized. Send your Desearch API key in the Authorization: Bearer <key> header or the x-api-key header.");
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Stateless Streamable HTTP handler.
|
|
112
|
+
* Each POST builds a fresh MCP server tied to that request's API key.
|
|
113
|
+
* JSON responses (not a long-lived SSE session) so the same handler runs
|
|
114
|
+
* in a local Node process and in a Vercel function.
|
|
115
|
+
* GET and DELETE return 405: this server does not push messages or store sessions.
|
|
116
|
+
*/
|
|
117
|
+
export async function handleMcpHttpRequest(request) {
|
|
118
|
+
if (request.method === "OPTIONS") {
|
|
119
|
+
return withCors(new Response(null, { status: 204 }));
|
|
120
|
+
}
|
|
121
|
+
const pathname = normalizePath(new URL(request.url).pathname);
|
|
122
|
+
if (isHealthPath(pathname)) {
|
|
123
|
+
if (request.method === "GET") {
|
|
124
|
+
return withCors(healthResponse());
|
|
125
|
+
}
|
|
126
|
+
return withCors(jsonRpcError(405, -32000, "Method not allowed.", { Allow: "GET" }));
|
|
127
|
+
}
|
|
128
|
+
if (!isMcpPath(pathname)) {
|
|
129
|
+
return withCors(new Response(JSON.stringify({ error: "Not found" }), {
|
|
130
|
+
status: 404,
|
|
131
|
+
headers: { "Content-Type": "application/json" },
|
|
132
|
+
}));
|
|
133
|
+
}
|
|
134
|
+
const apiKey = extractDesearchApiKey(request);
|
|
135
|
+
if (!apiKey) {
|
|
136
|
+
return withCors(unauthorized());
|
|
137
|
+
}
|
|
138
|
+
if (request.method === "GET" || request.method === "DELETE") {
|
|
139
|
+
return withCors(jsonRpcError(405, -32000, "Method not allowed. This server is stateless; use POST.", { Allow: "POST" }));
|
|
140
|
+
}
|
|
141
|
+
if (request.method !== "POST") {
|
|
142
|
+
return withCors(jsonRpcError(405, -32000, "Method not allowed.", { Allow: "POST" }));
|
|
143
|
+
}
|
|
144
|
+
const server = createDesearchMcpServer(apiKey);
|
|
145
|
+
const transport = new WebStandardStreamableHTTPServerTransport({
|
|
146
|
+
enableJsonResponse: true,
|
|
147
|
+
});
|
|
148
|
+
transport.onerror = (error) => {
|
|
149
|
+
console.error(`MCP HTTP error: ${error.message}`);
|
|
150
|
+
};
|
|
151
|
+
try {
|
|
152
|
+
await server.connect(transport);
|
|
153
|
+
const response = await transport.handleRequest(request);
|
|
154
|
+
return withCors(response);
|
|
155
|
+
}
|
|
156
|
+
catch (error) {
|
|
157
|
+
console.error(`MCP HTTP handler error: ${error instanceof Error ? error.message : String(error)}`);
|
|
158
|
+
return withCors(jsonRpcError(500, -32603, "Internal server error"));
|
|
159
|
+
}
|
|
160
|
+
finally {
|
|
161
|
+
await transport.close().catch(() => undefined);
|
|
162
|
+
await server.close().catch(() => undefined);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
function readRawBody(req) {
|
|
166
|
+
return new Promise((resolve, reject) => {
|
|
167
|
+
const chunks = [];
|
|
168
|
+
req.on("data", (chunk) => {
|
|
169
|
+
chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
|
|
170
|
+
});
|
|
171
|
+
req.on("end", () => resolve(Buffer.concat(chunks)));
|
|
172
|
+
req.on("error", reject);
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
async function toWebRequest(req) {
|
|
176
|
+
const host = req.headers.host ?? "localhost";
|
|
177
|
+
const url = `http://${host}${req.url ?? "/"}`;
|
|
178
|
+
const headers = new Headers();
|
|
179
|
+
for (const [key, value] of Object.entries(req.headers)) {
|
|
180
|
+
if (value === undefined || HOP_BY_HOP.has(key.toLowerCase())) {
|
|
181
|
+
continue;
|
|
182
|
+
}
|
|
183
|
+
if (Array.isArray(value)) {
|
|
184
|
+
for (const entry of value) {
|
|
185
|
+
headers.append(key, entry);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
else {
|
|
189
|
+
headers.set(key, value);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
const method = req.method ?? "GET";
|
|
193
|
+
const hasBody = method !== "GET" && method !== "HEAD";
|
|
194
|
+
const body = hasBody ? await readRawBody(req) : undefined;
|
|
195
|
+
return new Request(url, {
|
|
196
|
+
method,
|
|
197
|
+
headers,
|
|
198
|
+
body: body && body.byteLength > 0 ? body : undefined,
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
async function writeWebResponse(res, response) {
|
|
202
|
+
const body = Buffer.from(await response.arrayBuffer());
|
|
203
|
+
const headers = new Headers(response.headers);
|
|
204
|
+
headers.set("content-length", String(body.byteLength));
|
|
205
|
+
const outgoing = {};
|
|
206
|
+
headers.forEach((value, key) => {
|
|
207
|
+
outgoing[key] = value;
|
|
208
|
+
});
|
|
209
|
+
res.writeHead(response.status, outgoing);
|
|
210
|
+
res.end(body);
|
|
211
|
+
}
|
|
212
|
+
export function startHttpServer(options = {}) {
|
|
213
|
+
const host = options.host ?? "0.0.0.0";
|
|
214
|
+
const port = options.port ?? 3000;
|
|
215
|
+
const nodeServer = createServer(async (req, res) => {
|
|
216
|
+
try {
|
|
217
|
+
const request = await toWebRequest(req);
|
|
218
|
+
const response = await handleMcpHttpRequest(request);
|
|
219
|
+
await writeWebResponse(res, response);
|
|
220
|
+
}
|
|
221
|
+
catch (error) {
|
|
222
|
+
console.error(`HTTP server error: ${error instanceof Error ? error.message : String(error)}`);
|
|
223
|
+
if (!res.headersSent) {
|
|
224
|
+
res.writeHead(500, { "Content-Type": "application/json" });
|
|
225
|
+
res.end(JSON.stringify({
|
|
226
|
+
jsonrpc: "2.0",
|
|
227
|
+
error: { code: -32603, message: "Internal server error" },
|
|
228
|
+
id: null,
|
|
229
|
+
}));
|
|
230
|
+
}
|
|
231
|
+
else {
|
|
232
|
+
res.end();
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
});
|
|
236
|
+
return new Promise((resolve, reject) => {
|
|
237
|
+
nodeServer.once("error", reject);
|
|
238
|
+
nodeServer.listen(port, host, () => {
|
|
239
|
+
const address = nodeServer.address();
|
|
240
|
+
const boundPort = typeof address === "object" && address ? address.port : port;
|
|
241
|
+
console.error(`Desearch MCP listening on ${host}:${boundPort} (endpoint /mcp)`);
|
|
242
|
+
resolve({
|
|
243
|
+
host,
|
|
244
|
+
port: boundPort,
|
|
245
|
+
close: () => new Promise((closeResolve, closeReject) => {
|
|
246
|
+
// Client transports abort the optional GET stream and can leave
|
|
247
|
+
// a socket that close() would otherwise hold until timeout.
|
|
248
|
+
nodeServer.closeAllConnections();
|
|
249
|
+
nodeServer.close((error) => (error ? closeReject(error) : closeResolve()));
|
|
250
|
+
}),
|
|
251
|
+
});
|
|
252
|
+
});
|
|
253
|
+
});
|
|
254
|
+
}
|