@stabgan/openrouter-mcp-multimodal 4.5.1 → 4.5.3

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.
Files changed (32) hide show
  1. package/README.md +367 -283
  2. package/dist/index.js +1 -1
  3. package/dist/model-cache.d.ts +22 -12
  4. package/dist/model-cache.js +58 -21
  5. package/dist/tool-descriptions.d.ts +19 -0
  6. package/dist/tool-descriptions.js +423 -0
  7. package/dist/tool-handlers/analyze-audio.js +5 -1
  8. package/dist/tool-handlers/analyze-image.js +6 -5
  9. package/dist/tool-handlers/analyze-video.js +6 -5
  10. package/dist/tool-handlers/audio-utils.js +4 -2
  11. package/dist/tool-handlers/chat-completion.js +1 -1
  12. package/dist/tool-handlers/fetch-utils.js +16 -2
  13. package/dist/tool-handlers/generate-audio.js +2 -4
  14. package/dist/tool-handlers/generate-image-input.d.ts +3 -0
  15. package/dist/tool-handlers/generate-image-input.js +38 -0
  16. package/dist/tool-handlers/generate-image.d.ts +13 -51
  17. package/dist/tool-handlers/generate-image.js +32 -119
  18. package/dist/tool-handlers/generate-video.js +28 -24
  19. package/dist/tool-handlers/image-utils.d.ts +1 -0
  20. package/dist/tool-handlers/image-utils.js +26 -16
  21. package/dist/tool-handlers/openrouter-errors.js +6 -2
  22. package/dist/tool-handlers/provider-routing.js +7 -2
  23. package/dist/tool-handlers/rerank.js +2 -5
  24. package/dist/tool-handlers/search-models.d.ts +2 -2
  25. package/dist/tool-handlers/search-models.js +2 -6
  26. package/dist/tool-handlers/structured-output.d.ts +8 -0
  27. package/dist/tool-handlers/structured-output.js +11 -0
  28. package/dist/tool-handlers/video-utils.js +6 -9
  29. package/dist/tool-handlers.js +25 -123
  30. package/dist/version.d.ts +1 -1
  31. package/dist/version.js +1 -1
  32. package/package.json +26 -14
@@ -1,6 +1,7 @@
1
1
  import path from 'path';
2
2
  import { promises as fs } from 'fs';
3
3
  import { readEnvInt, isBlockedIPv4 as _isBlockedIPv4, assertUrlSafeForFetch as _assertUrlSafeForFetch, fetchHttpResource, parseBase64DataUrl, } from './fetch-utils.js';
4
+ import { resolveSafeInputPath } from './path-safety.js';
4
5
  // Re-export for backward compatibility (tests import from image-utils)
5
6
  export const isBlockedIPv4 = _isBlockedIPv4;
6
7
  export const assertUrlSafeForFetch = _assertUrlSafeForFetch;
@@ -39,22 +40,29 @@ async function loadSharp() {
39
40
  sharpFn = fn ?? mod;
40
41
  }
41
42
  catch {
42
- console.error('sharp not available, images will be sent unprocessed');
43
+ // sharp is optional — images will be sent unprocessed (larger but functional)
44
+ const { logger } = await import('../logger.js');
45
+ logger.warn('sharp not available, images will be sent unprocessed');
43
46
  }
44
47
  }
45
48
  return sharpFn;
46
49
  }
50
+ const IMAGE_EXT_MIME = {
51
+ png: 'image/png',
52
+ jpg: 'image/jpeg',
53
+ jpeg: 'image/jpeg',
54
+ webp: 'image/webp',
55
+ gif: 'image/gif',
56
+ bmp: 'image/bmp',
57
+ };
58
+ export function mimeFromExtension(ext) {
59
+ const normalized = ext.toLowerCase().replace(/^\./, '');
60
+ if (!normalized)
61
+ return null;
62
+ return IMAGE_EXT_MIME[normalized] ?? null;
63
+ }
47
64
  export function getMimeType(filePath) {
48
- const ext = path.extname(filePath).toLowerCase();
49
- const map = {
50
- '.png': 'image/png',
51
- '.jpg': 'image/jpeg',
52
- '.jpeg': 'image/jpeg',
53
- '.webp': 'image/webp',
54
- '.gif': 'image/gif',
55
- '.bmp': 'image/bmp',
56
- };
57
- return map[ext] || 'image/jpeg';
65
+ return mimeFromExtension(path.extname(filePath)) ?? 'image/jpeg';
58
66
  }
