@stabgan/openrouter-mcp-multimodal 4.6.0 → 4.6.1

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/dist/index.js CHANGED
@@ -7,6 +7,7 @@ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
7
7
  import { ToolHandlers } from './tool-handlers.js';
8
8
  import { logger } from './logger.js';
9
9
  import { SERVER_VERSION } from './version.js';
10
+ import { SERVER_ICON } from './tool-icons.js';
10
11
  const DEFAULT_MODEL = 'nvidia/nemotron-nano-12b-v2-vl:free';
11
12
  // Exit on fatal errors to prevent silent zombie processes (issue #5).
12
13
  // We log an explicit whitelist of fields rather than the raw error object
@@ -37,7 +38,14 @@ if (!apiKey) {
37
38
  process.exit(1);
38
39
  }
39
40
  const defaultModel = process.env.OPENROUTER_DEFAULT_MODEL || process.env.DEFAULT_MODEL || DEFAULT_MODEL;
40
- const server = new Server({ name: 'openrouter-multimodal-server', version: SERVER_VERSION }, { capabilities: { tools: {} } });
41
+ const server = new Server({
42
+ name: 'openrouter-multimodal-server',
43
+ version: SERVER_VERSION,
44
+ title: 'OpenRouter MCP Multimodal',
45
+ description: 'MCP server for OpenRouter — chat with 300+ LLMs, analyze/generate images, audio, and video.',
46
+ websiteUrl: 'https://github.com/stabgan/openrouter-mcp-multimodal',
47
+ icons: SERVER_ICON,
48
+ }, { capabilities: { tools: {} } });
41
49
  server.onerror = (error) => logFatal('mcpError', error);
42
50
  new ToolHandlers(server, apiKey, defaultModel);
