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.
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Web provider id resolution: 9Router publishes catalog ids like `tavily/search`,
3
+ * but `/v1/search` wants the provider and rejects the catalog id outright.
4
+ */
5
+ const WEB_ACTIONS = ['search', 'fetch'];
6
+ export function resolveWebModelId(requested) {
7
+ const trimmed = requested.trim();
8
+ const separator = trimmed.lastIndexOf('/');
9
+ if (separator > 0) {
10
+ const suffix = trimmed.slice(separator + 1).toLowerCase();
11
+ if (WEB_ACTIONS.includes(suffix)) {
12
+ return { id: trimmed.slice(0, separator), action: suffix };
13
+ }
14
+ }
15
+ return { id: trimmed };
16
+ }
package/dist/server.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { McpServer } from '@modelcontextprotocol/server';
2
2
  import { StdioServerTransport } from '@modelcontextprotocol/server/stdio';
3
3
  import { getConfig, resolveConfigPath } from './ninerouter-client.js';
4
+ import { LOCAL_RUNTIME } from './lib/media-output.js';
4
5
  import { registerEmbeddingTools } from './tools/embeddings.js';
5
6
  import { registerMediaTools } from './tools/media.js';
6
7
  import { registerModelTools } from './tools/models.js';
@@ -16,10 +17,10 @@ export function buildServer() {
16
17
  });
17
18
  return server;
18
19
  }
