@stabgan/openrouter-mcp-multimodal 4.5.0 → 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 (34) hide show
  1. package/README.md +380 -242
  2. package/dist/index.js +22 -6
  3. package/dist/model-cache.d.ts +35 -12
  4. package/dist/model-cache.js +79 -22
  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/health-check.js +4 -1
  20. package/dist/tool-handlers/image-utils.d.ts +1 -0
  21. package/dist/tool-handlers/image-utils.js +26 -16
  22. package/dist/tool-handlers/openrouter-errors.d.ts +5 -1
  23. package/dist/tool-handlers/openrouter-errors.js +78 -13
  24. package/dist/tool-handlers/provider-routing.js +7 -2
  25. package/dist/tool-handlers/rerank.js +2 -5
  26. package/dist/tool-handlers/search-models.d.ts +2 -2
  27. package/dist/tool-handlers/search-models.js +2 -6
  28. package/dist/tool-handlers/structured-output.d.ts +8 -0
  29. package/dist/tool-handlers/structured-output.js +11 -0
  30. package/dist/tool-handlers/video-utils.js +6 -9
  31. package/dist/tool-handlers.js +43 -123
  32. package/dist/version.d.ts +1 -1
  33. package/dist/version.js +1 -1
  34. package/package.json +26 -14
@@ -5,6 +5,29 @@
5
5
  * don't drift.
6
6
  */
7
7
  import { ErrorCode, toolError } from '../errors.js';