43
51
  process.on('SIGINT', async () => {
@@ -113,12 +113,10 @@ export const TOOL_DESCRIPTIONS = {
113
113
  'Polling for a long-running reasoning model result',
114
114
  ],
115
115
  notWhen: [
116
- 'You haven\'t started a job yet → use start_chat_completion first',
116
+ "You haven't started a job yet → use start_chat_completion first",
117
117
  'You want to start a new completion',
118
118
  ],
119
- goodExamples: [
120
- '`{ "job_id": "chat_20260806_001" }`',
121
- ],
119
+ goodExamples: ['`{ "job_id": "chat_20260806_001" }`'],
122
120
  badExamples: [
123
121
  '`{ "job_id": "" }` → INVALID_INPUT',
124
122
  '`{ "job_id": "nonexistent" }` → job not found',
@@ -322,7 +320,7 @@ export const TOOL_DESCRIPTIONS = {
322
320
  worksWith: ['analyze_image', 'generate_video_from_image'],
323
321
  }),
324
322
  generate_image_dedicated: buildToolDescription({
325
- summary: 'Generate images via OpenRouter\'s dedicated Image API (POST /api/v1/images). Supports ' +
323
+ summary: "Generate images via OpenRouter's dedicated Image API (POST /api/v1/images). Supports " +
326
324
  'normalized resolution tiers, quality levels, output format selection, and reference images. ' +
327
325
  'New image models are added exclusively to this endpoint. Default model: google/gemini-2.5-flash-image.',
328
326
  useWhen: [
@@ -348,7 +346,7 @@ export const TOOL_DESCRIPTIONS = {
348
346
  failsWhen: [
349
347
  'INVALID_INPUT: empty prompt, invalid resolution/quality/output_format',
350
348
  'UNSAFE_PATH: save_path or input_references escaped sandbox',
351
- 'UPSTREAM_REFUSED: content policy, model doesn\'t support requested options',
349
+ "UPSTREAM_REFUSED: content policy, model doesn't support requested options",
352
350
  'MODEL_NOT_FOUND: invalid model slug',
353
351
  ],
354
352
  worksWith: ['analyze_image', 'generate_video_from_image'],
@@ -376,7 +374,7 @@ export const TOOL_DESCRIPTIONS = {
376
374
  worksWith: ['analyze_audio'],
377
375
  }),
378
376
  text_to_speech: buildToolDescription({
379
- summary: 'Convert text to speech via OpenRouter\'s dedicated TTS endpoint (POST /api/v1/audio/speech). ' +
377
+ summary: "Convert text to speech via OpenRouter's dedicated TTS endpoint (POST /api/v1/audio/speech). " +
380
378
  'Faster and cheaper than chat completions for pure TTS. Models: OpenAI GPT-4o Mini TTS, Google Gemini Flash TTS, Mistral Voxtral.',
381
379
  useWhen: [
382
380
  'You need text-to-speech with specific voice control',
@@ -406,7 +404,7 @@ export const TOOL_DESCRIPTIONS = {
406
404
  worksWith: ['speech_to_text', 'analyze_audio'],
407
405
  }),
408
406
  speech_to_text: buildToolDescription({
409
- summary: 'Transcribe audio via OpenRouter\'s dedicated STT endpoint (POST /api/v1/audio/transcriptions). ' +
407
+ summary: "Transcribe audio via OpenRouter's dedicated STT endpoint (POST /api/v1/audio/transcriptions). " +
410
408
  'Faster and cheaper than chat completions for pure transcription. Models: Whisper-1, GPT-4o Transcribe, Voxtral.',
411
409
  useWhen: [
412
410
  'You need fast transcription of audio files',
@@ -20,7 +20,7 @@ import path from 'node:path';
20
20
  import { ErrorCode, toolError } from '../errors.js';
21
21
  import { SERVER_VERSION } from '../version.js';
22
22
  import { logger } from '../logger.js';
23
- import { extractCompletionText, buildCompletionMeta, } from './completion-utils.js';
23
+ import { extractCompletionText, buildCompletionMeta } from './completion-utils.js';
24
24
  import { readProviderDefaults, mergeProviderOptions, buildProviderBody, resolveMaxTokens, } from './provider-routing.js';
25
25
  import { buildCacheHeaders } from './cache.js';
26
26
  // ─── Job Store ───────────────────────────────────────────────────────────────
@@ -10,7 +10,7 @@
10
10
  */
11
11
  import { promises as fs } from 'fs';
12
12
  import path from 'node:path';
13
- import { resolveSafeOutputPath, resolveSafeInputPath, UnsafeOutputPathError } from './path-safety.js';
13
+ import { resolveSafeOutputPath, resolveSafeInputPath, UnsafeOutputPathError, } from './path-safety.js';
14
14
  import { ErrorCode, toolError, toolErrorFrom } from '../errors.js';
15
15
  import { SERVER_VERSION } from '../version.js';
16
16
  import { logger } from '../logger.js';
@@ -36,11 +36,15 @@ async function resolveReference(source) {
36
36
  const abs = await resolveSafeInputPath(trimmed);
37
37
  const buf = await fs.readFile(abs);
38
38
  const ext = path.extname(abs).toLowerCase();
39
- const mime = ext === '.png' ? 'image/png' :
40
- ext === '.webp' ? 'image/webp' :
41
- ext === '.gif' ? 'image/gif' :
42
- ext === '.svg' ? 'image/svg+xml' :
43
- 'image/jpeg';
39
+ const mime = ext === '.png'
40
+ ? 'image/png'
41
+ : ext === '.webp'
42
+ ? 'image/webp'
43
+ : ext === '.gif'
44
+ ? 'image/gif'
45
+ : ext === '.svg'
46
+ ? 'image/svg+xml'
47
+ : 'image/jpeg';
44
48
  const dataUrl = `data:${mime};base64,${buf.toString('base64')}`;
45
49
  return { type: 'image_url', image_url: { url: dataUrl } };
46
50
  }
@@ -128,11 +132,15 @@ export async function handleGenerateImageDedicated(request, apiClient) {
128
132
  }
129
133
  const firstImage = images[0];
130
134
  const imageData = firstImage.b64_json;
131
- const mimeType = output_format === 'png' ? 'image/png' :
132
- output_format === 'webp' ? 'image/webp' :
133
- output_format === 'svg' ? 'image/svg+xml' :
134
- output_format === 'jpeg' ? 'image/jpeg' :
135
- 'image/png'; // default
135
+ const mimeType = output_format === 'png'
136
+ ? 'image/png'
137
+ : output_format === 'webp'
138
+ ? 'image/webp'
139
+ : output_format === 'svg'
140
+ ? 'image/svg+xml'
141
+ : output_format === 'jpeg'
142
+ ? 'image/jpeg'
143
+ : 'image/png'; // default
136
144
  const baseMeta = {
137
145
  server_version: SERVER_VERSION,
138
146
  model: model || DEFAULT_MODEL,
@@ -168,9 +176,7 @@ export async function handleGenerateImageDedicated(request, apiClient) {
168
176
  }
169
177
  // URL-only response (some models return URLs instead of base64)
170
178
  return {
171
- content: [
172
- { type: 'text', text: `Image generated. URL: ${firstImage.url}` },
173
- ],
179
+ content: [{ type: 'text', text: `Image generated. URL: ${firstImage.url}` }],
174
180
  _meta: { ...baseMeta, image_url: firstImage.url },
175
181
  };
176
182
  }
@@ -20,16 +20,24 @@ const VALID_RESPONSE_FORMATS = new Set(['json', 'text', 'srt', 'verbose_json', '
20
20
  function audioFormatFromExt(ext) {
21
21
  const normalized = ext.toLowerCase().replace('.', '');
22
22
  switch (normalized) {
23
- case 'mp3': return 'mp3';
23
+ case 'mp3':
24
+ return 'mp3';
24
25
  case 'mp4':
25
- case 'm4a': return 'mp4';
26
- case 'wav': return 'wav';
27
- case 'flac': return 'flac';
26
+ case 'm4a':
27
+ return 'mp4';
28
+ case 'wav':
29
+ return 'wav';
30
+ case 'flac':
31
+ return 'flac';
28
32
  case 'ogg':
29
- case 'oga': return 'ogg';
30
- case 'webm': return 'webm';
31
- case 'opus': return 'opus';
32
- default: return 'mp3';
33
+ case 'oga':
34
+ return 'ogg';
35
+ case 'webm':
36
+ return 'webm';
37
+ case 'opus':
38
+ return 'opus';
39
+ default:
40
+ return 'mp3';
33
41
  }
34
42
  }
35
43
  /**
@@ -14,11 +14,12 @@ import { handleAnalyzeVideo } from './tool-handlers/analyze-video.js';
14
14
  import { handleGenerateVideo, handleGetVideoStatus, handleGenerateVideoFromImage, } from './tool-handlers/generate-video.js';
15
15
  import { handleRerankDocuments } from './tool-handlers/rerank.js';
16
16
  import { handleHealthCheck } from './tool-handlers/health-check.js';
17
- import { handleGenerateImageDedicated, } from './tool-handlers/generate-image-dedicated.js';
17
+ import { handleGenerateImageDedicated } from './tool-handlers/generate-image-dedicated.js';
18
18
  import { handleTextToSpeech } from './tool-handlers/text-to-speech.js';
19
19
  import { handleSpeechToText } from './tool-handlers/speech-to-text.js';
20
20
  import { handleStartChatCompletion, handleGetChatCompletionStatus, } from './tool-handlers/async-chat.js';
21
21
  import { TOOL_DESCRIPTIONS } from './tool-descriptions.js';
22
+ import { TOOL_ICONS } from './tool-icons.js';
22
23
  function wrapToolArgs(a) {
23
24
  return { params: { arguments: a ?? {} } };
24
25
  }
@@ -192,7 +193,9 @@ export class ToolHandlers {
192
193
  type: 'object',
193
194
  properties: {
194
195
  role: { type: 'string', enum: ['system', 'user', 'assistant'] },
195
- content: { oneOf: [{ type: 'string' }, { type: 'array', items: { type: 'object' } }] },
196
+ content: {
197
+ oneOf: [{ type: 'string' }, { type: 'array', items: { type: 'object' } }],
198
+ },
196
199
  },
197
200
  required: ['role', 'content'],
198
201
  },
@@ -223,7 +226,10 @@ export class ToolHandlers {
223
226
  inputSchema: {
224
227
  type: 'object',
225
228
  properties: {
226
- job_id: { type: 'string', description: 'The job_id returned by start_chat_completion.' },
229
+ job_id: {
230
+ type: 'string',
231
+ description: 'The job_id returned by start_chat_completion.',
232
+ },
227
233
  },
228
234
  required: ['job_id'],
229
235
  },
@@ -782,7 +788,10 @@ export class ToolHandlers {
782
788
  ],
783
789
  },
784
790
  },
785
- ],
791
+ ].map((tool) => ({
792
+ ...tool,
793
+ icons: TOOL_ICONS[tool.name],
794
+ })),
786
795
  }));
787
796
  server.setRequestHandler(CallToolRequestSchema, async (request) => {
788
797
  const { name, arguments: args } = request.params;
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Tool icons for MCP 2025-11-25+ clients that render icons in the tool list.
3
+ * Each icon is a minimal SVG data URI — no external hosting needed.
4
+ *
5
+ * Clients that don't support icons simply ignore the `icons` field.
6
+ */
7
+ export type ToolIcon = Array<{
8
+ src: string;
9
+ mimeType: string;
10
+ sizes: string[];
11
+ }>;
12
+ /** Map of tool name → icons array for use in tool definitions. */
13
+ export declare const TOOL_ICONS: Record<string, ToolIcon>;
14
+ /** Server icon for use in the Server initialization metadata. */
15
+ export declare const SERVER_ICON: ToolIcon;
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Tool icons for MCP 2025-11-25+ clients that render icons in the tool list.
3
+ * Each icon is a minimal SVG data URI — no external hosting needed.
4
+ *
5
+ * Clients that don't support icons simply ignore the `icons` field.
6
+ */
7
+ /** Encode an SVG string as a data URI. */
8
+ function svgDataUri(svg) {
9
+ return `data:image/svg+xml,${encodeURIComponent(svg.trim())}`;
10
+ }
11
+ // Minimal 24x24 SVG icons using simple shapes
12
+ const ICONS = {
13
+ chat: svgDataUri(`<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#6366f1" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>`),
14
+ chatAsync: svgDataUri(`<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#8b5cf6" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/><circle cx="17" cy="7" r="3" fill="#8b5cf6"/></svg>`),
15
+ image: svgDataUri(`<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#10b981" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><path d="m21 15-5-5L5 21"/></svg>`),
16
+ audio: svgDataUri(`<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#f59e0b" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/></svg>`),
17
+ mic: svgDataUri(`<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ef4444" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3Z"/><path d="M19 10v2a7 7 0 0 1-14 0v-2"/><line x1="12" x2="12" y1="19" y2="22"/></svg>`),
18
+ video: svgDataUri(`<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ec4899" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m22 8-6 4 6 4V8Z"/><rect x="2" y="6" width="14" height="12" rx="2"/></svg>`),
19
+ search: svgDataUri(`<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#06b6d4" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/></svg>`),
20
+ info: svgDataUri(`<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#06b6d4" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/></svg>`),
21
+ check: svgDataUri(`<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#22c55e" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><path d="m9 11 3 3L22 4"/></svg>`),
22
+ rerank: svgDataUri(`<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#a855f7" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="8" x2="21" y1="6" y2="6"/><line x1="8" x2="21" y1="12" y2="12"/><line x1="8" x2="21" y1="18" y2="18"/><line x1="3" x2="3.01" y1="6" y2="6"/><line x1="3" x2="3.01" y1="12" y2="12"/><line x1="3" x2="3.01" y1="18" y2="18"/></svg>`),
23
+ health: svgDataUri(`<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#22c55e" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 12h-4l-3 9L9 3l-3 9H2"/></svg>`),
24
+ speaker: svgDataUri(`<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#f97316" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/><path d="M15.54 8.46a5 5 0 0 1 0 7.07"/><path d="M19.07 4.93a10 10 0 0 1 0 14.14"/></svg>`),
25
+ poll: svgDataUri(`<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#64748b" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2v4"/><path d="m16.2 7.8 2.9-2.9"/><path d="M18 12h4"/><path d="m16.2 16.2 2.9 2.9"/><path d="M12 18v4"/><path d="m4.9 19.1 2.9-2.9"/><path d="M2 12h4"/><path d="m4.9 4.9 2.9 2.9"/></svg>`),
26
+ };
27
+ function icon(src) {
28
+ return [{ src, mimeType: 'image/svg+xml', sizes: ['24x24'] }];
29
+ }
30
+ /** Map of tool name → icons array for use in tool definitions. */
31
+ export const TOOL_ICONS = {
32
+ chat_completion: icon(ICONS.chat),
33
+ start_chat_completion: icon(ICONS.chatAsync),
34
+ get_chat_completion_status: icon(ICONS.poll),
35
+ analyze_image: icon(ICONS.image),
36
+ analyze_audio: icon(ICONS.audio),
37
+ analyze_video: icon(ICONS.video),
38
+ search_models: icon(ICONS.search),
39
+ get_model_info: icon(ICONS.info),
40
+ validate_model: icon(ICONS.check),
41
+ generate_image: icon(ICONS.image),
42
+ generate_image_dedicated: icon(ICONS.image),
43
+ generate_audio: icon(ICONS.audio),
44
+ text_to_speech: icon(ICONS.speaker),
45
+ speech_to_text: icon(ICONS.mic),
46
+ generate_video: icon(ICONS.video),
47
+ generate_video_from_image: icon(ICONS.video),
48
+ get_video_status: icon(ICONS.poll),
49
+ rerank_documents: icon(ICONS.rerank),
50
+ health_check: icon(ICONS.health),
51
+ };
52
+ /** Server icon for use in the Server initialization metadata. */
53
+ export const SERVER_ICON = [
54
+ {
55
+ src: svgDataUri(`<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 48 48" fill="none"><rect width="48" height="48" rx="12" fill="#1a1a2e"/><path d="M14 24h20M24 14v20" stroke="#6366f1" stroke-width="3" stroke-linecap="round"/><circle cx="24" cy="24" r="8" stroke="#22c55e" stroke-width="2"/><circle cx="24" cy="24" r="3" fill="#6366f1"/></svg>`),
56
+ mimeType: 'image/svg+xml',
57
+ sizes: ['48x48'],
58
+ },
59
+ ];
package/dist/version.d.ts CHANGED
@@ -6,7 +6,7 @@
6
6
  * Bumped in lockstep with package.json / server.json / smithery.yaml /
7
7
  * scripts/build-manifest.mjs during release prep.
8
8
  */
9
- export declare const SERVER_VERSION = "4.6.0";
9
+ export declare const SERVER_VERSION = "4.6.1";
10
10
  /**
11
11
  * MCP protocol version our SDK speaks. Hardcoded to match the version
12
12
  * bundled with `@modelcontextprotocol/sdk`; update when upgrading the
package/dist/version.js CHANGED
@@ -6,7 +6,7 @@
6
6
  * Bumped in lockstep with package.json / server.json / smithery.yaml /
7
7
  * scripts/build-manifest.mjs during release prep.
8
8
  */
9
- export const SERVER_VERSION = '4.6.0';
9
+ export const SERVER_VERSION = '4.6.1';
10
10
  /**
11
11
  * MCP protocol version our SDK speaks. Hardcoded to match the version
12
12
  * bundled with `@modelcontextprotocol/sdk`; update when upgrading the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stabgan/openrouter-mcp-multimodal",
3
- "version": "4.6.0",
3
+ "version": "4.6.1",
4
4
  "mcpName": "io.github.stabgan/openrouter-multimodal",
5
5
  "description": "MCP server for OpenRouter — chat with 300+ LLMs, analyze images/audio/video, generate images (dedicated API), TTS/STT, video generation (Veo 3.1 / Seedance / Wan), async completions, response caching",
6
6
  "type": "module",