19
- export function registerTools(server, config) {
20
+ export function registerTools(server, config, runtime = LOCAL_RUNTIME) {
20
21
  registerModelTools(server, config);
21
22
  registerWebTools(server, config);
22
- registerMediaTools(server, config);
23
+ registerMediaTools(server, config, runtime);
23
24
  registerEmbeddingTools(server, config);
24
25
  }
25
26
  async function main() {
@@ -1,15 +1,18 @@
1
1
  import { z } from 'zod/v4';
2
2
  import { requestJson } from '../ninerouter-client.js';
3
- import { tryModelsWithFallback, toPrettyJson } from './common.js';
3
+ import { tryModelsWithFallback, toPrettyJson } from '../lib/common.js';
4
4
  export function registerEmbeddingTools(server, config) {
5
5
  server.registerTool('embeddings', {
6
- description: 'Generate vector embeddings for a single string or a batch of strings. Returns the raw upstream payload (vectors, usage). Use for retrieval, semantic search, clustering, or similarity. Pass `model` (alias `provider`) to pick an embedding model (e.g. `openai/text-embedding-3-small`); otherwise the configured `default_models.embeddings` fallback chain is tried in order.',
6
+ description: 'Generate vector embeddings for a single string or a batch of strings. Returns the raw upstream payload (vectors, usage). Use for retrieval, semantic search, clustering, or similarity. Leave `model` unset to use the configured `default_models.embeddings` fallback chain; set it only to force an embedding model (e.g. `openai/text-embedding-3-small`).',
7
7
  inputSchema: z.object({
8
8
  model: z
9
9
  .string()
10
10
  .optional()
11
- .describe('Embedding model id, for example openai/text-embedding-3-small. Omit it to use the configured default_models.embeddings chain.'),
12
- provider: z.string().optional().describe('Alias for model.'),
11
+ .describe('Omit it to use the configured default_models.embeddings chain. Set it to force an embedding model, for example openai/text-embedding-3-small.'),
12
+ provider: z
13
+ .string()
14
+ .optional()
15
+ .describe('Alias for `model`. Omit it to use the configured default_models.embeddings chain.'),
13
16
  input: z
14
17
  .union([z.string(), z.array(z.string().min(1))])
15
18
  .describe('Text input or a batch of text inputs.'),
@@ -3,7 +3,8 @@ import os from 'node:os';
3
3
  import path from 'node:path';
4
4
  import { z } from 'zod/v4';
5
5
  import { createFileBlob, fileNameFromPath, requestBinary, requestJson, requestMultipartText, } from '../ninerouter-client.js';
6
- import { tryModelsWithFallback, toPrettyJson } from './common.js';
6
+ import { tryModelsWithFallback, toPrettyJson } from '../lib/common.js';
7
+ import { LOCAL_RUNTIME, resolveMediaDelivery, } from '../lib/media-output.js';
7
8
  function defaultOutputPath(prefix, ext) {
8
9
  const safe = prefix
9
10
  .toLowerCase()
@@ -33,16 +34,60 @@ function extFromContentType(contentType, fallback) {
33
34
  };
34
35
  return map[contentType.split(';')[0].trim().toLowerCase()] ?? fallback;
35
36
  }
36
- export function registerMediaTools(server, config) {
37
+ const RETURN_MODES = ['auto', 'path', 'inline'];
38
+ function returnParam() {
39
+ return z
40
+ .enum(RETURN_MODES)
41
+ .optional()
42
+ .default('auto')
43
+ .describe('How the bytes reach you. `auto` returns a content block under stdio and a signed download URL under HTTP; `inline` forces the content block; `path` returns the server-side path alone.');
44
+ }
45
+ /**
46
+ * Hand back a written file as a content block, a signed download URL, or the
47
+ * path alone. A URL replaces `outputPath` because the client is on another host
48
+ * and cannot open a path the server wrote.
49
+ */
50
+ async function mediaResult(input) {
51
+ const content = [];
52
+ if (input.delivery.inline) {
53
+ content.push({ type: input.blockType, data: input.data, mimeType: input.mimeType });
54
+ }
55
+ const details = {
56
+ bytes: input.bytes,
57
+ contentType: input.contentType,
58
+ };
59
+ if (input.delivery.url && input.runtime.expose) {
60
+ const exposed = input.runtime.expose({
61
+ path: input.filePath,
62
+ contentType: input.contentType,
63
+ fileName: path.basename(input.filePath),
64
+ sessionId: input.ctx?.sessionId,
65
+ request: input.ctx?.http?.req,
66
+ });
67
+ details.url = exposed.location;
68
+ if (exposed.expiresAt !== undefined) {
69
+ details.urlExpiresAt = new Date(exposed.expiresAt).toISOString();
70
+ }
71
+ }
72
+ else {
73
+ details.outputPath = input.filePath;
74
+ }
75
+ content.push({ type: 'text', text: toPrettyJson(details) });
76
+ return { content };
77
+ }
78
+ export function registerMediaTools(server, config, runtime = LOCAL_RUNTIME) {
37
79
  server.registerTool('generate_image', {
38
- description: 'Generate an image from a text prompt. Always writes the file and returns the image as a base64 content block plus `{ outputPath, bytes, contentType }`. Omit `outputPath` to write to a temp file with extension auto-derived from upstream content-type (.png default).',
80
+ description: 'Generate an image from a text prompt. Writes the file, then hands it back per `return`: a base64 content block under stdio (plus `{ outputPath, bytes, contentType }`), or a signed, time-limited download URL under HTTP. Omit `outputPath` to write to a temp file with extension auto-derived from upstream content-type (.png default).',
39
81
  inputSchema: z.object({
40
82
  prompt: z.string().min(1).describe('Image prompt.'),
41
83
  model: z
42
84
  .string()
43
85
  .optional()
44
86
  .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
- provider: z.string().optional().describe('Alias for model.'),
87
+ provider: z
88
+ .string()
89
+ .optional()
90
+ .describe('Alias for `model`. Omit it to use the configured default_models.generate_image chain.'),
46
91
  n: z.number().int().positive().max(10).optional().default(1),
47
92
  size: z.string().optional().default('1024x1024'),
48
93
  quality: z.enum(['standard', 'hd']).optional(),
@@ -50,8 +95,9 @@ export function registerMediaTools(server, config) {
50
95
  .string()
51
96
  .optional()
52
97
  .describe('Where to write the image. Default: temp dir, extension derived from upstream content-type.'),
98
+ return: returnParam(),
53
99
  }),
54
- }, async ({ prompt, model, provider, n, size, quality, outputPath }) => {
100
+ }, async ({ prompt, model, provider, n, size, quality, outputPath, return: returnMode }, ctx) => {
55
101
  const userModel = model ?? provider;
56
102
  const defaultModels = config.defaultModels?.generateImage ?? [];
57
103
  const modelsToTry = userModel ? [userModel] : defaultModels;
@@ -80,39 +126,37 @@ export function registerMediaTools(server, config) {
80
126
  const ext = extFromContentType(result.contentType, '.png');
81
127
  const resolvedPath = outputPath ?? defaultOutputPath(prompt, ext);
82
128
  await writeFile(resolvedPath, result.bytes);
83
- return {
84
- content: [
85
- {
86
- type: 'image',
87
- data: result.b64,
88
- mimeType: result.contentType ?? 'image/png',
89
- },
90
- {
91
- type: 'text',
92
- text: toPrettyJson({
93
- outputPath: resolvedPath,
94
- bytes: result.bytes.length,
95
- contentType: result.contentType,
96
- }),
97
- },
98
- ],
99
- };
129
+ return await mediaResult({
130
+ runtime,
131
+ delivery: resolveMediaDelivery(returnMode, runtime),
132
+ blockType: 'image',
133
+ data: result.b64,
134
+ mimeType: result.contentType ?? 'image/png',
135
+ filePath: resolvedPath,
136
+ bytes: result.bytes.length,
137
+ contentType: result.contentType,
138
+ ctx,
139
+ });
100
140
  });
101
141
  server.registerTool('text_to_speech', {
102
- description: 'Convert text to speech. Always writes the audio file and returns `{ outputPath, bytes, contentType }`. Omit `outputPath` to write to a temp file as .mp3; pass a path to control location.',
142
+ description: 'Convert text to speech. Writes the audio file, then hands it back per `return`: a base64 audio content block under stdio (plus `{ outputPath, bytes, contentType }`), or a signed, time-limited download URL under HTTP. Omit `outputPath` to write to a temp file as .mp3; pass a path to control location.',
103
143
  inputSchema: z.object({
104
144
  input: z.string().min(1).describe('Text to synthesize.'),
105
145
  model: z
106
146
  .string()
107
147
  .optional()
108
148
  .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
- provider: z.string().optional().describe('Alias for model.'),
149
+ provider: z
150
+ .string()
151
+ .optional()
152
+ .describe('Alias for `model`. Omit it to use the configured default_models.text_to_speech chain.'),
110
153
  outputPath: z
111
154
  .string()
112
155
  .optional()
113
156
  .describe('Where to write the audio. Default: temp dir, extension derived from upstream content-type (.mp3 default).'),
157
+ return: returnParam(),
114
158
  }),
115
- }, async ({ input, model, provider, outputPath }) => {
159
+ }, async ({ input, model, provider, outputPath, return: returnMode }, ctx) => {
116
160
  const userModel = model ?? provider;
117
161
  const defaultModels = config.defaultModels?.textToSpeech ?? [];
118
162
  const modelsToTry = userModel ? [userModel] : defaultModels;
@@ -131,33 +175,30 @@ export function registerMediaTools(server, config) {
131
175
  const ext = extFromContentType(result.contentType, '.mp3');
132
176
  const resolvedPath = outputPath ?? defaultOutputPath(input, ext);
133
177
  await writeFile(resolvedPath, result.bytes);
134
- return {
135
- content: [
136
- {
137
- type: 'audio',
138
- data: result.b64,
139
- mimeType: result.contentType,
140
- },
141
- {
142
- type: 'text',
143
- text: toPrettyJson({
144
- outputPath: resolvedPath,
145
- bytes: result.bytes.length,
146
- contentType: result.contentType,
147
- }),
148
- },
149
- ],
150
- };
178
+ return await mediaResult({
179
+ runtime,
180
+ delivery: resolveMediaDelivery(returnMode, runtime),
181
+ blockType: 'audio',
182
+ data: result.b64,
183
+ mimeType: result.contentType,
184
+ filePath: resolvedPath,
185
+ bytes: result.bytes.length,
186
+ contentType: result.contentType,
187
+ ctx,
188
+ });
151
189
  });
152
190
  server.registerTool('speech_to_text', {
153
- description: 'Transcribe audio. Provide exactly one of `audioPath` (local file) or `audioBase64` (base64 payload); both are sent as multipart upload. `responseFormat` selects the output shape (`json`, `text`, `verbose_json`, `srt`, `vtt`). Pass `model` (alias `provider`) to pick a transcriber (e.g. `openai/whisper-1`, `groq/whisper-large-v3-turbo`); otherwise the configured `default_models.speech_to_text` fallback chain is tried in order.',
191
+ description: 'Transcribe audio. Provide exactly one of `audioPath` (local file) or `audioBase64` (base64 payload); both are sent as multipart upload. `responseFormat` selects the output shape (`json`, `text`, `verbose_json`, `srt`, `vtt`). Leave `model` unset to use the configured `default_models.speech_to_text` fallback chain; set it only to force a transcriber (e.g. `openai/whisper-1`, `groq/whisper-large-v3-turbo`).',
154
192
  inputSchema: z
155
193
  .object({
156
194
  model: z
157
195
  .string()
158
196
  .optional()
159
197
  .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
- provider: z.string().optional().describe('Alias for model.'),
198
+ provider: z
199
+ .string()
200
+ .optional()
201
+ .describe('Alias for `model`. Omit it to use the configured default_models.speech_to_text chain.'),
161
202
  audioPath: z.string().optional().describe('Local path to an audio file.'),
162
203
  audioBase64: z.string().optional().describe('Base64-encoded audio payload.'),
163
204
  fileName: z
@@ -1,6 +1,7 @@
1
1
  import { z } from 'zod/v4';
2
2
  import { LIST_MODELS_DEFAULTS, requestGetJson, } from '../ninerouter-client.js';
3
- import { toPrettyJson } from './common.js';
3
+ import { toPrettyJson } from '../lib/common.js';
4
+ import { resolveWebModelId } from '../lib/web-model.js';
4
5
  const MODEL_KINDS = ['chat', 'image', 'tts', 'embedding', 'web', 'stt', 'image-to-text'];
5
6
  // Upper bound for an explicit `limit`; `0` still means "everything".
6
7
  const MAX_LIMIT = 500;
@@ -41,6 +42,14 @@ function findListArray(payload) {
41
42
  }
42
43
  return null;
43
44
  }
45
+ // Web catalog ids carry the action (`tavily/search`); the web tools want the
46
+ // bare provider, so add `model` for the caller to pass through.
47
+ function annotateWebEntry(item, kind) {
48
+ if (kind !== 'web' || !isRecord(item) || typeof item.id !== 'string') {
49
+ return item;
50
+ }
51
+ return { ...item, model: resolveWebModelId(item.id).id };
52
+ }
44
53
  export function registerModelTools(server, config) {
45
54
  const cache = new Map();
46
55
  const defaultLimit = config.listModels?.limit ?? LIST_MODELS_DEFAULTS.limit;
@@ -60,7 +69,7 @@ export function registerModelTools(server, config) {
60
69
  return payload;
61
70
  };
62
71
  server.registerTool('list_models', {
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.`,
72
+ 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. Entries returned for \`kind: "web"\` also carry \`model\`, the id to hand to \`web_search\` or \`web_fetch\`. 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.`,
64
73
  inputSchema: z.object({
65
74
  kind: z
66
75
  .enum(MODEL_KINDS)
@@ -104,7 +113,9 @@ export function registerModelTools(server, config) {
104
113
  : items;
105
114
  const effectiveLimit = (limit ?? defaultLimit) || matched.length;
106
115
  const effectiveOffset = Math.min(offset ?? defaultOffset, matched.length);
107
- const shown = matched.slice(effectiveOffset, effectiveOffset + effectiveLimit);
116
+ const shown = matched
117
+ .slice(effectiveOffset, effectiveOffset + effectiveLimit)
118
+ .map((item) => annotateWebEntry(item, kind));
108
119
  const nextOffset = effectiveOffset + shown.length;
109
120
  const hasMore = nextOffset < matched.length;
110
121
  const result = isRecord(payload)
package/dist/tools/web.js CHANGED
@@ -1,22 +1,57 @@
1
1
  import { z } from 'zod/v4';
2
2
  import { requestJson } from '../ninerouter-client.js';
3
- import { tryModelsWithFallback, toPrettyJson } from './common.js';
3
+ import { attachWarnings, tryModelsWithFallback, toPrettyJson } from '../lib/common.js';
4
+ import { convertCountry, fetchProviderInfo, lookupWebCapabilities, unsupportedFilterWarning, } from '../lib/web-capabilities.js';
5
+ import { resolveWebModelId } from '../lib/web-model.js';
6
+ function normalizeDomains(value) {
7
+ if (!value) {
8
+ return [];
9
+ }
10
+ const entries = Array.isArray(value) ? value : value.split(',');
11
+ return entries.map((entry) => entry.trim()).filter((entry) => entry.length > 0);
12
+ }
4
13
  export function registerWebTools(server, config) {
5
14
  server.registerTool('web_search', {
6
- description: 'Search the web and return the raw upstream search payload as JSON. Use when the user needs current information, links, or news. Pass `model` (or its alias `provider`) to pick a specific search backend (e.g. `tavily/search`, `brave-search/search`); otherwise the configured `default_models.web_search` fallback chain is tried in order.',
15
+ description: 'Search the web and return the raw upstream search payload as JSON. Use when the user needs current information, links, or news. Leave `model` unset to use the configured `default_models.web_search` chain; set it only to force one backend: a provider id (`tavily`, `serper`), a catalog id (`tavily/search`), or a combo (`search-combo`). When the chosen backend does not read a filter, the server drops it and reports it in `warnings`, so check that field before trusting a filtered result set.',
7
16
  inputSchema: z.object({
8
17
  query: z.string().min(1).describe('Search query to send to the backend.'),
9
18
  model: z
10
19
  .string()
11
20
  .optional()
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
- provider: z.string().optional().describe('Alias for model.'),
14
- maxResults: z.number().int().positive().max(20).optional().default(5),
15
- searchType: z.enum(['web', 'news']).optional().default('web'),
16
- country: z.string().optional(),
17
- language: z.string().optional(),
18
- timeRange: z.string().optional(),
19
- domainFilter: z.string().optional(),
21
+ .describe('Omit it to use the configured default_models.web_search chain. Set it to force one backend: a provider id (`tavily`), a catalog id (`tavily/search`), or a combo name (`search-combo`).'),
22
+ provider: z
23
+ .string()
24
+ .optional()
25
+ .describe('Alias for `model`. Omit it to use the configured default_models.web_search chain.'),
26
+ maxResults: z
27
+ .number()
28
+ .int()
29
+ .positive()
30
+ .max(100)
31
+ .optional()
32
+ .default(5)
33
+ .describe('How many results to return (1-100). 9Router caps it to the provider limit and reports the cap in `warnings`.'),
34
+ searchType: z
35
+ .enum(['web', 'news', 'research_paper'])
36
+ .optional()
37
+ .default('web')
38
+ .describe('Result type. Only tinyfish reads `research_paper`; other providers fall back to their own type and say so in `warnings`.'),
39
+ country: z
40
+ .string()
41
+ .optional()
42
+ .describe('Country filter: a country name (`united states`) or an ISO 3166-1 alpha-2 code (`us`). The server rewrites it to the format the provider expects, and drops it with a warning when the provider does not read it.'),
43
+ language: z
44
+ .string()
45
+ .optional()
46
+ .describe('Language filter, for example `en` or `vi`. The server drops it with a warning when the provider does not read it.'),
47
+ timeRange: z
48
+ .enum(['day', 'week', 'month', 'year'])
49
+ .optional()
50
+ .describe('Keep only results from the last day, week, month or year. Only google-pse, linkup, youcom and searxng read it; the server drops it with a warning everywhere else.'),
51
+ domainFilter: z
52
+ .union([z.string(), z.array(z.string())])
53
+ .optional()
54
+ .describe('Restrict results to specific sites. Pass an array of domains (`["snyk.io"]`) or a comma-separated string. Prefix a domain with `-` to exclude it instead (`["-reddit.com"]`). The server drops it with a warning when the provider does not read it.'),
20
55
  }),
21
56
  }, async ({ query, model, provider, maxResults, searchType, country, language, timeRange, domainFilter, }) => {
22
57
  const userModel = model ?? provider;
@@ -25,31 +60,89 @@ export function registerWebTools(server, config) {
25
60
  if (modelsToTry.length === 0) {
26
61
  throw new Error('No model specified and no default_models.web_search configured.');
27
62
  }
28
- const payload = await tryModelsWithFallback(modelsToTry, async (selectedModel) => {
29
- return await requestJson(config, '/v1/search', {
30
- model: selectedModel,
63
+ const domains = normalizeDomains(domainFilter);
64
+ const outcome = await tryModelsWithFallback(modelsToTry, async (selectedModel) => {
65
+ const resolved = resolveWebModelId(selectedModel);
66
+ const capabilities = lookupWebCapabilities(resolved.id);
67
+ const info = await fetchProviderInfo(config, resolved.id, 'search');
68
+ const warnings = [];
69
+ const body = {
70
+ model: resolved.id,
31
71
  query,
32
72
  max_results: maxResults,
33
73
  search_type: searchType,
34
- country,
35
- language,
36
- time_range: timeRange,
37
- domain_filter: domainFilter,
38
- });
74
+ };
75
+ if (country) {
76
+ if (capabilities && !capabilities.country) {
77
+ warnings.push(unsupportedFilterWarning('country', resolved.id));
78
+ }
79
+ else {
80
+ const conversion = convertCountry(country, capabilities?.countryFormat ?? 'any', resolved.id);
81
+ if (conversion.action === 'send') {
82
+ body.country = conversion.value;
83
+ if (conversion.warning) {
84
+ warnings.push(conversion.warning);
85
+ }
86
+ }
87
+ else {
88
+ warnings.push(conversion.warning);
89
+ }
90
+ }
91
+ }
92
+ if (language) {
93
+ if (capabilities && !capabilities.language) {
94
+ warnings.push(unsupportedFilterWarning('language', resolved.id));
95
+ }
96
+ else {
97
+ body.language = language;
98
+ }
99
+ }
100
+ if (timeRange) {
101
+ if (capabilities && !capabilities.timeRange) {
102
+ warnings.push(unsupportedFilterWarning('timeRange', resolved.id));
103
+ }
104
+ else {
105
+ body.time_range = timeRange;
106
+ }
107
+ }
108
+ if (domains.length > 0) {
109
+ if (capabilities && !capabilities.domainFilter) {
110
+ warnings.push(unsupportedFilterWarning('domainFilter', resolved.id));
111
+ }
112
+ else {
113
+ body.domain_filter = domains;
114
+ }
115
+ }
116
+ if (info?.maxResults && maxResults > info.maxResults) {
117
+ warnings.push(`maxResults ${maxResults} exceeds the ${resolved.id} limit of ${info.maxResults}; 9Router returns fewer.`);
118
+ }
119
+ if (info?.searchTypes?.length && !info.searchTypes.includes(searchType)) {
120
+ warnings.push(`${resolved.id} does not read searchType "${searchType}"; it searches ${info.searchTypes.join(', ')} instead.`);
121
+ }
122
+ const payload = await requestJson(config, '/v1/search', body);
123
+ return { payload, warnings };
39
124
  });
40
125
  return {
41
- content: [{ type: 'text', text: toPrettyJson(payload) }],
126
+ content: [
127
+ {
128
+ type: 'text',
129
+ text: toPrettyJson(attachWarnings(outcome.payload, outcome.warnings)),
130
+ },
131
+ ],
42
132
  };
43
133
  });
44
134
  server.registerTool('web_fetch', {
45
- description: 'Fetch a URL and return its content as markdown (default), plain text, or HTML. Use when the user gives a link and you need its readable body. Output is truncated to `maxCharacters` (default 8000). Pass `model` (alias `provider`) to choose a fetcher (e.g. `jina-reader/fetch`, `firecrawl/fetch`); otherwise the configured `default_models.web_fetch` fallback chain is tried in order.',
135
+ description: 'Fetch a URL and return its content as markdown (default), plain text, or HTML. Use when the user gives a link and you need its readable body. The server truncates the output to `maxCharacters` (default 8000). Leave `model` unset to use the configured `default_models.web_fetch` chain; set it only to force one fetcher: a provider id (`tavily`), a catalog id (`tavily/fetch`), or a combo (`fetch-combo`).',
46
136
  inputSchema: z.object({
47
137
  url: z.string().url().describe('URL to fetch and extract.'),
48
138
  model: z
49
139
  .string()
50
140
  .optional()
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
- provider: z.string().optional().describe('Alias for model.'),
141
+ .describe('Omit it to use the configured default_models.web_fetch chain. Set it to force one fetcher: a provider id (`tavily`), a catalog id (`tavily/fetch`), or a combo name (`fetch-combo`).'),
142
+ provider: z
143
+ .string()
144
+ .optional()
145
+ .describe('Alias for `model`. Omit it to use the configured default_models.web_fetch chain.'),
53
146
  format: z.enum(['markdown', 'text', 'html']).optional().default('markdown'),
54
147
  maxCharacters: z.number().int().nonnegative().optional().default(8000),
55
148
  }),
@@ -61,8 +154,9 @@ export function registerWebTools(server, config) {
61
154
  throw new Error('No model specified and no default_models.web_fetch configured.');
62
155
  }
63
156
  const payload = await tryModelsWithFallback(modelsToTry, async (selectedModel) => {
157
+ const resolved = resolveWebModelId(selectedModel);
64
158
  return await requestJson(config, '/v1/web/fetch', {
65
- model: selectedModel,
159
+ model: resolved.id,
66
160
  url,
67
161
  format,
68
162
  max_characters: maxCharacters,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ninerouter-mcp",
3
- "version": "1.2.0-beta.2",
3
+ "version": "1.3.0",
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",