@stabgan/openrouter-mcp-multimodal 4.6.2 → 4.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -32,6 +32,7 @@
32
32
  <a href="#examples">Examples</a> ·
33
33
  <a href="#security">Security</a> ·
34
34
  <a href="#development">Development</a> ·
35
+ <a href="#releasing">Releasing</a> ·
35
36
  <a href="#faq">FAQ</a>
36
37
  </p>
37
38
 
@@ -129,7 +130,7 @@ npx -y @stabgan/openrouter-mcp-multimodal
129
130
  }
130
131
  ```
131
132
 
132
- Pin a release: `"args": ["-y", "@stabgan/openrouter-mcp-multimodal@4.6.2"]`
133
+ Pin a release: `"args": ["-y", "@stabgan/openrouter-mcp-multimodal@4.7.0"]`
133
134
 
134
135
  </details>
135
136
 
@@ -141,7 +142,7 @@ Install [uv](https://docs.astral.sh/uv/getting-started/installation/) (includes
141
142
  ```bash
142
143
  export OPENROUTER_API_KEY=sk-or-v1-...
143
144
  uvx mcp-server-openrouter-multimodal
144
- # pin npm version: OPENROUTER_MCP_NPM_VERSION=4.6.2 uvx mcp-server-openrouter-multimodal
145
+ # pin npm version: OPENROUTER_MCP_NPM_VERSION=4.7.0 uvx mcp-server-openrouter-multimodal
145
146
  ```
146
147
 
147
148
  ```json
@@ -160,7 +161,7 @@ uvx mcp-server-openrouter-multimodal
160
161
 
161
162
  **pipx equivalent:** `pipx run mcp-server-openrouter-multimodal`
162
163
 
163
- Optional: `OPENROUTER_MCP_NPM_VERSION=4.6.2` pins the underlying npm package.
164
+ Optional: `OPENROUTER_MCP_NPM_VERSION=4.7.0` pins the underlying npm package.
164
165
 
165
166
  </details>
166
167
 
