ninerouter-mcp 1.2.0 → 1.3.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/README.md +92 -48
- package/dist/create-config.js +6 -5
- package/dist/http.js +106 -9
- package/dist/{tools → lib}/common.js +13 -0
- package/dist/lib/countries.js +352 -0
- package/dist/lib/download-links.js +106 -0
- package/dist/lib/media-output.js +23 -0
- package/dist/lib/web-capabilities.js +220 -0
- package/dist/lib/web-model.js +16 -0
- package/dist/server.js +3 -2
- package/dist/tools/embeddings.js +7 -4
- package/dist/tools/media.js +85 -44
- package/dist/tools/models.js +14 -3
- package/dist/tools/web.js +117 -23
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
MCP server that exposes 9Router capabilities as native tools for any MCP client: model discovery, automatic fallback, and Zod-validated inputs.
|
|
9
9
|
|
|
10
|
-
> **Status:** 9Router already hides provider-specific complexity behind a single API. This server exposes that API through MCP, so agents and apps can call web search, web fetch, image generation, TTS, STT, and embeddings as standard MCP tools
|
|
10
|
+
> **Status:** 9Router already hides provider-specific complexity behind a single API. This server exposes that API through MCP, so agents and apps can call web search, web fetch, image generation, TTS, STT, and embeddings as standard MCP tools: no skill-file loading, no per-provider glue code.
|
|
11
11
|
|
|
12
12
|
## Why use it
|
|
13
13
|
|
|
@@ -17,7 +17,7 @@ MCP server that exposes 9Router capabilities as native tools for any MCP client:
|
|
|
17
17
|
- One config file for endpoint, auth, and default models with fallback chains.
|
|
18
18
|
- Single transport (`stdio`); works with any MCP-capable client.
|
|
19
19
|
|
|
20
|
-
Chat / code generation is intentionally **not** included
|
|
20
|
+
Chat / code generation is intentionally **not** included. That is what the host model is for.
|
|
21
21
|
|
|
22
22
|
## Contents
|
|
23
23
|
|
|
@@ -31,7 +31,7 @@ Chat / code generation is intentionally **not** included — that is what the ho
|
|
|
31
31
|
|
|
32
32
|
## Installation
|
|
33
33
|
|
|
34
|
-
The package is `ninerouter-mcp` on npm. The JSON body is identical across every MCP client
|
|
34
|
+
The package is `ninerouter-mcp` on npm. The JSON body is identical across every MCP client. Only the top-level key and file path differ. Paste the block into the right key in your client's config:
|
|
35
35
|
|
|
36
36
|
```json
|
|
37
37
|
{
|
|
@@ -128,8 +128,8 @@ base_url = "http://localhost:20128"
|
|
|
128
128
|
# Single string = one model.
|
|
129
129
|
# Array = tried in order, first success wins, errors are aggregated.
|
|
130
130
|
[default_models]
|
|
131
|
-
web_search = ["tavily
|
|
132
|
-
web_fetch = ["
|
|
131
|
+
web_search = ["tavily", "serper"]
|
|
132
|
+
web_fetch = ["tavily", "jina-reader"]
|
|
133
133
|
generate_image = "openai/dall-e-3"
|
|
134
134
|
text_to_speech = "openai/tts-1"
|
|
135
135
|
speech_to_text = ["openai/whisper-1", "groq/whisper-large-v3-turbo"]
|
|
@@ -179,13 +179,33 @@ Published package users can run it directly:
|
|
|
179
179
|
npx -y ninerouter-mcp
|
|
180
180
|
```
|
|
181
181
|
|
|
182
|
+
### Serve over HTTP
|
|
183
|
+
|
|
184
|
+
Add `--http` to serve the MCP endpoint as Streamable HTTP on `/mcp` instead of stdio. Use it when the client cannot spawn a process, or when the server runs on a different host from the client.
|
|
185
|
+
|
|
186
|
+
```bash
|
|
187
|
+
npx -y ninerouter-mcp --http --port 3000 --host 127.0.0.1
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
| Flag | Default | Notes |
|
|
191
|
+
| --------------- | ------------ | ---------------------------------------------------------------------------------------------------------------------------------------- |
|
|
192
|
+
| `--http` | off | Serve Streamable HTTP instead of stdio. |
|
|
193
|
+
| `--port` / `-p` | `3000` | Listen port. |
|
|
194
|
+
| `--host` | `127.0.0.1` | Listen address. Use `0.0.0.0` to accept clients from other machines. |
|
|
195
|
+
| `--public-url` | request host | Origin the server uses to build download URLs, for a deployment behind a proxy. Env: `NINEROUTER_MCP_PUBLIC_URL`. |
|
|
196
|
+
| `--local` | off | Assume clients share this server's filesystem: `auto` returns a content block plus `outputPath`, not a URL. Env: `NINEROUTER_MCP_LOCAL`. |
|
|
197
|
+
|
|
198
|
+
Over HTTP the client may not share the server's filesystem, so a written path can be useless to it. `generate_image` and `text_to_speech` therefore hand back a signed download URL instead of the base64 bytes. The URL looks like `/files/<session>/<id>?exp=<ms>&sig=<hmac>`, expires after 15 minutes, and needs no MCP session: the HMAC is the authorization, so a browser or `curl` can fetch it.
|
|
199
|
+
|
|
200
|
+
HTTP does not imply another machine, though. When one server process backs many agents on the same host, the client can read the path just fine and a URL only adds a round trip. Pass `--local` for that case: `auto` then behaves like stdio (content block plus `outputPath`), the server stops exposing a download route, and `--public-url` no longer applies. A per-call `return` still overrides the default, so an agent can ask for `inline` or `path` regardless.
|
|
201
|
+
|
|
182
202
|
## Tools
|
|
183
203
|
|
|
184
204
|
Every tool is registered with the MCP server at startup. Unless noted, all tools return a single text content block with pretty-printed JSON.
|
|
185
205
|
|
|
186
206
|
### `list_models`
|
|
187
207
|
|
|
188
|
-
Look up model ids exposed by 9Router. This tool is optional: every other tool works without it, because each has a configured default model or fallback chain. Call it only to target a specific model or to discover ids that differ from the defaults. It filters by `search`, then pages through the matches with `offset`/`limit`, so a large catalog cannot flood the context window. Each response reports `total`, `count`, `offset`, and `nextOffset` (omitted on the last page). The upstream list is cached for about a minute, because 9Router rebuilds the whole catalog on every request (several seconds), so only the first call is slow.
|
|
208
|
+
Look up model ids exposed by 9Router. This tool is optional: every other tool works without it, because each has a configured default model or fallback chain. Call it only to target a specific model or to discover ids that differ from the defaults. It filters by `search`, then pages through the matches with `offset`/`limit`, so a large catalog cannot flood the context window. Each response reports `total`, `count`, `offset`, and `nextOffset` (omitted on the last page). Entries returned for `kind: "web"` also carry `model`, the id to hand to `web_search` or `web_fetch`. The upstream list is cached for about a minute, because 9Router rebuilds the whole catalog on every request (several seconds), so only the first call is slow.
|
|
189
209
|
|
|
190
210
|
| Parameter | Type | Required | Description |
|
|
191
211
|
| --------- | ------- | -------- | -------------------------------------------------------------------------------------------------------- |
|
|
@@ -210,54 +230,77 @@ Built-in defaults (used when a key is omitted): `limit = 20`, `offset = 0`, `cac
|
|
|
210
230
|
|
|
211
231
|
Search the web through 9Router.
|
|
212
232
|
|
|
213
|
-
| Parameter | Type
|
|
214
|
-
| -------------- |
|
|
215
|
-
| `query` | string
|
|
216
|
-
| `model` | string
|
|
217
|
-
| `provider` | string
|
|
218
|
-
| `maxResults` | number
|
|
219
|
-
| `searchType` | string
|
|
220
|
-
| `country` | string
|
|
221
|
-
| `language` | string
|
|
222
|
-
| `timeRange` | string
|
|
223
|
-
| `domainFilter` | string | — |
|
|
233
|
+
| Parameter | Type | Default | Notes |
|
|
234
|
+
| -------------- | --------------- | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
235
|
+
| `query` | string | — | Required. |
|
|
236
|
+
| `model` | string | — | Backend id: provider (`tavily`), catalog id (`tavily/search`), or combo (`search-combo`). The server falls back to `provider`, then `default_models.web_search`. |
|
|
237
|
+
| `provider` | string | — | Alias for `model`. |
|
|
238
|
+
| `maxResults` | number | `5` | 1–100. 9Router caps it to the provider limit. |
|
|
239
|
+
| `searchType` | string | `web` | `web`, `news`, or `research_paper` (tinyfish only). |
|
|
240
|
+
| `country` | string | — | Country name (`united states`) or ISO 3166-1 alpha-2 code (`us`). The server rewrites it to the format the provider wants. |
|
|
241
|
+
| `language` | string | — | For example `en` or `vi`. |
|
|
242
|
+
| `timeRange` | string | — | `day`, `week`, `month`, or `year`. Only google-pse, linkup, youcom and searxng read it. |
|
|
243
|
+
| `domainFilter` | string \| array | — | Domains to include (`["snyk.io"]`) or exclude (`["-reddit.com"]`). A comma-separated string also works. |
|
|
244
|
+
|
|
245
|
+
Each provider reads only the filters its own request builder knows about, so the same call filters differently depending on the backend:
|
|
246
|
+
|
|
247
|
+
| Provider | `country` | `language` | `timeRange` | `domainFilter` |
|
|
248
|
+
| --------------- | ---------------------- | ---------- | ----------- | -------------- |
|
|
249
|
+
| `tavily` | name (`united states`) | no | no | yes |
|
|
250
|
+
| `exa` | no | no | no | yes |
|
|
251
|
+
| `serper` | code, lowercase (`us`) | yes | no | no |
|
|
252
|
+
| `brave-search` | code, uppercase (`US`) | yes | no | no |
|
|
253
|
+
| `perplexity` | no | no | no | no |
|
|
254
|
+
| `google-pse` | code, lowercase (`us`) | yes | yes | no |
|
|
255
|
+
| `linkup` | no | no | yes | yes |
|
|
256
|
+
| `searchapi` | code, lowercase (`us`) | yes | no | no |
|
|
257
|
+
| `youcom` | code, uppercase (`US`) | yes | yes | yes |
|
|
258
|
+
| `searxng` | no | yes | yes | no |
|
|
259
|
+
| `xquik` | no | yes | no | no |
|
|
260
|
+
| `tinyfish` | code, uppercase (`US`) | yes | no | yes |
|
|
261
|
+
| `ollama-search` | no | no | no | no |
|
|
262
|
+
| `glm` | no | no | no | no |
|
|
263
|
+
|
|
264
|
+
When the chosen provider does not read a filter, the server drops it and says so in the response's `warnings` array, so you can tell an applied filter from an ignored one. The same array reports a country that was rewritten for the provider, a result count that 9Router capped, and a `searchType` the provider cannot use. `perplexity` and `glm` search through a chat model and forward only the query and the result count, which is why none of their filters apply. `ollama-search` hands the country and language to Ollama, whose API ignores both.
|
|
224
265
|
|
|
225
266
|
### `web_fetch`
|
|
226
267
|
|
|
227
268
|
Fetch a URL and return it as markdown, text, or HTML.
|
|
228
269
|
|
|
229
|
-
| Parameter | Type | Default | Notes
|
|
230
|
-
| --------------- | ------ | ---------- |
|
|
231
|
-
| `url` | string | — | Required. Must be a valid URL.
|
|
232
|
-
| `model` | string | — |
|
|
233
|
-
| `provider` | string | — | Alias for `model`.
|
|
234
|
-
| `format` | string | `markdown` | `markdown`, `text`, or `html`.
|
|
235
|
-
| `maxCharacters` | number | `8000` | Truncation limit.
|
|
270
|
+
| Parameter | Type | Default | Notes |
|
|
271
|
+
| --------------- | ------ | ---------- | --------------------------------------------------------------------------------------- |
|
|
272
|
+
| `url` | string | — | Required. Must be a valid URL. |
|
|
273
|
+
| `model` | string | — | Backend id: provider (`tavily`), catalog id (`tavily/fetch`), or combo (`fetch-combo`). |
|
|
274
|
+
| `provider` | string | — | Alias for `model`. |
|
|
275
|
+
| `format` | string | `markdown` | `markdown`, `text`, or `html`. |
|
|
276
|
+
| `maxCharacters` | number | `8000` | Truncation limit. |
|
|
236
277
|
|
|
237
278
|
### `generate_image`
|
|
238
279
|
|
|
239
|
-
Text-to-image generation.
|
|
280
|
+
Text-to-image generation. Writes the file, then hands it back per `return`: a base64 content block plus `{ outputPath, bytes, contentType }` under stdio, or a signed download URL under HTTP. Omit `outputPath` to write to the OS temp dir.
|
|
240
281
|
|
|
241
|
-
| Parameter | Type | Default | Notes
|
|
242
|
-
| ------------ | ------ | ----------- |
|
|
243
|
-
| `prompt` | string | — | Required.
|
|
244
|
-
| `model` | string | — | e.g. `openai/dall-e-3`, `gemini/gemini-3-pro-image-preview`.
|
|
245
|
-
| `provider` | string | — | Alias for `model`.
|
|
246
|
-
| `n` | number | `1` | 1–10.
|
|
247
|
-
| `size` | string | `1024x1024` |
|
|
248
|
-
| `quality` | string | — | `standard` or `hd`.
|
|
249
|
-
| `outputPath` | string | OS tmpdir | Where to write the file. Default `os.tmpdir()/ninerouter-<slug>-<ts>.<ext>`. Ext from content-type.
|
|
282
|
+
| Parameter | Type | Default | Notes |
|
|
283
|
+
| ------------ | ------ | ----------- | -------------------------------------------------------------------------------------------------------------------------- |
|
|
284
|
+
| `prompt` | string | — | Required. |
|
|
285
|
+
| `model` | string | — | e.g. `openai/dall-e-3`, `gemini/gemini-3-pro-image-preview`. |
|
|
286
|
+
| `provider` | string | — | Alias for `model`. |
|
|
287
|
+
| `n` | number | `1` | 1–10. |
|
|
288
|
+
| `size` | string | `1024x1024` | |
|
|
289
|
+
| `quality` | string | — | `standard` or `hd`. |
|
|
290
|
+
| `outputPath` | string | OS tmpdir | Where to write the file. Default `os.tmpdir()/ninerouter-<slug>-<ts>.<ext>`. Ext from content-type. |
|
|
291
|
+
| `return` | string | `auto` | `auto` returns a content block under stdio and a URL under HTTP; `inline` forces the block; `path` returns the path alone. |
|
|
250
292
|
|
|
251
293
|
### `text_to_speech`
|
|
252
294
|
|
|
253
|
-
Synthesize audio.
|
|
295
|
+
Synthesize audio. Writes the file, then hands it back per `return`: a base64 content block plus `{ outputPath, bytes, contentType }` under stdio, or a signed download URL under HTTP. Omit `outputPath` to write to the OS temp dir.
|
|
254
296
|
|
|
255
|
-
| Parameter | Type | Default | Notes
|
|
256
|
-
| ------------ | ------ | --------- |
|
|
257
|
-
| `input` | string | — | Required.
|
|
258
|
-
| `model` | string | — | e.g. `openai/tts-1`, `edge-tts/vi-VN-HoaiMyNeural`.
|
|
259
|
-
| `provider` | string | — | Alias for `model`.
|
|
260
|
-
| `outputPath` | string | OS tmpdir | Where to write the file. Default `os.tmpdir()/ninerouter-<slug>-<ts>.<ext>`. Ext from content-type.
|
|
297
|
+
| Parameter | Type | Default | Notes |
|
|
298
|
+
| ------------ | ------ | --------- | -------------------------------------------------------------------------------------------------------------------------- |
|
|
299
|
+
| `input` | string | — | Required. |
|
|
300
|
+
| `model` | string | — | e.g. `openai/tts-1`, `edge-tts/vi-VN-HoaiMyNeural`. |
|
|
301
|
+
| `provider` | string | — | Alias for `model`. |
|
|
302
|
+
| `outputPath` | string | OS tmpdir | Where to write the file. Default `os.tmpdir()/ninerouter-<slug>-<ts>.<ext>`. Ext from content-type. |
|
|
303
|
+
| `return` | string | `auto` | `auto` returns a content block under stdio and a URL under HTTP; `inline` forces the block; `path` returns the path alone. |
|
|
261
304
|
|
|
262
305
|
### `speech_to_text`
|
|
263
306
|
|
|
@@ -290,8 +333,9 @@ Generate embeddings for a string or a batch of strings.
|
|
|
290
333
|
## Behavior notes
|
|
291
334
|
|
|
292
335
|
- **Fallback chain.** For every model-using tool: if `model`/`provider` is set, only that model is tried. Otherwise the chain in `default_models.<tool>` is tried in order. If all entries fail, the tool throws an `All models failed. Errors: ...` error that includes every per-model message.
|
|
293
|
-
- **`provider` is an alias for `model
|
|
294
|
-
- **
|
|
336
|
+
- **`model` and `provider` are both optional.** `provider` is an alias for `model`. When neither is set, the tool uses its configured `default_models.<tool>` chain. Set `model` only to force a single backend, because either one overrides the chain.
|
|
337
|
+
- **Web ids come in two spellings.** 9Router's catalog publishes `tavily/search` and `tavily/fetch`, while its search and fetch endpoints want the provider itself (`tavily`). Both spellings work here: the action suffix is stripped before the request.
|
|
338
|
+
- **Image and audio go to a file first, then to the caller.** `generate_image` and `text_to_speech` request `b64_json` / `mp3` from upstream and write the bytes to `outputPath` (or the OS temp dir if you omit it). Under stdio the caller gets an MCP `image` / `audio` content block plus `{ outputPath, bytes, contentType }`; under HTTP it gets a signed, 15-minute download URL instead, because a path the server wrote may not exist on the client's machine. Start HTTP with `--local` when the client does share the server's filesystem (one server, many agents on one host), and the server returns the stdio shape. Set `return` on either tool to force a shape. Extension is derived from upstream `content-type`.
|
|
295
339
|
- **Config file wins over env vars.** If you need different settings for a single run, prefer `--config` over exporting env vars.
|
|
296
340
|
- **STT multipart upload.** The tool sends the audio as `multipart/form-data`; `fileName` only matters when the upstream provider inspects the filename.
|
|
297
341
|
- **`list_models` is trimmed and cached.** The tool caps its output (default `20`) and keeps the full upstream list in memory for about a minute. This is not server-side pagination: `/v1/models` ignores query params and always returns the whole catalog. The cap protects the context window, and the cache protects latency, since 9Router rebuilds the catalog on every request (observed ~7s, even for `/v1/models/<kind>`, which returns a tiny body). Pass `refresh: true` to force a refetch.
|
|
@@ -299,11 +343,11 @@ Generate embeddings for a string or a batch of strings.
|
|
|
299
343
|
|
|
300
344
|
## Troubleshooting
|
|
301
345
|
|
|
302
|
-
- **`NINEROUTER_URL is required
|
|
303
|
-
- **`No model specified and no default_models.<tool> configured
|
|
304
|
-
- **`All models failed. Errors:
|
|
305
|
-
- **Auth errors (401/403)
|
|
306
|
-
- **STT fails with "Provide audioPath or audioBase64"
|
|
346
|
+
- **`NINEROUTER_URL is required`**: set the env var or create `~/.config/ninerouter-mcp/config.toml` with `base_url`.
|
|
347
|
+
- **`No model specified and no default_models.<tool> configured`**: either pass `model` in the call or add a `default_models` entry to your config.
|
|
348
|
+
- **`All models failed. Errors: ...`**: every fallback model returned an error; the aggregated message includes each one for diagnosis.
|
|
349
|
+
- **Auth errors (401/403)**: your 9Router instance requires a key; set `NINEROUTER_KEY` or `api_key` in the config file.
|
|
350
|
+
- **STT fails with "Provide audioPath or audioBase64"**: exactly one of those two must be set.
|
|
307
351
|
|
|
308
352
|
## Development
|
|
309
353
|
|
package/dist/create-config.js
CHANGED
|
@@ -19,14 +19,15 @@ base_url = "http://localhost:20128"
|
|
|
19
19
|
# If user doesn't specify a model, these will be tried in order
|
|
20
20
|
[default_models]
|
|
21
21
|
|
|
22
|
-
# Web search - tries tavily first, falls back to
|
|
23
|
-
|
|
22
|
+
# Web search - tries tavily first, falls back to serper if tavily fails
|
|
23
|
+
# Ids are 9Router provider ids; catalog ids like "tavily/search" also work.
|
|
24
|
+
web_search = ["tavily", "serper"]
|
|
24
25
|
|
|
25
26
|
# Or use single string for just one default
|
|
26
|
-
# web_search = "tavily
|
|
27
|
+
# web_search = "tavily"
|
|
27
28
|
|
|
28
|
-
# Web fetch - tries
|
|
29
|
-
web_fetch = ["
|
|
29
|
+
# Web fetch - tries tavily first, then jina-reader
|
|
30
|
+
web_fetch = ["tavily", "jina-reader"]
|
|
30
31
|
|
|
31
32
|
# Image generation
|
|
32
33
|
generate_image = "openai/dall-e-3"
|
package/dist/http.js
CHANGED
|
@@ -1,11 +1,24 @@
|
|
|
1
1
|
import { createServer } from 'node:http';
|
|
2
2
|
import { randomUUID } from 'node:crypto';
|
|
3
|
+
import { readFile } from 'node:fs/promises';
|
|
3
4
|
import { WebStandardStreamableHTTPServerTransport, isJsonContentType, } from '@modelcontextprotocol/server';
|
|
4
5
|
import { getConfig, resolveConfigPath } from './ninerouter-client.js';
|
|
6
|
+
import { createUrlExposer, DownloadStore } from './lib/download-links.js';
|
|
7
|
+
import { LOCAL_RUNTIME } from './lib/media-output.js';
|
|
5
8
|
import { buildServer, registerTools } from './server.js';
|
|
9
|
+
/** Where signed download links are served from, alongside the MCP endpoint. */
|
|
10
|
+
const FILES_PATH = '/files';
|
|
11
|
+
function normalizePublicUrl(value) {
|
|
12
|
+
return value.replace(/\/+$/, '');
|
|
13
|
+
}
|
|
14
|
+
function isTruthy(value) {
|
|
15
|
+
return value === '1' || value?.toLowerCase() === 'true' || value?.toLowerCase() === 'yes';
|
|
16
|
+
}
|
|
6
17
|
function parseHttpArgs(argv) {
|
|
7
18
|
let port = 3000;
|
|
8
19
|
let host = '127.0.0.1';
|
|
20
|
+
let publicUrl = process.env.NINEROUTER_MCP_PUBLIC_URL?.trim() || undefined;
|
|
21
|
+
let local = isTruthy(process.env.NINEROUTER_MCP_LOCAL?.trim());
|
|
9
22
|
for (let index = 2; index < argv.length; index += 1) {
|
|
10
23
|
const argument = argv[index];
|
|
11
24
|
const nextValue = argv[index + 1];
|
|
@@ -25,8 +38,24 @@ function parseHttpArgs(argv) {
|
|
|
25
38
|
host = nextValue;
|
|
26
39
|
index += 1;
|
|
27
40
|
}
|
|
41
|
+
else if (argument === '--public-url') {
|
|
42
|
+
if (!nextValue)
|
|
43
|
+
throw new Error(`Missing value for --public-url.`);
|
|
44
|
+
publicUrl = nextValue;
|
|
45
|
+
index += 1;
|
|
46
|
+
}
|
|
47
|
+
else if (argument === '--local') {
|
|
48
|
+
local = true;
|
|
49
|
+
}
|
|
28
50
|
}
|
|
29
|
-
return {
|
|
51
|
+
return {
|
|
52
|
+
port,
|
|
53
|
+
host,
|
|
54
|
+
path: '/mcp',
|
|
55
|
+
configPath: resolveConfigPath(argv),
|
|
56
|
+
publicUrl: publicUrl ? normalizePublicUrl(publicUrl) : undefined,
|
|
57
|
+
local,
|
|
58
|
+
};
|
|
30
59
|
}
|
|
31
60
|
function toWebRequest(req) {
|
|
32
61
|
const host = req.headers.host ?? 'localhost';
|
|
@@ -99,13 +128,73 @@ function applyCors(res) {
|
|
|
99
128
|
res.setHeader(name, value);
|
|
100
129
|
}
|
|
101
130
|
}
|
|
131
|
+
/**
|
|
132
|
+
* Serve a generated file from a signed link. The HMAC and the expiry are the
|
|
133
|
+
* only authorization, so a browser or download tool can fetch the file without
|
|
134
|
+
* the MCP session that produced it.
|
|
135
|
+
*/
|
|
136
|
+
async function sendDownload(req, res, store, url) {
|
|
137
|
+
applyCors(res);
|
|
138
|
+
if (req.method !== 'GET' && req.method !== 'HEAD' && req.method !== 'OPTIONS') {
|
|
139
|
+
res.statusCode = 405;
|
|
140
|
+
res.setHeader('content-type', 'text/plain');
|
|
141
|
+
res.end('Method Not Allowed');
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
if (req.method === 'OPTIONS') {
|
|
145
|
+
res.statusCode = 204;
|
|
146
|
+
res.end();
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
store.prune();
|
|
150
|
+
const segments = url.pathname.slice(FILES_PATH.length + 1).split('/');
|
|
151
|
+
const [sessionId, id] = segments;
|
|
152
|
+
const expiresAt = Number(url.searchParams.get('exp'));
|
|
153
|
+
const token = url.searchParams.get('sig') ?? '';
|
|
154
|
+
const entry = segments.length === 2 && sessionId && id
|
|
155
|
+
? store.verify(sessionId, id, expiresAt, token)
|
|
156
|
+
: undefined;
|
|
157
|
+
if (!entry) {
|
|
158
|
+
res.statusCode = 403;
|
|
159
|
+
res.setHeader('content-type', 'text/plain');
|
|
160
|
+
res.end('Invalid or expired link');
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
let body;
|
|
164
|
+
try {
|
|
165
|
+
body = await readFile(entry.path);
|
|
166
|
+
}
|
|
167
|
+
catch {
|
|
168
|
+
res.statusCode = 404;
|
|
169
|
+
res.setHeader('content-type', 'text/plain');
|
|
170
|
+
res.end('File not found');
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
res.statusCode = 200;
|
|
174
|
+
res.setHeader('content-type', entry.contentType);
|
|
175
|
+
res.setHeader('content-length', String(body.length));
|
|
176
|
+
res.setHeader('content-disposition', `attachment; filename="${entry.fileName}"`);
|
|
177
|
+
res.setHeader('cache-control', 'private, no-store');
|
|
178
|
+
if (req.method === 'HEAD') {
|
|
179
|
+
res.end();
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
res.end(body);
|
|
183
|
+
}
|
|
102
184
|
async function main() {
|
|
103
185
|
const args = parseHttpArgs(process.argv);
|
|
104
186
|
const config = await getConfig({ configPath: args.configPath });
|
|
187
|
+
const downloads = args.local ? undefined : new DownloadStore();
|
|
188
|
+
const runtime = downloads
|
|
189
|
+
? {
|
|
190
|
+
remote: true,
|
|
191
|
+
expose: createUrlExposer(downloads, FILES_PATH, args.publicUrl, `http://${args.host}:${args.port}`),
|
|
192
|
+
}
|
|
193
|
+
: LOCAL_RUNTIME;
|
|
105
194
|
const sessions = new Map();
|
|
106
195
|
const createSession = async () => {
|
|
107
196
|
const server = buildServer();
|
|
108
|
-
registerTools(server, config);
|
|
197
|
+
registerTools(server, config, runtime);
|
|
109
198
|
const transport = new WebStandardStreamableHTTPServerTransport({
|
|
110
199
|
sessionIdGenerator: () => randomUUID(),
|
|
111
200
|
onsessioninitialized: (sessionId) => {
|
|
@@ -131,6 +220,18 @@ async function main() {
|
|
|
131
220
|
};
|
|
132
221
|
try {
|
|
133
222
|
const url = new URL(req.url ?? '/', 'http://localhost');
|
|
223
|
+
if (req.method === 'OPTIONS') {
|
|
224
|
+
applyCors(res);
|
|
225
|
+
res.statusCode = 204;
|
|
226
|
+
res.end();
|
|
227
|
+
log(204);
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
if (downloads && url.pathname.startsWith(`${FILES_PATH}/`)) {
|
|
231
|
+
await sendDownload(req, res, downloads, url);
|
|
232
|
+
log(res.statusCode);
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
134
235
|
if (url.pathname !== args.path) {
|
|
135
236
|
applyCors(res);
|
|
136
237
|
res.statusCode = 404;
|
|
@@ -139,13 +240,6 @@ async function main() {
|
|
|
139
240
|
log(404);
|
|
140
241
|
return;
|
|
141
242
|
}
|
|
142
|
-
if (req.method === 'OPTIONS') {
|
|
143
|
-
applyCors(res);
|
|
144
|
-
res.statusCode = 204;
|
|
145
|
-
res.end();
|
|
146
|
-
log(204);
|
|
147
|
-
return;
|
|
148
|
-
}
|
|
149
243
|
if (req.method === 'POST' && !isJsonContentType(req.headers['content-type'] ?? null)) {
|
|
150
244
|
applyCors(res);
|
|
151
245
|
res.statusCode = 415;
|
|
@@ -182,6 +276,9 @@ async function main() {
|
|
|
182
276
|
});
|
|
183
277
|
await new Promise((resolve) => server.listen(args.port, args.host, resolve));
|
|
184
278
|
console.error(`ninerouter-mcp HTTP listening on http://${args.host}:${args.port}${args.path}`);
|
|
279
|
+
console.error(downloads
|
|
280
|
+
? `generated media served from ${args.publicUrl ?? `http://${args.host}:${args.port}`}${FILES_PATH}/<session>/<id> (signed, 15 min)`
|
|
281
|
+
: 'generated media returned inline or by path (--local: client shares this filesystem)');
|
|
185
282
|
const shutdown = () => {
|
|
186
283
|
server.close(() => {
|
|
187
284
|
for (const { transport, server: s } of sessions.values()) {
|
|
@@ -1,6 +1,19 @@
|
|
|
1
1
|
export function toPrettyJson(value) {
|
|
2
2
|
return JSON.stringify(value, null, 2);
|
|
3
3
|
}
|
|
4
|
+
/**
|
|
5
|
+
* Attach notes about what was changed on the way to the provider, so a caller
|
|
6
|
+
* can tell an applied filter from a dropped one.
|
|
7
|
+
*/
|
|
8
|
+
export function attachWarnings(payload, warnings) {
|
|
9
|
+
if (warnings.length === 0) {
|
|
10
|
+
return payload;
|
|
11
|
+
}
|
|
12
|
+
if (typeof payload === 'object' && payload !== null && !Array.isArray(payload)) {
|
|
13
|
+
return { ...payload, warnings };
|
|
14
|
+
}
|
|
15
|
+
return { result: payload, warnings };
|
|
16
|
+
}
|
|
4
17
|
export async function tryModelsWithFallback(models, operation) {
|
|
5
18
|
const errors = [];
|
|
6
19
|
for (const model of models) {
|