59
67
  export async function fetchHttpImage(urlString) {
60
68
  const { buffer } = await fetchHttpResource(urlString, {
@@ -77,7 +85,8 @@ export async function fetchImage(source) {
77
85
  if (source.startsWith('http://') || source.startsWith('https://')) {
78
86
  return fetchHttpImage(source);
79
87
  }
80
- return fs.readFile(source);
88
+ const safe = await resolveSafeInputPath(source);
89
+ return fs.readFile(safe);
81
90
  }
82
91
  /**
83
92
  * Sniff image MIME type from magic bytes. Used to label the output of a
@@ -134,13 +143,14 @@ export async function optimizeImage(buffer) {
134
143
  const maxDim = getMaxImageDimension();
135
144
  const quality = getImageJpegQuality();
136
145
  try {
137
- const meta = await sharp(buffer).metadata();
138
- let pipeline = sharp(buffer);
146
+ const pipeline = sharp(buffer);
147
+ const meta = await pipeline.metadata();
148
+ let resized = pipeline;
139
149
  if (meta.width && meta.height && Math.max(meta.width, meta.height) > maxDim) {
140
150
  const opts = meta.width > meta.height ? { width: maxDim } : { height: maxDim };
141
- pipeline = pipeline.resize(opts);
151
+ resized = pipeline.resize(opts);
142
152
  }
143
- const out = await pipeline.jpeg({ quality }).toBuffer();
153
+ const out = await resized.jpeg({ quality }).toBuffer();
144
154
  return { base64: out.toString('base64'), mime: 'image/jpeg' };
145
155
  }
146
156
  catch {
@@ -110,7 +110,9 @@ export function classifyUpstreamError(err, contextMessage) {
110
110
  }
111
111
  // Model lookup failures.
112
112
  if (lower.includes('model') &&
113
- (lower.includes('does not exist') || lower.includes('not found') || lower.includes('invalid model'))) {
113
+ (lower.includes('does not exist') ||
114
+ lower.includes('not found') ||
115
+ lower.includes('invalid model'))) {
114
116
  return toolError(ErrorCode.MODEL_NOT_FOUND, fullMsg, { status }, {
115
117
  suggestions: [
116
118
  'Use search_models to discover valid model ids',
@@ -119,7 +121,9 @@ export function classifyUpstreamError(err, contextMessage) {
119
121
  });
120
122
  }
121
123
  // Content policy / moderation — surface as UPSTREAM_REFUSED so callers can distinguish from 5xx.
122
- if (lower.includes('content policy') || lower.includes('moderation') || lower.includes('refused')) {
124
+ if (lower.includes('content policy') ||
125
+ lower.includes('moderation') ||
126
+ lower.includes('refused')) {
123
127
  return toolError(ErrorCode.UPSTREAM_REFUSED, fullMsg, { status, reason: 'policy' }, {
124
128
  suggestions: ['Rephrase the prompt', 'Try a different provider via provider.order'],
125
129
  });
@@ -7,6 +7,7 @@
7
7
  * Precedence: explicit tool arg > env var > unset. Empty arrays / empty
8
8
  * objects are dropped so we don't send noise to the API.
9
9
  */
10
+ import { logger } from '../logger.js';
10
11
  function parseCsv(raw) {
11
12
  if (!raw)
12
13
  return undefined;
@@ -51,7 +52,9 @@ function parseSort(raw) {
51
52
  if (!raw)
52
53
  return undefined;
53
54
  const lc = raw.trim().toLowerCase();
54
- return lc === 'price' || lc === 'throughput' || lc === 'latency' ? lc : undefined;
55
+ return lc === 'price' || lc === 'throughput' || lc === 'latency'
56
+ ? lc
57
+ : undefined;
55
58
  }
56
59
  function parseDataCollection(raw) {
57
60
  if (!raw)
@@ -85,7 +88,9 @@ export function readProviderDefaults() {
85
88
  // operator notices instead of wondering why their ordering is being
86
89
  // ignored. All other OPENROUTER_PROVIDER_* fields follow the same
87
90
  // "silent drop" policy for consistency.
88
- console.error(`[openrouter-mcp] OPENROUTER_PROVIDER_ORDER ignored: ${err instanceof Error ? err.message : String(err)}`);
91
+ logger.warn('OPENROUTER_PROVIDER_ORDER ignored', {
92
+ err: err instanceof Error ? err.message : String(err),
93
+ });
89
94
  }
90
95
  const requireParams = parseBool(env.OPENROUTER_PROVIDER_REQUIRE_PARAMETERS);
91
96
  if (requireParams !== undefined)
@@ -3,8 +3,7 @@ import { classifyUpstreamError } from './openrouter-errors.js';
3
3
  import { buildStructuredResult } from './structured-output.js';
4
4
  const DEFAULT_MODEL = 'cohere/rerank-english-v3.0';
5
5
  export async function handleRerankDocuments(request, apiClient) {
6
- const args = request.params.arguments ??
7
- { query: '', documents: [] };
6
+ const args = request.params.arguments ?? { query: '', documents: [] };
8
7
  const { query, documents, model, top_n, return_documents } = args;
9
8
  if (!query?.trim()) {
10
9
  return toolError(ErrorCode.INVALID_INPUT, 'query is required.');
@@ -33,9 +32,7 @@ export async function handleRerankDocuments(request, apiClient) {
33
32
  const score = typeof r.score === 'number' ? r.score : r.relevance_score;
34
33
  const out = { index: r.index, score };
35
34
  if (return_documents) {
36
- const doc = typeof r.document === 'string'
37
- ? r.document
38
- : r.document?.text ?? documents[r.index];
35
+ const doc = typeof r.document === 'string' ? r.document : (r.document?.text ?? documents[r.index]);
39
36
  out.document = doc;
40
37
  }
41
38
  return out;
@@ -1,4 +1,4 @@
1
- import { ModelCache, type OpenRouterModelRecord } from '../model-cache.js';
1
+ import { ModelCache } from '../model-cache.js';
2
2
  import { OpenRouterAPIClient } from '../openrouter-api.js';
3
3
  export interface SearchModelsArgs {
4
4
  query?: string;
@@ -22,7 +22,7 @@ export declare function handleSearchModels(request: {
22
22
  arguments: SearchModelsArgs;
23
23
  };
24
24
  }, apiClient: OpenRouterAPIClient, modelCache: ModelCache): Promise<import("../errors.js").ToolErrorResult | import("./structured-output.js").StructuredResult<{
25
- results: OpenRouterModelRecord[];
25
+ results: import("../model-cache.js").OpenRouterModelRecord[];
26
26
  offset: number;
27
27
  limit: number;
28
28
  total: number;
@@ -14,15 +14,11 @@ export async function handleSearchModels(request, apiClient, modelCache) {
14
14
  const args = request.params.arguments ?? {};
15
15
  const limit = Math.min(Math.max(1, args.limit ?? DEFAULT_LIMIT), MAX_LIMIT);
16
16
  const offset = Math.max(0, args.offset ?? 0);
17
- // Get the full filtered set, then slice for pagination.
18
- const all = modelCache.search({
17
+ const { page, total } = modelCache.searchPaginated({
19
18
  query: args.query,
20
19
  provider: args.provider,
21
20
  capabilities: args.capabilities,
22
- all: true,
23
- });
24
- const total = all.length;
25
- const page = all.slice(offset, offset + limit);
21
+ }, offset, limit);
26
22
  const nextOffset = offset + limit;
27
23
  const hasMore = nextOffset < total;
28
24
  return buildStructuredResult({
@@ -11,3 +11,11 @@ export interface StructuredResult<T = unknown> {
11
11
  * format. `meta` is merged on top of the default `server_version` stamp.
12
12
  */
13
13
  export declare function buildStructuredResult<T>(data: T, meta?: Record<string, unknown>): StructuredResult<T>;
14
+ /** Read typed JSON from an MCP tool result (structuredContent or legacy text). */
15
+ export declare function readToolPayload<T = unknown>(result: {
16
+ structuredContent?: T;
17
+ content?: Array<{
18
+ type: string;
19
+ text?: string;
20
+ }>;
21
+ }): T;
@@ -22,3 +22,14 @@ export function buildStructuredResult(data, meta = {}) {
22
22
  _meta: { server_version: SERVER_VERSION, ...meta },
23
23
  };
24
24
  }
25
+ /** Read typed JSON from an MCP tool result (structuredContent or legacy text). */
26
+ export function readToolPayload(result) {
27
+ if (result.structuredContent !== undefined) {
28
+ return result.structuredContent;
29
+ }
30
+ const text = result.content?.[0]?.text;
31
+ if (text === undefined) {
32
+ throw new Error('tool result has no structuredContent or content text');
33
+ }
34
+ return JSON.parse(text);
35
+ }
@@ -9,6 +9,7 @@
9
9
  import path from 'node:path';
10
10
  import { promises as fs } from 'node:fs';
11
11
  import { readEnvInt, fetchHttpResource, parseBase64DataUrl } from './fetch-utils.js';
12
+ import { resolveSafeInputPath } from './path-safety.js';
12
13
  export { isBlockedIPv4, assertUrlSafeForFetch } from './fetch-utils.js';
13
14
  const DEFAULT_FETCH_TIMEOUT_MS = 60_000;
14
15
  const DEFAULT_MAX_DOWNLOAD_BYTES = 100 * 1024 * 1024; // 100 MB
@@ -93,10 +94,7 @@ export function detectVideoFormat(buffer) {
93
94
  }
94
95
  if (buffer.length >= 4) {
95
96
  // EBML header — WebM & Matroska.
96
- if (buffer[0] === 0x1a &&
97
- buffer[1] === 0x45 &&
98
- buffer[2] === 0xdf &&
99
- buffer[3] === 0xa3) {
97
+ if (buffer[0] === 0x1a && buffer[1] === 0x45 && buffer[2] === 0xdf && buffer[3] === 0xa3) {
100
98
  return 'webm';
101
99
  }
102
100
  // MPEG-PS / MPEG-TS start codes.
@@ -146,9 +144,7 @@ export async function prepareVideoData(source) {
146
144
  maxRedirects: getMaxRedirects(),
147
145
  });
148
146
  const urlPath = new URL(source).pathname;
149
- const format = detectVideoFormat(buffer) ??
150
- getVideoFormat(urlPath) ??
151
- formatFromContentType(contentType);
147
+ const format = detectVideoFormat(buffer) ?? getVideoFormat(urlPath) ?? formatFromContentType(contentType);
152
148
  if (!format) {
153
149
  throw new Error(`Could not determine video format from ${source}. Supported: ${SUPPORTED_VIDEO_FORMATS.join(', ')}`);
154
150
  }
@@ -160,8 +156,9 @@ export async function prepareVideoData(source) {
160
156
  };
161
157
  }
162
158
  // --- local file ---
163
- const buffer = await fs.readFile(source);
164
- const format = detectVideoFormat(buffer) ?? getVideoFormat(source);
159
+ const safe = await resolveSafeInputPath(source);
160
+ const buffer = await fs.readFile(safe);
161
+ const format = detectVideoFormat(buffer) ?? getVideoFormat(safe);
165
162
  if (!format) {
166
163
  throw new Error(`Unsupported video format for file: ${source}. Supported: ${SUPPORTED_VIDEO_FORMATS.join(', ')}`);
167
164
  }
@@ -14,6 +14,7 @@ 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 { TOOL_DESCRIPTIONS } from './tool-descriptions.js';
17
18
  function wrapToolArgs(a) {
18
19
  return { params: { arguments: a ?? {} } };
19
20
  }
@@ -49,124 +50,9 @@ function buildProgressHook(server, progressToken) {
49
50
  };
50
51
  }
51
52
  function extractProgressToken(req) {
52
- const meta = req?.params
53
- ?._meta;
53
+ const meta = req?.params?._meta;
54
54
  return meta?.progressToken;
55
55
  }
56
- // ---------------------------------------------------------------------------
57
- // Tool descriptions include explicit "Fails when" and "Works with" sections
58
- // per arxiv 2602.18764 (Schema-Guided Dialogue / MCP convergence). Explicit
59
- // failure-mode documentation reduces misrouted calls and helps the model
60
- // pick the right recovery path after an error.
61
- const TOOL_DESCRIPTIONS = {
62
- chat_completion: 'Send messages to an OpenRouter model and get a text response. Supports provider routing ' +
63
- '(quantizations / ignore / sort / order / require_parameters / data_collection / allow_fallbacks), ' +
64
- 'model variant suffixes (`:nitro` fastest, `:floor` cheapest, `:exacto` tool-calling accuracy), ' +
65
- 'reasoning token passthrough, web search, and response caching.\n\n' +
66
- 'Fails when:\n' +
67
- '- INVALID_INPUT: messages array is empty\n' +
68
- '- UPSTREAM_REFUSED: provider rejected the request (credits, content policy, or rate limit)\n' +
69
- '- UPSTREAM_TIMEOUT: upstream did not respond within the SDK timeout\n' +
70
- '- MODEL_NOT_FOUND: model slug does not exist on OpenRouter\n\n' +
71
- 'Works with: validate_model (pre-flight model id check), search_models (discover models).',
72
- analyze_image: 'Analyze an image with a vision model. Accepts local file paths, http(s) URLs, or base64 data URLs. ' +
73
- 'Output is model-generated and tagged `_meta.content_is_untrusted: true`.\n\n' +
74
- 'Fails when:\n' +
75
- '- INVALID_INPUT: image_path missing or malformed\n' +
76
- '- UNSAFE_PATH: local path escaped the sandbox\n' +
77
- '- RESOURCE_TOO_LARGE: image exceeded configured fetch size cap\n' +
78
- '- UPSTREAM_REFUSED: provider SSRF guard blocked the URL or content policy rejected\n\n' +
79
- 'Works with: search_models (find vision-capable models), generate_image (follow-up creation).',
80
- analyze_audio: 'Transcribe or analyze an audio file (WAV / MP3 / FLAC / OGG / etc.) using a multimodal model. ' +
81
- 'Output is tagged `_meta.content_is_untrusted: true`.\n\n' +
82
- 'Fails when:\n' +
83
- '- INVALID_INPUT: audio_path missing\n' +
84
- '- UNSUPPORTED_FORMAT: decoder could not identify the file as audio\n' +
85
- '- RESOURCE_TOO_LARGE: input exceeded size cap\n' +
86
- '- UPSTREAM_REFUSED: blocked host or content policy\n\n' +
87
- 'Works with: generate_audio (text-to-speech follow-up).',
88
- analyze_video: 'Describe or analyze a video (mp4 / mpeg / mov / webm) using a multimodal model. Default model: ' +
89
- 'google/gemini-2.5-flash. Output is tagged `_meta.content_is_untrusted: true`.\n\n' +
90
- 'Fails when:\n' +
91
- '- INVALID_INPUT: video_path missing\n' +
92
- '- UNSUPPORTED_FORMAT: not a recognized video container\n' +
93
- '- RESOURCE_TOO_LARGE: exceeds fetch cap\n' +
94
- '- UPSTREAM_REFUSED: SSRF block or provider refusal\n\n' +
95
- 'Works with: generate_video (text-to-video), get_video_status (poll async jobs).',
96
- search_models: 'Search OpenRouter\'s model catalog by name, provider, or capability. Returns a paginated list; ' +
97
- 'use `offset` / `limit` / `next_offset` to page through.\n\n' +
98
- 'Fails when:\n' +
99
- '- UPSTREAM_HTTP: /models endpoint returned an error\n' +
100
- '- UPSTREAM_REFUSED: invalid API key\n\n' +
101
- 'Works with: validate_model, get_model_info.',
102
- get_model_info: 'Get pricing / context-length / capability details for a specific model id.\n\n' +
103
- 'Fails when:\n' +
104
- '- INVALID_INPUT: model not provided\n' +
105
- '- MODEL_NOT_FOUND: model slug does not exist\n' +
106
- '- UPSTREAM_HTTP: model list fetch failed\n\n' +
107
- 'Works with: search_models (discover ids), validate_model (cheap existence check).',
108
- validate_model: 'Check whether a model id exists on OpenRouter. Cheap boolean lookup against the cached catalog.\n\n' +
109
- 'Fails when:\n' +
110
- '- INVALID_INPUT: model not provided\n' +
111
- '- UPSTREAM_HTTP: catalog refresh failed\n\n' +
112
- 'Works with: get_model_info (detailed lookup), chat_completion (pre-flight validation).',
113
- generate_image: 'Generate an image from a text prompt. Optional reference images condition the output for style / ' +
114
- 'identity consistency. Default model: google/gemini-2.5-flash-image.\n\n' +
115
- 'Fails when:\n' +
116
- '- INVALID_INPUT: prompt empty, bad aspect_ratio / image_size, unreadable reference image\n' +
117
- '- UNSAFE_PATH: save_path or input_images path escaped the sandbox\n' +
118
- '- UPSTREAM_REFUSED: provider content policy rejected or insufficient credits\n' +
119
- '- MODEL_NOT_FOUND: model slug invalid\n\n' +
120
- 'Works with: analyze_image (verify the result), generate_video_from_image (next step in workflow).',
121
- generate_audio: 'Generate audio (speech / music) from a text prompt. Format auto-detected, extension auto-corrected.\n\n' +
122
- 'Fails when:\n' +
123
- '- INVALID_INPUT: prompt empty\n' +
124
- '- UNSAFE_PATH: save_path escaped the sandbox\n' +
125
- '- UPSTREAM_REFUSED: content policy or credit issues\n\n' +
126
- 'Works with: analyze_audio (verify the result).',
127
- generate_video: 'Generate a video from a text prompt (optionally conditioned on first/last-frame or reference images). ' +
128
- 'Submits an async job, polls until completion or max_wait_ms, and downloads the result. Emits MCP ' +
129
- 'progress notifications when the client provides a `progressToken`. Default model: google/veo-3.1.\n\n' +
130
- 'Fails when:\n' +
131
- '- INVALID_INPUT: prompt empty\n' +
132
- '- UNSAFE_PATH: save_path or reference image paths escaped the sandbox\n' +
133
- '- UPSTREAM_REFUSED: content policy, credits, or bad request\n' +
134
- '- JOB_FAILED: provider marked the job as failed\n' +
135
- '- UNSUPPORTED_FORMAT: reference/frame image could not be decoded\n\n' +
136
- 'Returns successfully with `_meta.code: JOB_STILL_RUNNING` (NOT an error) when the timeout ' +
137
- 'elapses — the response carries `_meta.video_id` so callers can resume via get_video_status.\n\n' +
138
- 'Works with: get_video_status (resume timed-out jobs), generate_video_from_image (narrower image-to-video variant).',
139
- generate_video_from_image: 'Narrower convenience wrapper around generate_video for image-to-video workflows. Takes a single ' +
140
- '`image` argument (used as the first frame) and `prompt`. Per arxiv 2511.03497, narrower tools with ' +
141
- 'fewer parameters improve tool-call hit rate. For last-frame conditioning or reference images, use ' +
142
- 'generate_video directly.\n\n' +
143
- 'Fails when:\n' +
144
- '- INVALID_INPUT: image or prompt missing\n' +
145
- '- UNSAFE_PATH: image path escaped the sandbox\n' +
146
- '- UPSTREAM_REFUSED / JOB_FAILED: same as generate_video\n\n' +
147
- 'Returns successfully with `_meta.code: JOB_STILL_RUNNING` on timeout (resumable via ' +
148
- 'get_video_status).\n\n' +
149
- 'Works with: generate_video (full parameter surface), get_video_status.',
150
- get_video_status: 'Poll an async video-generation job by id. Downloads the result when complete (and saves if save_path given).\n\n' +
151
- 'Fails when:\n' +
152
- '- INVALID_INPUT: video_id missing\n' +
153
- '- UNSAFE_PATH: save_path escaped the sandbox\n' +
154
- '- JOB_FAILED: provider marked the job as failed\n\n' +
155
- 'Returns successfully with `_meta.code: JOB_STILL_RUNNING` (NOT an error) when the job is still ' +
156
- 'in flight — response carries `_meta.last_status` and `_meta.progress` so callers can retry later.\n\n' +
157
- 'Works with: generate_video, generate_video_from_image.',
158
- rerank_documents: 'Re-order a list of documents by relevance to a query using an OpenRouter reranker. Default model: ' +
159
- 'cohere/rerank-english-v3.0.\n\n' +
160
- 'Fails when:\n' +
161
- '- INVALID_INPUT: query missing, documents empty, non-string document elements\n' +
162
- '- MODEL_NOT_FOUND: reranker model slug does not exist\n' +
163
- '- UPSTREAM_HTTP: provider returned an error\n\n' +
164
- 'Works with: search_models (discover rerankers), chat_completion (answer grounded in top-ranked docs).',
165
- health_check: 'Verify API-key validity, OpenRouter reachability, and return server + protocol versions. No args.\n\n' +
166
- 'Fails when: never returns an error result — always returns `{ ok, api_key_valid, ... }` so ops can ' +
167
- 'programmatically branch on the payload.\n\n' +
168
- 'Works with: every other tool (run once at startup to confirm credentials).',
169
- };
170
56
  export class ToolHandlers {
171
57
  openai;
172
58
  modelCache = ModelCache.getInstance();
@@ -252,11 +138,11 @@ export class ToolHandlers {
252
138
  },
253
139
  include_reasoning: {
254
140
  type: 'boolean',
255
- description: 'Surface the model\'s chain-of-thought on `_meta.reasoning` for R1 / Opus 4.7 / Gemini Thinking.',
141
+ description: "Surface the model's chain-of-thought on `_meta.reasoning` for R1 / Opus 4.7 / Gemini Thinking.",
256
142
  },
257
143
  online: {
258
144
  type: 'boolean',
259
- description: 'Enable OpenRouter\'s web-search plugin (Exa-backed, $4 / 1000 results).',
145
+ description: "Enable OpenRouter's web-search plugin (Exa-backed, $4 / 1000 results).",
260
146
  },
261
147
  web_max_results: {
262
148
  type: 'number',
@@ -293,8 +179,16 @@ export class ToolHandlers {
293
179
  inputSchema: {
294
180
  type: 'object',
295
181
  properties: {
296
- image_path: { type: 'string', description: 'File path, URL, or data URL' },
297
- question: { type: 'string', description: 'Question about the image' },
182
+ image_path: {
183
+ type: 'string',
184
+ description: 'Required. Local path (inside OPENROUTER_INPUT_DIR sandbox), https URL, or data URL. ' +
185
+ 'Good: `"photo.jpg"`. Bad: `"url": "..."` (wrong key), `"/etc/passwd"` (UNSAFE_PATH).',
186
+ },
187
+ question: {
188
+ type: 'string',
189
+ description: 'Optional question about the image. Defaults to "What\'s in this image?" if omitted. ' +
190
+ 'Good: `"List all text"`. Bad: using `prompt` key (wrong name for this tool).',
191
+ },
298
192
  model: { type: 'string' },
299
193
  cache_input: {
300
194
  type: 'boolean',
@@ -323,7 +217,8 @@ export class ToolHandlers {
323
217
  properties: {
324
218
  audio_path: {
325
219
  type: 'string',
326
- description: 'File path, URL, or data URL (base64-encoded audio)',
220
+ description: 'Local file path (sandboxed to OPENROUTER_INPUT_DIR / OPENROUTER_OUTPUT_DIR / cwd), ' +
221
+ 'http(s) URL, or data URL (base64-encoded audio)',
327
222
  },
328
223
  question: {
329
224
  type: 'string',
@@ -353,7 +248,8 @@ export class ToolHandlers {
353
248
  properties: {
354
249
  video_path: {
355
250
  type: 'string',
356
- description: 'File path, HTTP(S) URL, or base64 data URL. Supported: mp4 / mpeg / mov / webm.',
251
+ description: 'Local file path (sandboxed to OPENROUTER_INPUT_DIR / OPENROUTER_OUTPUT_DIR / cwd), ' +
252
+ 'http(s) URL, or base64 data URL. Supported: mp4 / mpeg / mov / webm.',
357
253
  },
358
254
  question: { type: 'string' },
359
255
  model: { type: 'string' },
@@ -661,7 +557,13 @@ export class ToolHandlers {
661
557
  models_cached: { type: 'number' },
662
558
  error: { type: 'string' },
663
559
  },
664
- required: ['ok', 'server_version', 'protocol_version', 'api_key_valid', 'models_cached'],
560
+ required: [
561
+ 'ok',
562
+ 'server_version',
563
+ 'protocol_version',
564
+ 'api_key_valid',
565
+ 'models_cached',
566
+ ],
665
567
  },
666
568
  },
667
569
  ],
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.5.1";
9
+ export declare const SERVER_VERSION = "4.5.3";
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.5.1';
9
+ export const SERVER_VERSION = '4.5.3';
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.5.1",
3
+ "version": "4.5.3",
4
4
  "mcpName": "io.github.stabgan/openrouter-multimodal",
5
5
  "description": "MCP server for OpenRouter with text chat, image analysis + generation, audio analysis + generation, video analysis, and video generation (Veo 3.1 / Sora 2 Pro / Seedance / Wan)",
6
6
  "type": "module",
@@ -19,8 +19,18 @@
19
19
  "lint": "eslint src",
20
20
  "format": "prettier --write \"src/**/*.ts\" *.json \"*.md\"",
21
21
  "format:check": "prettier --check \"src/**/*.ts\"",
22
- "test": "node --experimental-vm-modules node_modules/.bin/vitest run",
23
- "test:integration": "node --experimental-vm-modules node_modules/.bin/vitest run --config vitest.integration.config.ts"
22
+ "test": "vitest run",
23
+ "test:regression": "vitest run --config vitest.regression.config.ts",
24
+ "test:integration": "vitest run --config vitest.integration.config.ts",
25
+ "test:e2e": "npm run build && node scripts/live-e2e.mjs",
26
+ "test:smoke:npm": "npm run build && npm pack --quiet && node scripts/smoke-npm-mcp.mjs",
27
+ "test:smoke:docker": "node scripts/smoke-docker-mcp.mjs",
28
+ "test:smoke:uvx": "node scripts/smoke-uvx-mcp.mjs",
29
+ "test:smoke:uvx:local": "npm run build && npm pack --quiet && MCP_UVX_LOCAL=1 node scripts/smoke-uvx-mcp.mjs",
30
+ "test:smoke:uvx:git": "MCP_UVX_FROM_GIT=1 node scripts/smoke-uvx-mcp.mjs",
31
+ "test:smoke": "npm run test:smoke:npm && npm run test:smoke:docker && npm run test:smoke:uvx:local",
32
+ "test:all": "npm test && npm run test:regression && npm run test:integration",
33
+ "ci": "npm run lint && npm run format:check && npm run build && npm run test:all"
24
34
  },
25
35
  "keywords": [
26
36
  "mcp",
@@ -44,23 +54,25 @@
44
54
  "homepage": "https://github.com/stabgan/openrouter-mcp-multimodal#readme",
45
55
  "license": "Apache-2.0",
46
56
  "engines": {
47
- "node": ">=18.0.0"
57
+ "node": ">=20.0.0"
48
58
  },
49
59
  "dependencies": {
50
- "@modelcontextprotocol/sdk": "^1.27.1",
51
- "dotenv": "^16.4.7",
52
- "openai": "^4.89.1",
53
- "sharp": "^0.33.5"
60
+ "@modelcontextprotocol/sdk": "^1.29.0",
61
+ "dotenv": "^17.4.2",
62
+ "openai": "^4.104.0",
63
+ "sharp": "^0.35.3"
54
64
  },
55
65
  "devDependencies": {
56
66
  "@eslint/js": "^9.39.2",
57
- "@types/node": "^22.13.14",
67
+ "@types/node": "^22.20.0",
58
68
  "eslint": "^9.39.2",
59
69
  "eslint-config-prettier": "^10.1.8",
60
- "prettier": "^3.7.4",
61
- "shx": "^0.3.4",
62
- "typescript": "^5.8.2",
63
- "typescript-eslint": "^8.53.0",
64
- "vitest": "^3.1.1"
70
+ "form-data": "^4.0.6",
71
+ "js-yaml": "^4.3.0",
72
+ "prettier": "^3.9.4",
73
+ "shx": "^0.4.0",
74
+ "typescript": "^5.9.3",
75
+ "typescript-eslint": "^8.62.1",
76
+ "vitest": "^4.1.9"
65
77
  }
66
78
  }