ninerouter-mcp 1.1.0 → 1.2.0-beta.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -134,6 +134,12 @@ 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"]
136
136
  embeddings = "openai/text-embedding-3-small"
137
+
138
+ # Defaults for the list_models tool (all optional; these are the built-in values)
139
+ [list_models]
140
+ limit = 20 # default page size
141
+ offset = 0 # default start index
142
+ cache_ttl_seconds = 60 # 0 disables caching
137
143
  ```
138
144
 
139
145
  The `ninerouter` table is also accepted as an alias for top-level `base_url` and `api_key`:
@@ -179,11 +185,26 @@ Every tool is registered with the MCP server at startup. Unless noted, all tools
179
185
 
180
186
  ### `list_models`
181
187
 
182
- Discover valid model ids before calling other tools.
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.
189
+
190
+ | Parameter | Type | Required | Description |
191
+ | --------- | ------- | -------- | -------------------------------------------------------------------------------------------------------- |
192
+ | `kind` | string | no | One of `chat`, `image`, `tts`, `embedding`, `web`, `stt`, `image-to-text`. Omit for default chat models. |
193
+ | `search` | string | no | Case-insensitive substring match against model ids/names. |
194
+ | `limit` | number | no | Page size (default `20`, max `500`). Use `0` for the whole list. |
195
+ | `offset` | number | no | Index of the first model to return (default `0`). Use `nextOffset` to page. |
196
+ | `refresh` | boolean | no | Bypass the cache and refetch from 9Router. |
197
+
198
+ Defaults can be overridden in `config.toml`:
199
+
200
+ ```toml
201
+ [list_models]
202
+ limit = 20 # default page size
203
+ offset = 0 # default start index
204
+ cache_ttl_seconds = 60 # 0 disables caching
205
+ ```
183
206
 
184
- | Parameter | Type | Required | Description |
185
- | --------- | ------ | -------- | -------------------------------------------------------------------------------------------------------- |
186
- | `kind` | string | no | One of `chat`, `image`, `tts`, `embedding`, `web`, `stt`, `image-to-text`. Omit for default chat models. |
207
+ Built-in defaults (used when a key is omitted): `limit = 20`, `offset = 0`, `cache_ttl_seconds = 60`.
187
208
 
188
209
  ### `web_search`
189
210
 
@@ -273,6 +294,7 @@ Generate embeddings for a string or a batch of strings.
273
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`.
274
295
  - **Config file wins over env vars.** If you need different settings for a single run, prefer `--config` over exporting env vars.
275
296
  - **STT multipart upload.** The tool sends the audio as `multipart/form-data`; `fileName` only matters when the upstream provider inspects the filename.
297
+ - **`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.
276
298
  - **Config is read once at startup.** Edit `config.toml` or change `NINEROUTER_URL` / `NINEROUTER_KEY`, then restart the MCP server in your client. Hot-reload is not implemented.
277
299
 
278
300
  ## Troubleshooting
@@ -39,6 +39,13 @@ speech_to_text = ["openai/whisper-1", "groq/whisper-large-v3-turbo"]
39
39
 
40
40
  # Embeddings
41
41
  embeddings = "openai/text-embedding-3-small"
42
+
43
+ # Optional: defaults for the list_models tool
44
+ # Omitted keys fall back to the built-in defaults (limit 20, offset 0, cache 60s)
45
+ [list_models]
46
+ limit = 20
47
+ offset = 0
48
+ cache_ttl_seconds = 60
42
49
  `;
