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 +26 -4
- package/dist/create-config.js +7 -0
- package/dist/http.js +197 -0
- package/dist/index.js +9 -2
- package/dist/ninerouter-client.js +17 -0
- package/dist/server.js +18 -11
- package/dist/tools/embeddings.js +1 -1
- package/dist/tools/media.js +3 -3
- package/dist/tools/models.js +113 -7
- package/dist/tools/web.js +2 -2
- package/package.json +3 -1
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
|
-
|
|
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
|
-
|
|
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
|
package/dist/create-config.js
CHANGED
|
@@ -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 (
|
|
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
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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
|
}
|
package/dist/tools/embeddings.js
CHANGED
|
@@ -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))])
|
package/dist/tools/media.js
CHANGED
|
@@ -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.'),
|
package/dist/tools/models.js
CHANGED
|
@@ -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:
|
|
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
|
-
|
|
17
|
-
|
|
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(
|
|
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.
|
|
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",
|