@@ -241,7 +242,7 @@ Use `-i` (interactive stdio). Avoid `-t` (TTY corrupts MCP framing on some hosts
241
242
 
242
243
  ```bash
243
244
  docker run --rm -i -e OPENROUTER_API_KEY=sk-or-v1-... \
244
- ghcr.io/stabgan/openrouter-mcp-multimodal:4.6.2
245
+ ghcr.io/stabgan/openrouter-mcp-multimodal:4.7.0
245
246
  ```
246
247
 
247
248
  ```json
@@ -492,8 +493,22 @@ Mock tests live under `src/__tests__/mock/` and cover handlers, path sandboxes,
492
493
  ```bash
493
494
  npm run lint
494
495
  npm run format:check
496
+ npm run version:check # package.json vs src/version.ts, server.json, pyproject.toml
495
497
  ```
496
498
 
499
+ ## Releasing
500
+
501
+ Published artifacts (**npm**, **PyPI/uvx**, **Docker**, **GHCR**) all ship from the **same semver** on a git tag (`vX.Y.Z`). Pushing to `main` runs tests but does **not** publish to npm or PyPI.
502
+
503
+ **Normal flow:** merge conventional commits to `main` → [Release Please](https://github.com/googleapis/release-please) opens a Release PR → merge it → tag is created → CI publishes everywhere.
504
+
505
+ **Manual flow:** bump all version files → `npm run version:check` → `npm run ci` + smoke tests → commit → `git tag vX.Y.Z` → `git push origin vX.Y.Z`.
506
+
507
+ Full checklist, file list, CI secrets, and agent instructions:
508
+
509
+ - **[`docs/RELEASING.md`](docs/RELEASING.md)** — maintainer release guide
510
+ - **[`AGENTS.md`](AGENTS.md)** — quick reference for AI agents
511
+
497
512
  ## FAQ
498
513
 
499
514
  ### Do I need paid OpenRouter credits?
@@ -522,4 +537,6 @@ Apache 2.0 — see [LICENSE](./LICENSE).
522
537
 
523
538
  ## Contributing
524
539
 
525
- Issues and PRs welcome. For large changes, open an issue first. Run `npm run ci` before submitting.
540
+ Issues and PRs welcome. For large changes, open an issue first.
541
+
542
+ Before submitting: run **`npm run ci`**. Use [Conventional Commits](https://www.conventionalcommits.org/) (`fix:`, `feat:`, etc.) so [Release Please](docs/RELEASING.md) can cut the next release. See **[`docs/RELEASING.md`](docs/RELEASING.md)** if you need to ship a version.
@@ -0,0 +1,9 @@
1
+ import OpenAI from 'openai';
2
+ declare const OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1";
3
+ declare const OPENROUTER_ATTRIBUTION_HEADERS: {
4
+ readonly 'HTTP-Referer': "https://github.com/stabgan/openrouter-mcp-multimodal";
5
+ readonly 'X-Title': "openrouter-mcp-multimodal";
6
+ };
7
+ /** OpenAI SDK client configured for OpenRouter chat/completions endpoints. */
8
+ export declare function createOpenRouterOpenAIClient(apiKey: string): OpenAI;
9
+ export { OPENROUTER_BASE_URL, OPENROUTER_ATTRIBUTION_HEADERS };
@@ -0,0 +1,15 @@
1
+ import OpenAI from 'openai';
2
+ const OPENROUTER_BASE_URL = 'https://openrouter.ai/api/v1';
3
+ const OPENROUTER_ATTRIBUTION_HEADERS = {
4
+ 'HTTP-Referer': 'https://github.com/stabgan/openrouter-mcp-multimodal',
5
+ 'X-Title': 'openrouter-mcp-multimodal',
6
+ };
7
+ /** OpenAI SDK client configured for OpenRouter chat/completions endpoints. */
8
+ export function createOpenRouterOpenAIClient(apiKey) {
9
+ return new OpenAI({
10
+ apiKey,
11
+ baseURL: OPENROUTER_BASE_URL,
12
+ defaultHeaders: { ...OPENROUTER_ATTRIBUTION_HEADERS },
13
+ });
14
+ }
15
+ export { OPENROUTER_BASE_URL, OPENROUTER_ATTRIBUTION_HEADERS };
@@ -1,4 +1,9 @@
1
1
  import { TOOL_DESCRIPTIONS } from './tool-descriptions.js';
2
+ /** Shared JSON-schema fragment for save_path on generate/write tools. */
3
+ const SAVE_PATH_PROPERTY = {
4
+ type: 'string',
5
+ description: 'Write the artifact under OPENROUTER_OUTPUT_DIR (path-sandboxed). When set, the tool result is text-only with _meta.save_path — no inline media block. Without save_path, inline image/audio/video is returned only if under OPENROUTER_*_INLINE_MAX_BYTES (see .env.example).',
6
+ };
2
7
  export const TOOL_DEFINITIONS = [
3
8
  {
4
9
  name: 'chat_completion',
@@ -378,7 +383,7 @@ export const TOOL_DEFINITIONS = [
378
383
  },
379
384
  image_size: { type: 'string', enum: ['0.5K', '1K', '2K', '4K'] },
380
385
  max_tokens: { type: 'number', minimum: 1 },
381
- save_path: { type: 'string' },
386
+ save_path: SAVE_PATH_PROPERTY,
382
387
  input_images: { type: 'array', items: { type: 'string' } },
383
388
  modalities: { type: 'array', items: { type: 'string' } },
384
389
  },
@@ -436,7 +441,10 @@ export const TOOL_DEFINITIONS = [
436
441
  items: { type: 'string' },
437
442
  description: 'Reference images for image-to-image workflows. Each entry: local path, http(s) URL, or data URL.',
438
443
  },
439
- save_path: { type: 'string', description: 'Save generated image to this path.' },
444
+ save_path: {
445
+ ...SAVE_PATH_PROPERTY,
446
+ description: 'Save generated image to this path. ' + SAVE_PATH_PROPERTY.description,
447
+ },
440
448
  provider: {
441
449
  type: 'object',
442
450
  description: 'Provider routing overrides (order, sort, allow_fallbacks, etc.).',
@@ -465,7 +473,7 @@ export const TOOL_DEFINITIONS = [
465
473
  model: { type: 'string' },
466
474
  voice: { type: 'string' },
467
475
  format: { type: 'string' },
468
- save_path: { type: 'string' },
476
+ save_path: SAVE_PATH_PROPERTY,
469
477
  },
470
478
  required: ['prompt'],
471
479
  },
@@ -510,7 +518,10 @@ export const TOOL_DEFINITIONS = [
510
518
  type: 'string',
511
519
  description: 'Tone/style instructions (e.g. "speak in a warm, friendly tone"). OpenAI models only.',
512
520
  },
513
- save_path: { type: 'string', description: 'Save audio to this path.' },
521
+ save_path: {
522
+ ...SAVE_PATH_PROPERTY,
523
+ description: 'Save audio to this path. ' + SAVE_PATH_PROPERTY.description,
524
+ },
514
525
  cache: { type: 'boolean' },
515
526
  cache_ttl: { type: 'string' },
516
527
  cache_clear: { type: 'boolean' },
@@ -584,7 +595,7 @@ export const TOOL_DEFINITIONS = [
584
595
  last_frame_image: { type: 'string' },
585
596
  reference_images: { type: 'array', items: { type: 'string' } },
586
597
  provider: { type: 'object' },
587
- save_path: { type: 'string' },
598
+ save_path: SAVE_PATH_PROPERTY,
588
599
  max_wait_ms: { type: 'number', minimum: 10000 },
589
600
  poll_interval_ms: { type: 'number', minimum: 2000 },
590
601
  },
@@ -614,7 +625,7 @@ export const TOOL_DEFINITIONS = [
614
625
  aspect_ratio: { type: 'string' },
615
626
  duration: { type: 'number', minimum: 1 },
616
627
  seed: { type: 'number' },
617
- save_path: { type: 'string' },
628
+ save_path: SAVE_PATH_PROPERTY,
618
629
  max_wait_ms: { type: 'number', minimum: 10000 },
619
630
  poll_interval_ms: { type: 'number', minimum: 2000 },
620
631
  },
@@ -635,7 +646,7 @@ export const TOOL_DEFINITIONS = [
635
646
  type: 'object',
636
647
  properties: {
637
648
  video_id: { type: 'string' },
638
- save_path: { type: 'string' },
649
+ save_path: SAVE_PATH_PROPERTY,
639
650
  },
640
651
  required: ['video_id'],
641
652
  },
@@ -5,6 +5,7 @@ import { ErrorCode, toolError } from '../errors.js';
5
5
  import { SERVER_VERSION } from '../version.js';
6
6
  import { logger } from '../logger.js';
7
7
  import { extractCompletionText, buildCompletionMeta } from './completion-utils.js';
8
+ import { resolveSafeJobStatusPath, isValidJobId } from './path-safety.js';
8
9
  import { classifyUpstreamError } from './openrouter-errors.js';
9
10
  import { DEFAULT_CHAT_MODEL, buildChatCompletionBody, buildChatCompletionRequestOpts, asOpenAIChatBody, readIncludeReasoningDefault, } from './chat-request.js';
10
11
  const jobs = new Map();
@@ -44,8 +45,11 @@ export async function loadJobFromDisk(jobId) {
44
45
  const dir = getJobsDir();
45
46
  if (!dir)
46
47
  return null;
48
+ const statusPath = await resolveSafeJobStatusPath(dir, jobId);
49
+ if (!statusPath)
50
+ return null;
47
51
  try {
48
- const raw = await fs.readFile(path.join(dir, jobId, 'status.json'), 'utf8');
52
+ const raw = await fs.readFile(statusPath, 'utf8');
49
53
  return JSON.parse(raw);
50
54
  }
51
55
  catch {
@@ -148,6 +152,9 @@ export async function handleGetChatCompletionStatus(request) {
148
152
  if (!jobId) {
149
153
  return toolError(ErrorCode.INVALID_INPUT, 'job_id is required.');
150
154
  }
155
+ if (!isValidJobId(jobId)) {
156
+ return toolError(ErrorCode.INVALID_INPUT, `Invalid job_id "${jobId}". Must start with chat_ and must not contain path separators.`);
157
+ }
151
158
  const job = await resolveJob(jobId);
152
159
  if (!job) {
153
160
  const hint = getJobsDir()
@@ -22,46 +22,12 @@ export declare function detectAudioFormat(data: Buffer): {
22
22
  mimeType: string;
23
23
  };
24
24
  export declare function wrapPcmInWav(pcmData: Buffer, sampleRate?: number): Buffer;
25
- /** Strip existing extension (if any) and append a new one. */
26
- export declare function replaceExtension(filePath: string, newExt: string): string;
25
+ export { replaceExtension } from './path-utils.js';
27
26
  export declare function handleGenerateAudio(request: {
28
27
  params: {
29
28
  arguments: GenerateAudioToolRequest;
30
29
  };
31
30
  }, openai: OpenAI): Promise<import("../errors.js").ToolErrorResult | {
32
- content: ({
33
- type: "text";
34
- text: string;
35
- mimeType?: undefined;
36
- data?: undefined;
37
- } | {
38
- type: "audio";
39
- mimeType: string;
40
- data: string;
41
- text?: undefined;
42
- })[];
43
- _meta: {
44
- server_version: string;
45
- save_path: string;
46
- mime: string;
47
- size_bytes: number;
48
- };
49
- } | {
50
- content: ({
51
- type: "text";
52
- text: string;
53
- mimeType?: undefined;
54
- data?: undefined;
55
- } | {
56
- type: "audio";
57
- mimeType: string;
58
- data: string;
59
- text?: undefined;
60
- })[];
61
- _meta: {
62
- server_version: string;
63
- mime: string;
64
- size_bytes: number;
65
- save_path?: undefined;
66
- };
31
+ content: import("./tool-result-payload.js").BinaryToolContent[];
32
+ _meta: Record<string, unknown>;
67
33
  }>;
@@ -6,6 +6,8 @@ import { ErrorCode, toolError } from '../errors.js';
6
6
  import { SERVER_VERSION } from '../version.js';
7
7
  import { logger } from '../logger.js';
8
8
  import { classifyUpstreamError } from './openrouter-errors.js';
9
+ import { buildBinaryToolResult } from './tool-result-payload.js';
10
+ import { replaceExtension } from './path-utils.js';
9
11
  const DEFAULT_MODEL = 'openai/gpt-audio';
10
12
  const DEFAULT_VOICE = 'alloy';
11
13
  const DEFAULT_FORMAT = 'pcm16';
@@ -79,12 +81,7 @@ export function detectAudioFormat(data) {
79
81
  export function wrapPcmInWav(pcmData, sampleRate = DEFAULT_PCM_SAMPLE_RATE) {
80
82
  return Buffer.concat([createWavHeader(pcmData.length, sampleRate), pcmData]);
81
83
  }
82
- /** Strip existing extension (if any) and append a new one. */
83
- export function replaceExtension(filePath, newExt) {
84
- const current = extname(filePath);
85
- const base = current ? filePath.slice(0, -current.length) : filePath;
86
- return `${base}.${newExt}`;
87
- }
84
+ export { replaceExtension } from './path-utils.js';
88
85
  export async function handleGenerateAudio(request, openai) {
89
86
  const { prompt, model, voice, format, save_path } = request.params.arguments ?? {
90
87
  prompt: '',
@@ -148,7 +145,6 @@ export async function handleGenerateAudio(request, openai) {
148
145
  detected.ext = 'wav';
149
146
  detected.mimeType = 'audio/wav';
150
147
  }
151
- const returnBase64 = audioBuffer.toString('base64');
152
148
  if (safeBase) {
153
149
  const fileExt = extname(safeBase).toLowerCase().slice(1);
154
150
  const actualSavePath = fileExt === detected.ext ? safeBase : replaceExtension(safeBase, detected.ext);
@@ -159,30 +155,20 @@ export async function handleGenerateAudio(request, openai) {
159
155
  const result = transcript
160
156
  ? `Audio saved to: ${actualSavePath}${formatNote}\nTranscript: ${transcript}`
161
157
  : `Audio saved to: ${actualSavePath}${formatNote}`;
162
- return {
163
- content: [
164
- { type: 'text', text: result },
165
- { type: 'audio', mimeType: detected.mimeType, data: returnBase64 },
166
- ],
167
- _meta: {
158
+ return buildBinaryToolResult({ kind: 'audio', buffer: audioBuffer, mimeType: detected.mimeType }, {
159
+ savedPath: actualSavePath,
160
+ summaryText: result,
161
+ meta: {
168
162
  server_version: SERVER_VERSION,
169
- save_path: actualSavePath,
170
- mime: detected.mimeType,
171
- size_bytes: audioBuffer.length,
172
163
  },
173
- };
164
+ });
174
165
  }
175
- return {
176
- content: [
177
- { type: 'text', text: transcript || 'Audio generated successfully.' },
178
- { type: 'audio', mimeType: detected.mimeType, data: returnBase64 },
179
- ],
180
- _meta: {
166
+ return buildBinaryToolResult({ kind: 'audio', buffer: audioBuffer, mimeType: detected.mimeType }, {
167
+ prefixText: transcript || 'Audio generated successfully.',
168
+ meta: {
181
169
  server_version: SERVER_VERSION,
182
- mime: detected.mimeType,
183
- size_bytes: audioBuffer.length,
184
170
  },
185
- };
171
+ });
186
172
  }
187
173
  catch (err) {
188
174
  return classifyUpstreamError(err, 'generate_audio (stream)');
@@ -17,16 +17,6 @@ export declare function handleGenerateImageDedicated(request: {
17
17
  arguments: GenerateImageDedicatedRequest;
18
18
  };
19
19
  }, apiClient: OpenRouterAPIClient): Promise<import("../errors.js").ToolErrorResult | {
20
- content: ({
21
- type: "text";
22
- text: string;
23
- mimeType?: undefined;
24
- data?: undefined;
25
- } | {
26
- type: "image";
27
- mimeType: string;
28
- data: string;
29
- text?: undefined;
30
- })[];
20
+ content: import("./tool-result-payload.js").BinaryToolContent[];
31
21
  _meta: Record<string, unknown>;
32
22
  }>;
@@ -6,6 +6,8 @@ import { ErrorCode, toolError, toolErrorFrom } from '../errors.js';
6
6
  import { SERVER_VERSION } from '../version.js';
7
7
  import { logger } from '../logger.js';
8
8
  import { classifyUpstreamError } from './openrouter-errors.js';
9
+ import { buildBinaryToolResult } from './tool-result-payload.js';
10
+ import { fetchHttpResource, readEnvInt } from './fetch-utils.js';
9
11
  import { buildCacheHeaders } from './cache.js';
10
12
  const DEFAULT_MODEL = 'google/gemini-2.5-flash-image';
11
13
  const VALID_RESOLUTIONS = new Set(['512', '0.5K', '1K', '2K', '4K']);
@@ -99,27 +101,57 @@ export async function handleGenerateImageDedicated(request, apiClient) {
99
101
  baseMeta.usage = response.usage;
100
102
  if (firstImage.revised_prompt)
101
103
  baseMeta.revised_prompt = firstImage.revised_prompt;
102
- if (safeSavePath && imageData) {
103
- try {
104
- await fs.writeFile(safeSavePath, imageData, { encoding: 'base64' });
104
+ if (safeSavePath) {
105
+ let buffer = null;
106
+ if (imageData) {
107
+ try {
108
+ buffer = Buffer.from(imageData, 'base64');
109
+ if (buffer.length === 0)
110
+ buffer = null;
111
+ }
112
+ catch {
113
+ buffer = null;
114
+ }
105
115
  }
106
- catch (err) {
107
- return toolErrorFrom(ErrorCode.INTERNAL, err, 'Write');
116
+ if (buffer) {
117
+ try {
118
+ await fs.writeFile(safeSavePath, buffer);
119
+ }
120
+ catch (err) {
121
+ return toolErrorFrom(ErrorCode.INTERNAL, err, 'Write');
122
+ }
123
+ baseMeta.save_path = safeSavePath;
124
+ return buildBinaryToolResult({ kind: 'image', buffer, mimeType }, {
125
+ savedPath: safeSavePath,
126
+ summaryText: `Image saved to: ${safeSavePath}`,
127
+ meta: baseMeta,
128
+ });
129
+ }
130
+ if (firstImage.url) {
131
+ try {
132
+ const maxBytes = readEnvInt('OPENROUTER_IMAGE_MAX_DOWNLOAD_BYTES', 20 * 1024 * 1024, 1024);
133
+ const { buffer: fetched, contentType } = await fetchHttpResource(firstImage.url, {
134
+ maxBytes,
135
+ maxRedirects: 3,
136
+ timeoutMs: 30_000,
137
+ });
138
+ const resolvedMime = contentType?.split(';')[0]?.trim() || mimeType;
139
+ await fs.writeFile(safeSavePath, fetched);
140
+ baseMeta.save_path = safeSavePath;
141
+ return buildBinaryToolResult({ kind: 'image', buffer: fetched, mimeType: resolvedMime }, {
142
+ savedPath: safeSavePath,
143
+ summaryText: `Image saved to: ${safeSavePath}`,
144
+ meta: { ...baseMeta, mime: resolvedMime, image_url: firstImage.url },
145
+ });
146
+ }
147
+ catch (err) {
148
+ return toolErrorFrom(ErrorCode.UPSTREAM_HTTP, err, 'Download image URL for save_path');
149
+ }
108
150
  }
109
- baseMeta.save_path = safeSavePath;
110
- return {
111
- content: [
112
- { type: 'text', text: `Image saved to: ${safeSavePath}` },
113
- ...(imageData ? [{ type: 'image', mimeType, data: imageData }] : []),
114
- ],
115
- _meta: baseMeta,
116
- };
151
+ return toolError(ErrorCode.UPSTREAM_REFUSED, 'Model returned no usable image data for save_path (empty b64_json and URL download unavailable).');
117
152
  }
118
153
  if (imageData) {
119
- return {
120
- content: [{ type: 'image', mimeType, data: imageData }],
121
- _meta: baseMeta,
122
- };
154
+ return buildBinaryToolResult({ kind: 'image', buffer: Buffer.from(imageData, 'base64'), mimeType }, { inlineOnly: true, meta: baseMeta });
123
155
  }
124
156
  return {
125
157
  content: [{ type: 'text', text: `Image generated. URL: ${firstImage.url}` }],
@@ -14,49 +14,6 @@ export declare function handleGenerateImage(request: {
14
14
  arguments: GenerateImageToolRequest;
15
15
  };
16
16
  }, openai: OpenAI): Promise<import("../errors.js").ToolErrorResult | {
17
- content: ({
18
- type: "text";
19
- text: string;
20
- mimeType?: undefined;
21
- data?: undefined;
22
- } | {
23
- type: "image";
24
- mimeType: string;
25
- data: string;
26
- text?: undefined;
27
- })[];
28
- _meta: {
29
- usage: {
30
- prompt_tokens: number;
31
- completion_tokens: number;
32
- total_tokens: number;
33
- };
34
- server_version: string;
35
- save_path: string;
36
- mime: string;
37
- } | {
38
- usage?: undefined;
39
- server_version: string;
40
- save_path: string;
41
- mime: string;
42
- };
43
- } | {
44
- content: {
45
- type: "image";
46
- mimeType: string;
47
- data: string;
48
- }[];
49
- _meta: {
50
- usage: {
51
- prompt_tokens: number;
52
- completion_tokens: number;
53
- total_tokens: number;
54
- };
55
- server_version: string;
56
- mime: string;
57
- } | {
58
- usage?: undefined;
59
- server_version: string;
60
- mime: string;
61
- };
17
+ content: import("./tool-result-payload.js").BinaryToolContent[];
18
+ _meta: Record<string, unknown>;
62
19
  }>;
@@ -7,6 +7,7 @@ import { ErrorCode, toolError, toolErrorFrom } from '../errors.js';
7
7
  import { SERVER_VERSION } from '../version.js';
8
8
  import { logger } from '../logger.js';
9
9
  import { classifyUpstreamError } from './openrouter-errors.js';
10
+ import { buildBinaryToolResult } from './tool-result-payload.js';
10
11
  const DEFAULT_MODEL = 'google/gemini-2.5-flash-image';
11
12
  const VALID_ASPECT_RATIOS = new Set([
12
13
  '1:1',
@@ -115,28 +116,13 @@ function buildImageSuccessResult(base64, usage, savePath) {
115
116
  },
116
117
  }
117
118
  : {};
118
- if (savePath) {
119
- return {
120
- content: [
121
- { type: 'text', text: `Image saved to: ${savePath}` },
122
- { type: 'image', mimeType: base64.mime, data: base64.data },
123
- ],
124
- _meta: {
125
- server_version: SERVER_VERSION,
126
- save_path: savePath,
127
- mime: base64.mime,
128
- ...usageMeta,
129
- },
130
- };
131
- }
132
- return {
133
- content: [{ type: 'image', mimeType: base64.mime, data: base64.data }],
134
- _meta: {
135
- server_version: SERVER_VERSION,
136
- mime: base64.mime,
137
- ...usageMeta,
138
- },
139
- };
119
+ const buffer = Buffer.from(base64.data, 'base64');
120
+ return buildBinaryToolResult({ kind: 'image', buffer, mimeType: base64.mime }, {
121
+ savedPath: savePath ?? null,
122
+ inlineOnly: !savePath,
123
+ summaryText: savePath ? `Image saved to: ${savePath}` : undefined,
124
+ meta: { server_version: SERVER_VERSION, ...usageMeta },
125
+ });
140
126
  }
141
127
  function extractBase64(message) {
142
128
  const images = message.images;
@@ -7,11 +7,11 @@ import { resolveOptionalOutputPath, isToolErrorResult, UnsafeOutputPathError, }
7
7
  import { resolveImageBase64 } from './image-source.js';
8
8
  import { readEnvInt } from './fetch-utils.js';
9
9
  import { classifyUpstreamError } from './openrouter-errors.js';
10
+ import { buildBinaryToolResult } from './tool-result-payload.js';
10
11
  const FALLBACK_MODEL = 'google/veo-3.1';
11
12
  const DEFAULT_POLL_INTERVAL_MS = 15_000;
12
13
  const DEFAULT_MAX_WAIT_MS = 10 * 60_000;
13
14
  const MIN_POLL_INTERVAL_MS = 50; // just to avoid a 0ms busy-loop if a caller omits
14
- const INLINE_RETURN_CEILING_BYTES = 10 * 1024 * 1024;
15
15
  /** Models deprecated by OpenAI — removal date: 2026-09-24. */
16
16
  const SORA_DEPRECATED_MODELS = new Set([
17
17
  'openai/sora-2',
@@ -37,9 +37,6 @@ function checkSoraDeprecation(model) {
37
37
  `Your request will still be attempted, but may fail. Recommended alternatives:\n` +
38
38
  SORA_ALTERNATIVES.map((a) => ` • ${a}`).join('\n'));
39
39
  }
40
- function getMaxInlineBytes() {
41
- return readEnvInt('OPENROUTER_VIDEO_INLINE_MAX_BYTES', INLINE_RETURN_CEILING_BYTES, 4096);
42
- }
43
40
  function getDefaultPollInterval() {
44
41
  return readEnvInt('OPENROUTER_VIDEO_POLL_INTERVAL_MS', DEFAULT_POLL_INTERVAL_MS, MIN_POLL_INTERVAL_MS);
45
42
  }
@@ -171,36 +168,16 @@ async function finalizeCompletedJob(apiClient, status, savePath) {
171
168
  await fs.writeFile(finalPath, buffer);
172
169
  baseMeta.save_path = finalPath;
173
170
  const summaryNote = finalPath !== savePath ? ` (detected ${mime}, saved as ${finalPath})` : '';
174
- const content = [
175
- { type: 'text', text: `Video saved to: ${finalPath}${summaryNote}` },
176
- ];
177
- if (buffer.length <= getMaxInlineBytes()) {
178
- content.push({
179
- type: 'video',
180
- mimeType: mime,
181
- data: buffer.toString('base64'),
182
- });
183
- }
184
- return { content, _meta: baseMeta };
185
- }
186
- if (buffer.length <= getMaxInlineBytes()) {
187
- return {
188
- content: [
189
- { type: 'text', text: `Video generated (${buffer.length} bytes, ${mime}).` },
190
- { type: 'video', mimeType: mime, data: buffer.toString('base64') },
191
- ],
192
- _meta: baseMeta,
193
- };
171
+ return buildBinaryToolResult({ kind: 'video', buffer, mimeType: mime }, {
172
+ savedPath: finalPath,
173
+ summaryText: `Video saved to: ${finalPath}${summaryNote}`,
174
+ meta: baseMeta,
175
+ });
194
176
  }
195
- return {
196
- content: [
197
- {
198
- type: 'text',
199
- text: `Video generated (${buffer.length} bytes, ${mime}). Too large to inline; pass save_path to persist. URL: ${url}`,
200
- },
201
- ],
202
- _meta: baseMeta,
203
- };
177
+ return buildBinaryToolResult({ kind: 'video', buffer, mimeType: mime }, {
178
+ remoteUrl: url,
179
+ meta: baseMeta,
180
+ });
204
181
  }
205
182
  function stripAndReplaceExt(p, newExt) {
206
183
  const cur = extname(p);
@@ -15,3 +15,9 @@ export type OptionalOutputPath = {
15
15
  export declare function isToolErrorResult(result: OptionalOutputPath | ToolErrorResult): result is ToolErrorResult;
16
16
  /** Resolve optional save_path; returns a tool error result on sandbox violation. */
17
17
  export declare function resolveOptionalOutputPath(savePath: string | undefined): Promise<OptionalOutputPath | ToolErrorResult>;
18
+ export declare function isValidJobId(jobId: string): boolean;
19
+ /**
20
+ * Resolve async-chat job status.json under OPENROUTER_OUTPUT_DIR/openrouter-jobs/.
21
+ * Uses realpath when the job directory exists to block symlink escapes.
22
+ */
23
+ export declare function resolveSafeJobStatusPath(jobsDir: string, jobId: string): Promise<string | null>;
@@ -142,3 +142,34 @@ export async function resolveOptionalOutputPath(savePath) {
142
142
  return toolErrorFrom(ErrorCode.INTERNAL, err);
143
143
  }
144
144
  }
145
+ const JOB_ID_PATTERN = /^chat_[a-zA-Z0-9_-]{1,128}$/;
146
+ export function isValidJobId(jobId) {
147
+ if (!JOB_ID_PATTERN.test(jobId))
148
+ return false;
149
+ if (jobId.includes('..') || jobId.includes('/') || jobId.includes('\\'))
150
+ return false;
151
+ return true;
152
+ }
153
+ /**
154
+ * Resolve async-chat job status.json under OPENROUTER_OUTPUT_DIR/openrouter-jobs/.
155
+ * Uses realpath when the job directory exists to block symlink escapes.
156
+ */
157
+ export async function resolveSafeJobStatusPath(jobsDir, jobId) {
158
+ if (!isValidJobId(jobId))
159
+ return null;
160
+ const rootReal = await fs.realpath(jobsDir).catch(() => path.resolve(jobsDir));
161
+ const withSep = rootReal.endsWith(path.sep) ? rootReal : rootReal + path.sep;
162
+ const jobDirCandidate = path.resolve(jobsDir, jobId);
163
+ let jobDirReal;
164
+ try {
165
+ jobDirReal = await fs.realpath(jobDirCandidate);
166
+ }
167
+ catch {
168
+ if (!(jobDirCandidate === rootReal || jobDirCandidate.startsWith(withSep)))
169
+ return null;
170
+ return path.join(jobDirCandidate, 'status.json');
171
+ }
172
+ if (!(jobDirReal === rootReal || jobDirReal.startsWith(withSep)))
173
+ return null;
174
+ return path.join(jobDirReal, 'status.json');
175
+ }
@@ -0,0 +1,2 @@
1
+ /** Strip existing extension (if any) and append a new one. */
2
+ export declare function replaceExtension(filePath: string, newExt: string): string;
@@ -0,0 +1,7 @@
1
+ import { extname } from 'node:path';
2
+ /** Strip existing extension (if any) and append a new one. */
3
+ export function replaceExtension(filePath, newExt) {
4
+ const current = extname(filePath);
5
+ const base = current ? filePath.slice(0, -current.length) : filePath;
6
+ return `${base}.${newExt}`;
7
+ }
@@ -14,16 +14,6 @@ export declare function handleTextToSpeech(request: {
14
14
  arguments: TextToSpeechRequest;
15
15
  };
16
16
  }, apiClient: OpenRouterAPIClient): Promise<import("../errors.js").ToolErrorResult | {
17
- content: ({
18
- type: "text";
19
- text: string;
20
- mimeType?: undefined;
21
- data?: undefined;
22
- } | {
23
- type: "audio";
24
- mimeType: string;
25
- data: string;
26
- text?: undefined;
27
- })[];
17
+ content: import("./tool-result-payload.js").BinaryToolContent[];
28
18
  _meta: Record<string, unknown>;
29
19
  }>;
@@ -6,6 +6,8 @@ import { ErrorCode, toolError, toolErrorFrom } from '../errors.js';
6
6
  import { SERVER_VERSION } from '../version.js';
7
7
  import { logger } from '../logger.js';
8
8
  import { classifyUpstreamError } from './openrouter-errors.js';
9
+ import { buildBinaryToolResult } from './tool-result-payload.js';
10
+ import { replaceExtension } from './path-utils.js';
9
11
  import { buildCacheHeaders } from './cache.js';
10
12
  const DEFAULT_MODEL = 'openai/gpt-4o-mini-tts-2025-12-15';
11
13
  const DEFAULT_VOICE = 'alloy';
@@ -61,7 +63,7 @@ export async function handleTextToSpeech(request, apiClient) {
61
63
  };
62
64
  if (safeSavePath) {
63
65
  const currentExt = extname(safeSavePath).toLowerCase().slice(1);
64
- const actualPath = currentExt === ext ? safeSavePath : `${safeSavePath}.${ext}`;
66
+ const actualPath = currentExt === ext ? safeSavePath : replaceExtension(safeSavePath, ext);
65
67
  try {
66
68
  await fs.writeFile(actualPath, buffer);
67
69
  }
@@ -69,19 +71,14 @@ export async function handleTextToSpeech(request, apiClient) {
69
71
  return toolErrorFrom(ErrorCode.INTERNAL, err, 'Write');
70
72
  }
71
73
  baseMeta.save_path = actualPath;
72
- return {
73
- content: [
74
- { type: 'text', text: `Speech saved to: ${actualPath}` },
75
- { type: 'audio', mimeType, data: buffer.toString('base64') },
76
- ],
77
- _meta: baseMeta,
78
- };
74
+ return buildBinaryToolResult({ kind: 'audio', buffer, mimeType }, {
75
+ savedPath: actualPath,
76
+ summaryText: `Speech saved to: ${actualPath}`,
77
+ meta: baseMeta,
78
+ });
79
79
  }
80
- return {
81
- content: [
82
- { type: 'text', text: `Speech generated (${buffer.length} bytes, ${mimeType}).` },
83
- { type: 'audio', mimeType, data: buffer.toString('base64') },
84
- ],
85
- _meta: baseMeta,
86
- };
80
+ return buildBinaryToolResult({ kind: 'audio', buffer, mimeType }, {
81
+ prefixText: `Speech generated (${buffer.length} bytes, ${mimeType}).`,
82
+ meta: baseMeta,
83
+ });
87
84
  }
@@ -0,0 +1,47 @@
1
+ export type InlineMediaKind = 'image' | 'audio' | 'video';
2
+ type TextContent = {
3
+ type: 'text';
4
+ text: string;
5
+ };
6
+ type ImageContent = {
7
+ type: 'image';
8
+ mimeType: string;
9
+ data: string;
10
+ };
11
+ type AudioContent = {
12
+ type: 'audio';
13
+ mimeType: string;
14
+ data: string;
15
+ };
16
+ type ResourceContent = {
17
+ type: 'resource';
18
+ resource: {
19
+ uri: string;
20
+ mimeType?: string;
21
+ blob: string;
22
+ };
23
+ };
24
+ export type BinaryToolContent = TextContent | ImageContent | AudioContent | ResourceContent;
25
+ export interface BinaryArtifact {
26
+ kind: InlineMediaKind;
27
+ buffer: Buffer;
28
+ mimeType: string;
29
+ }
30
+ export interface BuildBinaryToolResultOptions {
31
+ savedPath?: string | null;
32
+ /** Overrides default saved/too-large message */
33
+ summaryText?: string;
34
+ /** Shown alongside inline media when not using inlineOnly */
35
+ prefixText?: string;
36
+ /** When inline fits and no save_path: return media block only (image UX) */
37
+ inlineOnly?: boolean;
38
+ remoteUrl?: string;
39
+ meta?: Record<string, unknown>;
40
+ maxInlineBytes?: number;
41
+ }
42
+ export declare function getMaxInlineBytes(kind: InlineMediaKind): number;
43
+ export declare function buildBinaryToolResult(artifact: BinaryArtifact, opts?: BuildBinaryToolResultOptions): {
44
+ content: BinaryToolContent[];
45
+ _meta: Record<string, unknown>;
46
+ };
47
+ export {};
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Canonical MCP tool-result policy for binary artifacts:
3
+ * - saved to disk -> text pointer only (never duplicate inline media)
4
+ * - not saved -> inline only when under byte ceiling, else text + save_path hint
5
+ * - video uses MCP `resource` blocks (spec has no `video` content type)
6
+ */
7
+ import { readEnvInt } from './fetch-utils.js';
8
+ const DEFAULT_INLINE_MAX_BYTES = 1024 * 1024;
9
+ const DEFAULT_VIDEO_INLINE_MAX_BYTES = 10 * 1024 * 1024;
10
+ const INLINE_VIDEO_URI = 'inline://openrouter-mcp-multimodal/video';
11
+ const KIND_ENV_KEYS = {
12
+ image: 'OPENROUTER_IMAGE_INLINE_MAX_BYTES',
13
+ audio: 'OPENROUTER_AUDIO_INLINE_MAX_BYTES',
14
+ video: 'OPENROUTER_VIDEO_INLINE_MAX_BYTES',
15
+ };
16
+ const KIND_DEFAULT_BYTES = {
17
+ image: DEFAULT_INLINE_MAX_BYTES,
18
+ audio: DEFAULT_INLINE_MAX_BYTES,
19
+ video: DEFAULT_VIDEO_INLINE_MAX_BYTES,
20
+ };
21
+ export function getMaxInlineBytes(kind) {
22
+ const globalFallback = readEnvInt('OPENROUTER_INLINE_MAX_BYTES', KIND_DEFAULT_BYTES[kind], 4096);
23
+ return readEnvInt(KIND_ENV_KEYS[kind], globalFallback, 4096);
24
+ }
25
+ function kindLabel(kind) {
26
+ switch (kind) {
27
+ case 'image':
28
+ return 'Image';
29
+ case 'audio':
30
+ return 'Audio';
31
+ case 'video':
32
+ return 'Video';
33
+ default: {
34
+ const _exhaustive = kind;
35
+ return _exhaustive;
36
+ }
37
+ }
38
+ }
39
+ function buildInlineBlock(kind, mimeType, data, remoteUrl) {
40
+ if (kind === 'video') {
41
+ return {
42
+ type: 'resource',
43
+ resource: {
44
+ uri: remoteUrl ?? INLINE_VIDEO_URI,
45
+ mimeType,
46
+ blob: data,
47
+ },
48
+ };
49
+ }
50
+ return { type: kind, mimeType, data };
51
+ }
52
+ export function buildBinaryToolResult(artifact, opts = {}) {
53
+ const { kind, buffer, mimeType } = artifact;
54
+ const maxInline = opts.maxInlineBytes ?? getMaxInlineBytes(kind);
55
+ const meta = {
56
+ ...opts.meta,
57
+ mime: mimeType,
58
+ size_bytes: buffer.length,
59
+ };
60
+ if (opts.savedPath) {
61
+ const text = opts.summaryText ??
62
+ `${kindLabel(kind)} saved to: ${opts.savedPath} (${buffer.length} bytes, ${mimeType})`;
63
+ return {
64
+ content: [{ type: 'text', text }],
65
+ _meta: { ...meta, save_path: opts.savedPath },
66
+ };
67
+ }
68
+ if (buffer.length <= maxInline) {
69
+ const data = buffer.toString('base64');
70
+ if (opts.inlineOnly) {
71
+ return {
72
+ content: [buildInlineBlock(kind, mimeType, data, opts.remoteUrl)],
73
+ _meta: meta,
74
+ };
75
+ }
76
+ const text = opts.prefixText ?? `${kindLabel(kind)} generated (${buffer.length} bytes, ${mimeType}).`;
77
+ return {
78
+ content: [textBlock(text), buildInlineBlock(kind, mimeType, data, opts.remoteUrl)],
79
+ _meta: meta,
80
+ };
81
+ }
82
+ const urlHint = opts.remoteUrl ? ` URL: ${opts.remoteUrl}` : '';
83
+ return {
84
+ content: [
85
+ {
86
+ type: 'text',
87
+ text: opts.summaryText ??
88
+ `${kindLabel(kind)} generated (${buffer.length} bytes, ${mimeType}). Too large to inline; pass save_path to persist.${urlHint}`,
89
+ },
90
+ ],
91
+ _meta: meta,
92
+ };
93
+ }
94
+ function textBlock(text) {
95
+ return { type: 'text', text };
96
+ }
@@ -1,5 +1,5 @@
1
1
  import { CallToolRequestSchema, ErrorCode as McpErrorCode, ListToolsRequestSchema, McpError, } from '@modelcontextprotocol/sdk/types.js';
2
- import OpenAI from 'openai';
2
+ import { createOpenRouterOpenAIClient } from './openrouter-openai-client.js';
3
3
  import { ModelCache } from './model-cache.js';
4
4
  import { OpenRouterAPIClient } from './openrouter-api.js';
5
5
  import { handleChatCompletion } from './tool-handlers/chat-completion.js';
@@ -55,10 +55,7 @@ export class ToolHandlers {
55
55
  constructor(server, apiKey, defaultModel) {
56
56
  this.defaultModel = defaultModel;
57
57
  this.apiClient = new OpenRouterAPIClient(apiKey);
58
- this.openai = new OpenAI({
59
- apiKey,
60
- baseURL: 'https://openrouter.ai/api/v1',
61
- });
58
+ this.openai = createOpenRouterOpenAIClient(apiKey);
62
59
  this.server = server;
63
60
  this.register(server);
64
61
  }
package/dist/version.d.ts CHANGED
@@ -1,2 +1,2 @@
1
- export declare const SERVER_VERSION = "4.6.2";
1
+ export declare const SERVER_VERSION = "4.7.0";
2
2
  export declare const MCP_PROTOCOL_VERSION = "2025-06-18";
package/dist/version.js CHANGED
@@ -1,2 +1,2 @@
1
- export const SERVER_VERSION = '4.6.2';
1
+ export const SERVER_VERSION = '4.7.0';
2
2
  export const MCP_PROTOCOL_VERSION = '2025-06-18';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stabgan/openrouter-mcp-multimodal",
3
- "version": "4.6.2",
3
+ "version": "4.7.0",
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",