8
+ function extractRetryAfterSeconds(err) {
9
+ if (typeof err !== 'object' || err === null)
10
+ return undefined;
11
+ const e = err;
12
+ const getHeader = (h) => {
13
+ if (!h)
14
+ return null;
15
+ if (typeof h === 'object' && typeof h.get === 'function') {
16
+ return h.get('retry-after') ?? null;
17
+ }
18
+ const rec = h;
19
+ return rec['retry-after'] ?? rec['Retry-After'] ?? null;
20
+ };
21
+ const raw = getHeader(e.headers) ?? getHeader(e.response?.headers);
22
+ if (!raw)
23
+ return undefined;
24
+ const n = Number(raw);
25
+ if (Number.isFinite(n) && n >= 0)
26
+ return n;
27
+ // Retry-After can also be an HTTP-date; return undefined for those (caller
28
+ // can still retry on its own backoff schedule).
29
+ return undefined;
30
+ }
8
31
  function extractStatus(err) {
9
32
  if (typeof err !== 'object' || err === null)
10
33
  return undefined;
@@ -49,43 +72,82 @@ function extractMessage(err) {
49
72
  * 2. Message heuristics for common OpenRouter strings (credits, ZDR,
50
73
  * "model does not exist", content policy, etc.).
51
74
  * 3. Default to INTERNAL to avoid leaking raw shapes.
75
+ *
76
+ * When the error carries a `Retry-After` header (on 429 / 503) we populate
77
+ * `_meta.retry_after_seconds` so agents can back off intelligently. We
78
+ * also attach canonical `suggestions[]` for common cases.
52
79
  */
53
- export function classifyUpstreamError(err, _contextMessage) {
54
- const msg = extractMessage(err);
80
+ export function classifyUpstreamError(err, contextMessage) {
81
+ const rawMsg = extractMessage(err);
55
82
  const status = extractStatus(err);
56
- const lower = msg.toLowerCase();
57
- const fullMsg = msg;
83
+ const lower = rawMsg.toLowerCase();
84
+ // Prefix every user-visible message with the handler context when the
85
+ // caller supplied one (e.g. `rerank`, `generate_video.submit`). Makes
86
+ // server-side triage possible without digging through logs.
87
+ const fullMsg = contextMessage ? `${contextMessage}: ${rawMsg}` : rawMsg;
88
+ const retryAfterSeconds = extractRetryAfterSeconds(err);
58
89
  // Explicit credit / balance signals.
59
90
  if (lower.includes('insufficient balance') ||
60
91
  lower.includes('insufficient credits') ||
61
92
  lower.includes('requires more credits') ||
62
93
  lower.includes('requires at least') ||
63
94
  status === 402) {
64
- return toolError(ErrorCode.UPSTREAM_REFUSED, fullMsg, { status, reason: 'credits' });
95
+ return toolError(ErrorCode.UPSTREAM_REFUSED, fullMsg, { status, reason: 'credits' }, {
96
+ suggestions: [
97
+ 'Top up credits at https://openrouter.ai/settings/credits',
98
+ 'Switch to a free-tier model (append :free to the slug)',
99
+ ],
100
+ });
65
101
  }
66
102
  // Zero Data Retention.
67
103
  if (lower.includes('zdr') || lower.includes('zero data retention')) {
68
- return toolError(ErrorCode.ZDR_INCOMPATIBLE, fullMsg, { status });
104
+ return toolError(ErrorCode.ZDR_INCOMPATIBLE, fullMsg, { status }, {
105
+ suggestions: [
106
+ 'Pick a provider that supports your ZDR policy',
107
+ 'Set provider.data_collection: "allow" to bypass the restriction',
108
+ ],
109
+ });
69
110
  }
70
111
  // Model lookup failures.
71
112
  if (lower.includes('model') &&
72
- (lower.includes('does not exist') || lower.includes('not found') || lower.includes('invalid model'))) {
73
- return toolError(ErrorCode.MODEL_NOT_FOUND, fullMsg, { status });
113
+ (lower.includes('does not exist') ||
114
+ lower.includes('not found') ||
115
+ lower.includes('invalid model'))) {
116
+ return toolError(ErrorCode.MODEL_NOT_FOUND, fullMsg, { status }, {
117
+ suggestions: [
118
+ 'Use search_models to discover valid model ids',
119
+ 'Use validate_model to pre-flight a model id',
120
+ ],
121
+ });
74
122
  }
75
123
  // Content policy / moderation — surface as UPSTREAM_REFUSED so callers can distinguish from 5xx.
76
- if (lower.includes('content policy') || lower.includes('moderation') || lower.includes('refused')) {
77
- return toolError(ErrorCode.UPSTREAM_REFUSED, fullMsg, { status, reason: 'policy' });
124
+ if (lower.includes('content policy') ||
125
+ lower.includes('moderation') ||
126
+ lower.includes('refused')) {
127
+ return toolError(ErrorCode.UPSTREAM_REFUSED, fullMsg, { status, reason: 'policy' }, {
128
+ suggestions: ['Rephrase the prompt', 'Try a different provider via provider.order'],
129
+ });
78
130
  }
79
131
  // Rate-limit specific.
80
132
  if (status === 429 || lower.includes('rate limit')) {
81
- return toolError(ErrorCode.UPSTREAM_REFUSED, fullMsg, { status, reason: 'rate_limit' });
133
+ return toolError(ErrorCode.UPSTREAM_REFUSED, fullMsg, { status, reason: 'rate_limit' }, {
134
+ suggestions: [
135
+ retryAfterSeconds !== undefined
136
+ ? `Wait ${retryAfterSeconds}s and retry`
137
+ : 'Wait and retry with exponential backoff',
138
+ 'Append :nitro to the model slug to route to a faster provider',
139
+ ],
140
+ retry_after_seconds: retryAfterSeconds,
141
+ });
82
142
  }
83
143
  // Timeouts (AbortError from `AbortSignal.timeout`).
84
144
  if (lower.includes('timed out') ||
85
145
  lower.includes('timeout') ||
86
146
  lower.includes('aborted') ||
87
147
  (err instanceof Error && err.name === 'AbortError')) {
88
- return toolError(ErrorCode.UPSTREAM_TIMEOUT, fullMsg, { status });
148
+ return toolError(ErrorCode.UPSTREAM_TIMEOUT, fullMsg, { status }, {
149
+ suggestions: ['Retry', 'Raise max_wait_ms or max_tokens'],
150
+ });
89
151
  }
90
152
  // Anything in the 4xx band that isn't covered above — user supplied a bad request.
91
153
  if (typeof status === 'number' && status >= 400 && status < 500) {
@@ -93,7 +155,10 @@ export function classifyUpstreamError(err, _contextMessage) {
93
155
  }
94
156
  // 5xx / network errors.
95
157
  if (typeof status === 'number' && status >= 500) {
96
- return toolError(ErrorCode.UPSTREAM_HTTP, fullMsg, { status });
158
+ return toolError(ErrorCode.UPSTREAM_HTTP, fullMsg, { status }, {
159
+ suggestions: ['Retry after a brief delay', 'Check https://status.openrouter.ai'],
160
+ retry_after_seconds: retryAfterSeconds,
161
+ });
97
162
  }
98
163
  return toolError(ErrorCode.UPSTREAM_HTTP, fullMsg);
99
164
  }
@@ -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,141 +14,45 @@ 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
  }
20
21
  function buildProgressHook(server, progressToken) {
21
22
  if (progressToken === undefined)
22
23
  return undefined;
24
+ // MCP `notifications/progress` REQUIRES `progress` to be strictly
25
+ // monotonically increasing within a single progressToken. OpenRouter
26
+ // returns `progress: 0..100` on some ticks and omits it on others, so
27
+ // we anchor on a per-hook attempt counter and use the upstream number
28
+ // only as an informational `message`. This guarantees monotonicity
29
+ // regardless of what the upstream does (drops, duplicates, decreases).
30
+ //
31
+ // See MCP spec 2025-06-18 utilities/progress §Behavior Requirements:
32
+ // "The progress value MUST increase with each notification, even if
33
+ // the total is unknown."
34
+ let lastSent = -1;
23
35
  return ({ status, progress, attempt, video_id }) => {
36
+ // Always monotonic: at least attempt+1 (so initial attempt=0 → 0 stays
37
+ // reserved for the 'submitted' ping). If upstream has a real numeric
38
+ // progress that's higher than our counter, adopt that.
39
+ const candidate = typeof progress === 'number' ? Math.max(attempt, progress) : attempt;
40
+ const next = Math.max(lastSent + 1, candidate);
41
+ lastSent = next;
24
42
  void server.notification({
25
43
  method: 'notifications/progress',
26
44
  params: {
27
45
  progressToken,
28
- // Progress MUST increase per the MCP spec. We use attempt as a
29
- // monotonic counter when the upstream doesn't return a numeric
30
- // progress value.
31
- progress: typeof progress === 'number' ? progress : attempt,
32
- ...(typeof progress === 'number' ? { total: 100 } : {}),
46
+ progress: next,
33
47
  message: `video ${video_id} — ${status}${typeof progress === 'number' ? ` (${progress}%)` : ''}`,
34
48
  },
35
49
  });
36
50
  };
37
51
  }
38
52
  function extractProgressToken(req) {
39
- const meta = req?.params
40
- ?._meta;
53
+ const meta = req?.params?._meta;
41
54
  return meta?.progressToken;
42
55
  }
43
- // ---------------------------------------------------------------------------
44
- // Tool descriptions include explicit "Fails when" and "Works with" sections
45
- // per arxiv 2602.18764 (Schema-Guided Dialogue / MCP convergence). Explicit
46
- // failure-mode documentation reduces misrouted calls and helps the model
47
- // pick the right recovery path after an error.
48
- const TOOL_DESCRIPTIONS = {
49
- chat_completion: 'Send messages to an OpenRouter model and get a text response. Supports provider routing ' +
50
- '(quantizations / ignore / sort / order / require_parameters / data_collection / allow_fallbacks), ' +
51
- 'model variant suffixes (`:nitro` fastest, `:floor` cheapest, `:exacto` tool-calling accuracy), ' +
52
- 'reasoning token passthrough, web search, and response caching.\n\n' +
53
- 'Fails when:\n' +
54
- '- INVALID_INPUT: messages array is empty\n' +
55
- '- UPSTREAM_REFUSED: provider rejected the request (credits, content policy, or rate limit)\n' +
56
- '- UPSTREAM_TIMEOUT: upstream did not respond within the SDK timeout\n' +
57
- '- MODEL_NOT_FOUND: model slug does not exist on OpenRouter\n\n' +
58
- 'Works with: validate_model (pre-flight model id check), search_models (discover models).',
59
- analyze_image: 'Analyze an image with a vision model. Accepts local file paths, http(s) URLs, or base64 data URLs. ' +
60
- 'Output is model-generated and tagged `_meta.content_is_untrusted: true`.\n\n' +
61
- 'Fails when:\n' +
62
- '- INVALID_INPUT: image_path missing or malformed\n' +
63
- '- UNSAFE_PATH: local path escaped the sandbox\n' +
64
- '- RESOURCE_TOO_LARGE: image exceeded configured fetch size cap\n' +
65
- '- UPSTREAM_REFUSED: provider SSRF guard blocked the URL or content policy rejected\n\n' +
66
- 'Works with: search_models (find vision-capable models), generate_image (follow-up creation).',
67
- analyze_audio: 'Transcribe or analyze an audio file (WAV / MP3 / FLAC / OGG / etc.) using a multimodal model. ' +
68
- 'Output is tagged `_meta.content_is_untrusted: true`.\n\n' +
69
- 'Fails when:\n' +
70
- '- INVALID_INPUT: audio_path missing\n' +
71
- '- UNSUPPORTED_FORMAT: decoder could not identify the file as audio\n' +
72
- '- RESOURCE_TOO_LARGE: input exceeded size cap\n' +
73
- '- UPSTREAM_REFUSED: blocked host or content policy\n\n' +
74
- 'Works with: generate_audio (text-to-speech follow-up).',
75
- analyze_video: 'Describe or analyze a video (mp4 / mpeg / mov / webm) using a multimodal model. Default model: ' +
76
- 'google/gemini-2.5-flash. Output is tagged `_meta.content_is_untrusted: true`.\n\n' +
77
- 'Fails when:\n' +
78
- '- INVALID_INPUT: video_path missing\n' +
79
- '- UNSUPPORTED_FORMAT: not a recognized video container\n' +
80
- '- RESOURCE_TOO_LARGE: exceeds fetch cap\n' +
81
- '- UPSTREAM_REFUSED: SSRF block or provider refusal\n\n' +
82
- 'Works with: generate_video (text-to-video), get_video_status (poll async jobs).',
83
- search_models: 'Search OpenRouter\'s model catalog by name, provider, or capability. Returns a paginated list; ' +
84
- 'use `offset` / `limit` / `next_offset` to page through.\n\n' +
85
- 'Fails when:\n' +
86
- '- UPSTREAM_HTTP: /models endpoint returned an error\n' +
87
- '- UPSTREAM_REFUSED: invalid API key\n\n' +
88
- 'Works with: validate_model, get_model_info.',
89
- get_model_info: 'Get pricing / context-length / capability details for a specific model id.\n\n' +
90
- 'Fails when:\n' +
91
- '- INVALID_INPUT: model not provided\n' +
92
- '- MODEL_NOT_FOUND: model slug does not exist\n' +
93
- '- UPSTREAM_HTTP: model list fetch failed\n\n' +
94
- 'Works with: search_models (discover ids), validate_model (cheap existence check).',
95
- validate_model: 'Check whether a model id exists on OpenRouter. Cheap boolean lookup against the cached catalog.\n\n' +
96
- 'Fails when:\n' +
97
- '- INVALID_INPUT: model not provided\n' +
98
- '- UPSTREAM_HTTP: catalog refresh failed\n\n' +
99
- 'Works with: get_model_info (detailed lookup), chat_completion (pre-flight validation).',
100
- generate_image: 'Generate an image from a text prompt. Optional reference images condition the output for style / ' +
101
- 'identity consistency. Default model: google/gemini-2.5-flash-image.\n\n' +
102
- 'Fails when:\n' +
103
- '- INVALID_INPUT: prompt empty, bad aspect_ratio / image_size, unreadable reference image\n' +
104
- '- UNSAFE_PATH: save_path or input_images path escaped the sandbox\n' +
105
- '- UPSTREAM_REFUSED: provider content policy rejected or insufficient credits\n' +
106
- '- MODEL_NOT_FOUND: model slug invalid\n\n' +
107
- 'Works with: analyze_image (verify the result), generate_video_from_image (next step in workflow).',
108
- generate_audio: 'Generate audio (speech / music) from a text prompt. Format auto-detected, extension auto-corrected.\n\n' +
109
- 'Fails when:\n' +
110
- '- INVALID_INPUT: prompt empty\n' +
111
- '- UNSAFE_PATH: save_path escaped the sandbox\n' +
112
- '- UPSTREAM_REFUSED: content policy or credit issues\n\n' +
113
- 'Works with: analyze_audio (verify the result).',
114
- generate_video: 'Generate a video from a text prompt (optionally conditioned on first/last-frame or reference images). ' +
115
- 'Submits an async job, polls until completion or max_wait_ms, and downloads the result. Emits MCP ' +
116
- 'progress notifications when the client provides a `progressToken`. Default model: google/veo-3.1.\n\n' +
117
- 'Fails when:\n' +
118
- '- INVALID_INPUT: prompt empty\n' +
119
- '- UNSAFE_PATH: save_path or reference image paths escaped the sandbox\n' +
120
- '- UPSTREAM_REFUSED: content policy, credits, or bad request\n' +
121
- '- JOB_FAILED: provider marked the job as failed\n' +
122
- '- JOB_STILL_RUNNING: exceeded max_wait_ms (response carries the video_id to resume)\n' +
123
- '- UNSUPPORTED_FORMAT: reference/frame image could not be decoded\n\n' +
124
- 'Works with: get_video_status (resume timed-out jobs), generate_video_from_image (narrower image-to-video variant).',
125
- generate_video_from_image: 'Narrower convenience wrapper around generate_video for image-to-video workflows. Takes a single ' +
126
- '`image` argument (used as the first frame) and `prompt`. Per arxiv 2511.03497, narrower tools with ' +
127
- 'fewer parameters improve tool-call hit rate.\n\n' +
128
- 'Fails when:\n' +
129
- '- INVALID_INPUT: image or prompt missing\n' +
130
- '- UNSAFE_PATH: image path escaped the sandbox\n' +
131
- '- UPSTREAM_REFUSED / JOB_FAILED / JOB_STILL_RUNNING: same as generate_video\n\n' +
132
- 'Works with: generate_video (full parameter surface), get_video_status.',
133
- get_video_status: 'Poll an async video-generation job by id. Downloads the result when complete (and saves if save_path given).\n\n' +
134
- 'Fails when:\n' +
135
- '- INVALID_INPUT: video_id missing\n' +
136
- '- UNSAFE_PATH: save_path escaped the sandbox\n' +
137
- '- JOB_FAILED: provider marked the job as failed\n' +
138
- '- JOB_STILL_RUNNING: job not yet complete (carries `_meta.last_status` + `progress`)\n\n' +
139
- 'Works with: generate_video, generate_video_from_image.',
140
- rerank_documents: 'Re-order a list of documents by relevance to a query using an OpenRouter reranker. Default model: ' +
141
- 'cohere/rerank-english-v3.0.\n\n' +
142
- 'Fails when:\n' +
143
- '- INVALID_INPUT: query missing, documents empty, non-string document elements\n' +
144
- '- MODEL_NOT_FOUND: reranker model slug does not exist\n' +
145
- '- UPSTREAM_HTTP: provider returned an error\n\n' +
146
- 'Works with: search_models (discover rerankers), chat_completion (answer grounded in top-ranked docs).',
147
- health_check: 'Verify API-key validity, OpenRouter reachability, and return server + protocol versions. No args.\n\n' +
148
- 'Fails when: never returns an error result — always returns `{ ok, api_key_valid, ... }` so ops can ' +
149
- 'programmatically branch on the payload.\n\n' +
150
- 'Works with: every other tool (run once at startup to confirm credentials).',
151
- };
152
56
  export class ToolHandlers {
153
57
  openai;
154
58
  modelCache = ModelCache.getInstance();
@@ -234,11 +138,11 @@ export class ToolHandlers {
234
138
  },
235
139
  include_reasoning: {
236
140
  type: 'boolean',
237
- 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.",
238
142
  },
239
143
  online: {
240
144
  type: 'boolean',
241
- 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).",
242
146
  },
243
147
  web_max_results: {
244
148
  type: 'number',
@@ -275,8 +179,16 @@ export class ToolHandlers {
275
179
  inputSchema: {
276
180
  type: 'object',
277
181
  properties: {
278
- image_path: { type: 'string', description: 'File path, URL, or data URL' },
279
- 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
+ },
280
192
  model: { type: 'string' },
281
193
  cache_input: {
282
194
  type: 'boolean',
@@ -305,7 +217,8 @@ export class ToolHandlers {
305
217
  properties: {
306
218
  audio_path: {
307
219
  type: 'string',
308
- 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)',
309
222
  },
310
223
  question: {
311
224
  type: 'string',
@@ -335,7 +248,8 @@ export class ToolHandlers {
335
248
  properties: {
336
249
  video_path: {
337
250
  type: 'string',
338
- 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.',
339
253
  },
340
254
  question: { type: 'string' },
341
255
  model: { type: 'string' },
@@ -643,7 +557,13 @@ export class ToolHandlers {
643
557
  models_cached: { type: 'number' },
644
558
  error: { type: 'string' },
645
559
  },
646
- 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
+ ],
647
567
  },
648
568
  },
649
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.0";
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.0';
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.0",
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
  }