43
50
  async function createConfig() {
44
51
  try {
package/dist/http.js ADDED
@@ -0,0 +1,197 @@
1
+ import { createServer } from 'node:http';
2
+ import { randomUUID } from 'node:crypto';
3
+ import { WebStandardStreamableHTTPServerTransport, isJsonContentType, } from '@modelcontextprotocol/server';
4
+ import { getConfig, resolveConfigPath } from './ninerouter-client.js';
5
+ import { buildServer, registerTools } from './server.js';
6
+ function parseHttpArgs(argv) {
7
+ let port = 3000;
8
+ let host = '127.0.0.1';
9
+ for (let index = 2; index < argv.length; index += 1) {
10
+ const argument = argv[index];
11
+ const nextValue = argv[index + 1];
12
+ if (argument === '--port' || argument === '-p') {
13
+ if (!nextValue)
14
+ throw new Error(`Missing value for ${argument}.`);
15
+ const parsed = Number.parseInt(nextValue, 10);
16
+ if (!Number.isInteger(parsed) || parsed < 0 || parsed > 65535) {
17
+ throw new Error(`Invalid port: ${nextValue}`);
18
+ }
19
+ port = parsed;
20
+ index += 1;
21
+ }
22
+ else if (argument === '--host') {
23
+ if (!nextValue)
24
+ throw new Error(`Missing value for --host.`);
25
+ host = nextValue;
26
+ index += 1;
27
+ }
28
+ }
29
+ return { port, host, path: '/mcp', configPath: resolveConfigPath(argv) };
30
+ }
31
+ function toWebRequest(req) {
32
+ const host = req.headers.host ?? 'localhost';
33
+ const protocol = 'http';
34
+ const url = `${protocol}://${host}${req.url ?? '/'}`;
35
+ const headers = new Headers();
36
+ for (const [name, value] of Object.entries(req.headers)) {
37
+ if (value === undefined)
38
+ continue;
39
+ if (Array.isArray(value)) {
40
+ for (const v of value)
41
+ headers.append(name, v);
42
+ }
43
+ else {
44
+ headers.set(name, value);
45
+ }
46
+ }
47
+ const init = { method: req.method, headers };
48
+ if (req.method !== 'GET' && req.method !== 'HEAD') {
49
+ init.body = new ReadableStream({
50
+ start(controller) {
51
+ req.on('data', (chunk) => controller.enqueue(new Uint8Array(chunk)));
52
+ req.on('end', () => controller.close());
53
+ req.on('error', (error) => controller.error(error));
54
+ },
55
+ });
56
+ init.duplex = 'half';
57
+ }
58
+ return new Request(url, init);
59
+ }
60
+ async function sendWebResponse(nodeResponse, response) {
61
+ applyCors(nodeResponse);
62
+ nodeResponse.statusCode = response.status;
63
+ response.headers.forEach((value, name) => {
64
+ if (name.toLowerCase() === 'set-cookie')
65
+ return;
66
+ if (name.toLowerCase().startsWith('access-control-'))
67
+ return;
68
+ nodeResponse.setHeader(name, value);
69
+ });
70
+ const setCookies = response.headers.getSetCookie?.() ?? [];
71
+ for (const cookie of setCookies)
72
+ nodeResponse.appendHeader('set-cookie', cookie);
73
+ if (response.body) {
74
+ const reader = response.body.getReader();
75
+ try {
76
+ while (true) {
77
+ const { done, value } = await reader.read();
78
+ if (done)
79
+ break;
80
+ if (value)
81
+ nodeResponse.write(Buffer.from(value));
82
+ }
83
+ }
84
+ finally {
85
+ reader.releaseLock();
86
+ }
87
+ }
88
+ nodeResponse.end();
89
+ }
90
+ const CORS_HEADERS = {
91
+ 'access-control-allow-origin': '*',
92
+ 'access-control-allow-methods': 'GET, POST, DELETE, OPTIONS',
93
+ 'access-control-allow-headers': 'content-type, mcp-session-id, mcp-protocol-version, mcp-method, mcp-name, accept',
94
+ 'access-control-expose-headers': 'mcp-session-id, mcp-protocol-version',
95
+ 'access-control-max-age': '86400',
96
+ };
97
+ function applyCors(res) {
98
+ for (const [name, value] of Object.entries(CORS_HEADERS)) {
99
+ res.setHeader(name, value);
100
+ }
101
+ }
102
+ async function main() {
103
+ const args = parseHttpArgs(process.argv);
104
+ const config = await getConfig({ configPath: args.configPath });
105
+ const sessions = new Map();
106
+ const createSession = async () => {
107
+ const server = buildServer();
108
+ registerTools(server, config);
109
+ const transport = new WebStandardStreamableHTTPServerTransport({
110
+ sessionIdGenerator: () => randomUUID(),
111
+ onsessioninitialized: (sessionId) => {
112
+ sessions.set(sessionId, { transport, server });
113
+ },
114
+ onsessionclosed: (sessionId) => {
115
+ sessions.delete(sessionId);
116
+ },
117
+ });
118
+ await server.connect(transport);
119
+ return { transport, server };
120
+ };
121
+ const server = createServer(async (req, res) => {
122
+ const start = Date.now();
123
+ const log = (status) => {
124
+ const ms = Date.now() - start;
125
+ const size = Number(res.getHeader('content-length')) || 0;
126
+ const mcpMethod = req.headers['mcp-method'];
127
+ const tag = mcpMethod ? ` ${String(mcpMethod)}` : '';
128
+ const sid = req.headers['mcp-session-id'];
129
+ const sidTag = sid ? ` sid=${String(sid).slice(0, 8)}` : '';
130
+ process.stderr.write(`[${new Date().toISOString()}] ${req.method} ${req.url}${tag}${sidTag} → ${status} ${ms}ms ${size}B\n`);
131
+ };
132
+ try {
133
+ const url = new URL(req.url ?? '/', 'http://localhost');
134
+ if (url.pathname !== args.path) {
135
+ applyCors(res);
136
+ res.statusCode = 404;
137
+ res.setHeader('content-type', 'text/plain');
138
+ res.end('Not Found');
139
+ log(404);
140
+ return;
141
+ }
142
+ if (req.method === 'OPTIONS') {
143
+ applyCors(res);
144
+ res.statusCode = 204;
145
+ res.end();
146
+ log(204);
147
+ return;
148
+ }
149
+ if (req.method === 'POST' && !isJsonContentType(req.headers['content-type'] ?? null)) {
150
+ applyCors(res);
151
+ res.statusCode = 415;
152
+ res.setHeader('content-type', 'text/plain');
153
+ res.end('Content-Type must be application/json');
154
+ log(415);
155
+ return;
156
+ }
157
+ const sessionHeader = req.headers['mcp-session-id'];
158
+ const sessionId = typeof sessionHeader === 'string' ? sessionHeader : undefined;
159
+ let transport;
160
+ if (sessionId && sessions.has(sessionId)) {
161
+ transport = sessions.get(sessionId).transport;
162
+ }
163
+ else {
164
+ const created = await createSession();
165
+ transport = created.transport;
166
+ }
167
+ const request = toWebRequest(req);
168
+ const response = await transport.handleRequest(request);
169
+ await sendWebResponse(res, response);
170
+ log(response.status);
171
+ }
172
+ catch (error) {
173
+ console.error(error);
174
+ if (!res.headersSent) {
175
+ applyCors(res);
176
+ res.statusCode = 500;
177
+ res.setHeader('content-type', 'text/plain');
178
+ }
179
+ res.end('Internal Server Error');
180
+ log(500);
181
+ }
182
+ });
183
+ await new Promise((resolve) => server.listen(args.port, args.host, resolve));
184
+ console.error(`ninerouter-mcp HTTP listening on http://${args.host}:${args.port}${args.path}`);
185
+ const shutdown = () => {
186
+ server.close(() => {
187
+ for (const { transport, server: s } of sessions.values()) {
188
+ void transport.close();
189
+ void s.close?.();
190
+ }
191
+ process.exit(0);
192
+ });
193
+ };
194
+ process.on('SIGINT', shutdown);
195
+ process.on('SIGTERM', shutdown);
196
+ }
197
+ void main();
package/dist/index.js CHANGED
@@ -1,10 +1,17 @@
1
1
  #!/usr/bin/env node
2
+ function hasFlag(flag) {
3
+ return process.argv.includes(flag);
4
+ }
2
5
  // Check if user wants to create config
3
- if (process.argv.includes('create-config')) {
6
+ if (hasFlag('create-config')) {
4
7
  await import('./create-config.js');
5
8
  }
9
+ else if (hasFlag('--http')) {
10
+ // Start MCP server over Streamable HTTP
11
+ await import('./http.js');
12
+ }
6
13
  else {
7
- // Start MCP server
14
+ // Start MCP server over stdio
8
15
  await import('./server.js');
9
16
  }
10
17
  export {};
@@ -3,6 +3,12 @@ import os from 'node:os';
3
3
  import path from 'node:path';
4
4
  import { parse as parseToml } from 'toml';
5
5
  const DEFAULT_CONFIG_PATH = path.join(os.homedir(), '.config', 'ninerouter-mcp', 'config.toml');
6
+ // Built-in defaults for `list_models`; config.toml `[list_models]` overrides these.
7
+ export const LIST_MODELS_DEFAULTS = {
8
+ limit: 20,
9
+ offset: 0,
10
+ cacheTtlMs: 60_000,
11
+ };
6
12
  export function getDefaultConfigPath() {
7
13
  return DEFAULT_CONFIG_PATH;
8
14
  }
@@ -72,10 +78,21 @@ export async function getConfig(options = {}) {
72
78
  embeddings: normalizeToArray(configFile.default_models.embeddings),
73
79
  }
74
80
  : undefined;
81
+ const listModelsFile = configFile?.list_models;
82
+ const listModels = listModelsFile
83
+ ? {
84
+ limit: listModelsFile.limit,
85
+ offset: listModelsFile.offset,
86
+ cacheTtlMs: typeof listModelsFile.cache_ttl_seconds === 'number'
87
+ ? Math.round(listModelsFile.cache_ttl_seconds * 1000)
88
+ : undefined,
89
+ }
90
+ : undefined;
75
91
  return {
76
92
  baseUrl: normalizeBaseUrl(baseUrl),
77
93
  apiKey: apiKey ? apiKey : undefined,
78
94
  defaultModels,
95
+ listModels,
79
96
  };
80
97
  }
81
98
  function buildUrl(baseUrl, pathname, query) {
package/dist/server.js CHANGED
@@ -5,21 +5,28 @@ import { registerEmbeddingTools } from './tools/embeddings.js';
5
5
  import { registerMediaTools } from './tools/media.js';
6
6
  import { registerModelTools } from './tools/models.js';
7
7
  import { registerWebTools } from './tools/web.js';
8
- const server = new McpServer({
9
- name: 'ninerouter-mcp',
10
- version: '1.0.0',
11
- }, {
12
- capabilities: {
13
- tools: {},
14
- },
15
- });
16
- async function main() {
17
- const configPath = resolveConfigPath(process.argv);
18
- const config = await getConfig({ configPath });
8
+ export function buildServer() {
9
+ const server = new McpServer({
10
+ name: 'ninerouter-mcp',
11
+ version: '1.0.0',
12
+ }, {
13
+ capabilities: {
14
+ tools: {},
15
+ },
16
+ });
17
+ return server;
18
+ }
19
+ export function registerTools(server, config) {
19
20
  registerModelTools(server, config);
20
21
  registerWebTools(server, config);
21
22
  registerMediaTools(server, config);
22
23
  registerEmbeddingTools(server, config);
24
+ }
25
+ async function main() {
26
+ const configPath = resolveConfigPath(process.argv);
27
+ const config = await getConfig({ configPath });
28
+ const server = buildServer();
29
+ registerTools(server, config);
23
30
  const transport = new StdioServerTransport();
24
31
  await server.connect(transport);
25
32
  }
@@ -8,7 +8,7 @@ export function registerEmbeddingTools(server, config) {
8
8
  model: z
9
9
  .string()
10
10
  .optional()
11
- .describe('Embedding model id, for example openai/text-embedding-3-small.'),
11
+ .describe('Embedding model id, for example openai/text-embedding-3-small. Omit it to use the configured default_models.embeddings chain.'),
12
12
  provider: z.string().optional().describe('Alias for model.'),
13
13
  input: z
14
14
  .union([z.string(), z.array(z.string().min(1))])
@@ -41,7 +41,7 @@ export function registerMediaTools(server, config) {
41
41
  model: z
42
42
  .string()
43
43
  .optional()
44
- .describe('Image model id, for example gemini/gemini-3-pro-image-preview or openai/dall-e-3.'),
44
+ .describe('Image model id, for example gemini/gemini-3-pro-image-preview or openai/dall-e-3. Omit it to use the configured default_models.generate_image chain.'),
45
45
  provider: z.string().optional().describe('Alias for model.'),
46
46
  n: z.number().int().positive().max(10).optional().default(1),
47
47
  size: z.string().optional().default('1024x1024'),
@@ -105,7 +105,7 @@ export function registerMediaTools(server, config) {
105
105
  model: z
106
106
  .string()
107
107
  .optional()
108
- .describe('TTS model or voice id, for example openai/tts-1 or edge-tts/vi-VN-HoaiMyNeural.'),
108
+ .describe('TTS model or voice id, for example openai/tts-1 or edge-tts/vi-VN-HoaiMyNeural. Omit it to use the configured default_models.text_to_speech chain.'),
109
109
  provider: z.string().optional().describe('Alias for model.'),
110
110
  outputPath: z
111
111
  .string()
@@ -156,7 +156,7 @@ export function registerMediaTools(server, config) {
156
156
  model: z
157
157
  .string()
158
158
  .optional()
159
- .describe('STT model id, for example openai/whisper-1 or groq/whisper-large-v3-turbo.'),
159
+ .describe('STT model id, for example openai/whisper-1 or groq/whisper-large-v3-turbo. Omit it to use the configured default_models.speech_to_text chain.'),
160
160
  provider: z.string().optional().describe('Alias for model.'),
161
161
  audioPath: z.string().optional().describe('Local path to an audio file.'),
162
162
  audioBase64: z.string().optional().describe('Base64-encoded audio payload.'),
@@ -1,22 +1,128 @@
1
1
  import { z } from 'zod/v4';
2
- import { requestGetJson } from '../ninerouter-client.js';
2
+ import { LIST_MODELS_DEFAULTS, requestGetJson, } from '../ninerouter-client.js';
3
3
  import { toPrettyJson } from './common.js';
4
4
  const MODEL_KINDS = ['chat', 'image', 'tts', 'embedding', 'web', 'stt', 'image-to-text'];
5
+ // Upper bound for an explicit `limit`; `0` still means "everything".
6
+ const MAX_LIMIT = 500;
7
+ // `/v1/models` rebuilds the whole catalog per request (~7s observed), and the payload
8
+ // is tiny (~0.5 MB), so cache the full list briefly instead of refetching on every call.
9
+ const LIST_KEYS = ['data', 'models'];
10
+ function isRecord(value) {
11
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
12
+ }
13
+ // Best-effort label for an entry so `search` can match strings and objects alike.
14
+ function entryLabel(item) {
15
+ if (typeof item === 'string') {
16
+ return item;
17
+ }
18
+ if (!isRecord(item)) {
19
+ return undefined;
20
+ }
21
+ for (const key of ['id', 'name', 'model']) {
22
+ const value = item[key];
23
+ if (typeof value === 'string') {
24
+ return value;
25
+ }
26
+ }
27
+ return undefined;
28
+ }
29
+ // Locate the array of models in the payload (the payload itself, or a `data`/`models` field).
30
+ function findListArray(payload) {
31
+ if (Array.isArray(payload)) {
32
+ return { items: payload };
33
+ }
34
+ if (isRecord(payload)) {
35
+ for (const key of LIST_KEYS) {
36
+ const value = payload[key];
37
+ if (Array.isArray(value)) {
38
+ return { key, items: value };
39
+ }
40
+ }
41
+ }
42
+ return null;
43
+ }
5
44
  export function registerModelTools(server, config) {
45
+ const cache = new Map();
46
+ const defaultLimit = config.listModels?.limit ?? LIST_MODELS_DEFAULTS.limit;
47
+ const defaultOffset = config.listModels?.offset ?? LIST_MODELS_DEFAULTS.offset;
48
+ const cacheTtlMs = config.listModels?.cacheTtlMs ?? LIST_MODELS_DEFAULTS.cacheTtlMs;
49
+ // The cache is what makes repeat calls cheap; `refresh` lets a caller force a refetch.
50
+ const fetchModels = async (kind, refresh) => {
51
+ const cacheKey = kind ?? '';
52
+ const cached = cache.get(cacheKey);
53
+ if (!refresh && cached && cached.expires > Date.now()) {
54
+ return cached.payload;
55
+ }
56
+ const payload = await requestGetJson(config, kind ? `/v1/models/${kind}` : '/v1/models');
57
+ if (cacheTtlMs > 0) {
58
+ cache.set(cacheKey, { expires: Date.now() + cacheTtlMs, payload });
59
+ }
60
+ return payload;
61
+ };
6
62
  server.registerTool('list_models', {
7
- description: 'List available model ids. Call this first to discover a valid `model` value before using web_search, web_fetch, generate_image, text_to_speech, speech_to_text, or embeddings. Pass `kind` to filter by capability.',
63
+ description: `Look up model ids exposed by 9Router. This tool is optional: the other tools work without it because each has a configured default model or fallback chain. Call it only to target a specific model, or to see which ids exist. \`kind\` filters by capability; \`search\` filters by name. Then \`offset\` and \`limit\` page through the matches (page size defaults to ${LIST_MODELS_DEFAULTS.limit}; \`limit: 0\` returns everything). Each response reports \`total\`, \`count\`, \`offset\`, and \`nextOffset\`, which is absent on the last page. Responses come from a short-lived cache, so repeat calls are fast.`,
8
64
  inputSchema: z.object({
9
65
  kind: z
10
66
  .enum(MODEL_KINDS)
11
67
  .optional()
12
68
  .describe('Optional model category. Omit to list default chat models.'),
69
+ search: z
70
+ .string()
71
+ .optional()
72
+ .describe('Case-insensitive substring match against model ids/names.'),
73
+ limit: z
74
+ .number()
75
+ .int()
76
+ .nonnegative()
77
+ .max(MAX_LIMIT)
78
+ .optional()
79
+ .describe(`Page size (config default ${defaultLimit}, max ${MAX_LIMIT}). Use 0 for the whole list.`),
80
+ offset: z
81
+ .number()
82
+ .int()
83
+ .nonnegative()
84
+ .optional()
85
+ .describe(`Index of the first model to return (config default ${defaultOffset}). Use \`nextOffset\` to page.`),
86
+ refresh: z
87
+ .boolean()
88
+ .optional()
89
+ .describe('Bypass the cache and refetch from 9Router.'),
13
90
  }),
14
- }, async ({ kind }) => {
15
- const payload = kind
16
- ? await requestGetJson(config, `/v1/models/${kind}`)
17
- : await requestGetJson(config, '/v1/models');
91
+ }, async ({ kind, search, limit, offset, refresh }) => {
92
+ const payload = await fetchModels(kind, refresh ?? false);
93
+ const found = findListArray(payload);
94
+ if (!found) {
95
+ // Not a list-shaped response (such as voices or a single object), so return it as-is.
96
+ return {
97
+ content: [{ type: 'text', text: toPrettyJson(payload) }],
98
+ };
99
+ }
100
+ const { key, items } = found;
101
+ const needle = search?.trim().toLowerCase();
102
+ const matched = needle
103
+ ? items.filter((item) => entryLabel(item)?.toLowerCase().includes(needle))
104
+ : items;
105
+ const effectiveLimit = (limit ?? defaultLimit) || matched.length;
106
+ const effectiveOffset = Math.min(offset ?? defaultOffset, matched.length);
107
+ const shown = matched.slice(effectiveOffset, effectiveOffset + effectiveLimit);
108
+ const nextOffset = effectiveOffset + shown.length;
109
+ const hasMore = nextOffset < matched.length;
110
+ const result = isRecord(payload)
111
+ ? { ...payload, [key ?? 'data']: shown }
112
+ : { object: 'list', data: shown };
113
+ result.total = items.length;
114
+ result.count = shown.length;
115
+ result.offset = effectiveOffset;
116
+ if (needle) {
117
+ result.matched = matched.length;
118
+ result.search = search;
119
+ }
120
+ if (hasMore) {
121
+ result.nextOffset = nextOffset;
122
+ result.hint = `Returned ${shown.length} of ${matched.length}${needle ? ' matched' : ''} models. Call again with \`offset: ${nextOffset}\` for the next page, or raise \`limit\` (0 = all).`;
123
+ }
18
124
  return {
19
- content: [{ type: 'text', text: toPrettyJson(payload) }],
125
+ content: [{ type: 'text', text: toPrettyJson(result) }],
20
126
  };
21
127
  });
22
128
  }
package/dist/tools/web.js CHANGED
@@ -9,7 +9,7 @@ export function registerWebTools(server, config) {
9
9
  model: z
10
10
  .string()
11
11
  .optional()
12
- .describe('Web-search model id, for example tavily/search or brave-search/search.'),
12
+ .describe('Web-search model id, for example tavily/search or brave-search/search. Omit it to use the configured default_models.web_search chain.'),
13
13
  provider: z.string().optional().describe('Alias for model.'),
14
14
  maxResults: z.number().int().positive().max(20).optional().default(5),
15
15
  searchType: z.enum(['web', 'news']).optional().default('web'),
@@ -48,7 +48,7 @@ export function registerWebTools(server, config) {
48
48
  model: z
49
49
  .string()
50
50
  .optional()
51
- .describe('Web-fetch model id, for example jina-reader/fetch or firecrawl/fetch.'),
51
+ .describe('Web-fetch model id, for example jina-reader/fetch or firecrawl/fetch. Omit it to use the configured default_models.web_fetch chain.'),
52
52
  provider: z.string().optional().describe('Alias for model.'),
53
53
  format: z.enum(['markdown', 'text', 'html']).optional().default('markdown'),
54
54
  maxCharacters: z.number().int().nonnegative().optional().default(8000),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ninerouter-mcp",
3
- "version": "1.1.0",
3
+ "version": "1.2.0-beta.2",
4
4
  "type": "module",
5
5
  "description": "MCP server for 9Router with model discovery, automatic fallback, and type-safe validation",
6
6
  "license": "Apache-2.0",
@@ -59,9 +59,11 @@
59
59
  },
60
60
  "devDependencies": {
61
61
  "@eslint/js": "^10.0.1",
62
+ "@semantic-release/exec": "^7.1.0",
62
63
  "@semantic-release/git": "^10.0.1",
63
64
  "@semantic-release/npm": "^13.1.5",
64
65
  "@types/node": "^22.20.0",
66
+ "conventional-changelog-conventionalcommits": "^9.1.0",
65
67
  "eslint": "^10.7.0",
66
68
  "husky": "^9.1.7",
67
69
  "lint-staged": "^16.0.0",