ninerouter-mcp 1.2.0-beta.2 → 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 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 — no skill-file loading, no per-provider glue code.
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 — that is what the host model is for.
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 — only the top-level key and file path differ. Paste the block into the right key in your client's config:
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/search", "brave-search/search"]
132
- web_fetch = ["firecrawl/fetch", "jina-reader/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 | Default | Notes |
214
- | -------------- | ------ | ------- | ---------------------------------------------------------------------------------------------------- |
215
- | `query` | string | — | Required. |
216
- | `model` | string | — | 9Router model id (e.g. `tavily/search`). Falls back to `provider`, then `default_models.web_search`. |
217
- | `provider` | string | — | Alias for `model`. |
218
- | `maxResults` | number | `5` | 1–20. |
219
- | `searchType` | string | `web` | `web` or `news`. |
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 | — | e.g. `jina-reader/fetch`, `firecrawl/fetch`. |
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. Always writes a file and returns the image as a base64 content block plus `{ outputPath, bytes, contentType }`. Omit `outputPath` to write to the OS temp dir.
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. Always writes a file and returns the audio as a base64 content block plus `{ outputPath, bytes, contentType }`. Omit `outputPath` to write to the OS temp dir.
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`** on every tool that accepts a model. Set whichever reads better for your use case.
294
- - **Image and audio are always written to a file and returned as a content block.** `generate_image` and `text_to_speech` request `b64_json` / `mp3` from upstream, write the bytes to `outputPath` (or the OS temp dir if you omit it), and return the asset as an MCP `image` / `audio` content block plus `{ outputPath, bytes, contentType }`. The host can display inline or just use the path. Extension is derived from upstream `content-type`.
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`** — set the env var or create `~/.config/ninerouter-mcp/config.toml` with `base_url`.
303
- - **`No model specified and no default_models.<tool> configured`** — either pass `model` in the call or add a `default_models` entry to your config.
304
- - **`All models failed. Errors: ...`** — every fallback model returned an error; the aggregated message includes each one for diagnosis.
305
- - **Auth errors (401/403)** — your 9Router instance requires a key; set `NINEROUTER_KEY` or `api_key` in the config file.
306
- - **STT fails with "Provide audioPath or audioBase64"** — exactly one of those two must be set.
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
 
@@ -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 brave if tavily fails
23
- web_search = ["tavily/search", "brave-search/search"]
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/search"
27
+ # web_search = "tavily"
27
28
 
28
- # Web fetch - tries firecrawl first, then jina-reader
29
- web_fetch = ["firecrawl/fetch", "jina-reader/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 { port, host, path: '/mcp', configPath: resolveConfigPath(argv) };
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) {