@stabgan/openrouter-mcp-multimodal 4.5.0 → 4.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -313,7 +313,7 @@ Use get_video_status with video_id "vid_abc123" and save_path "./river.mp4"
313
313
  ```
314
314
  src/
315
315
  ├── index.ts # Entry, env validation, graceful shutdown
316
- ├── tool-handlers.ts # 11 tools (annotated) + dispatch
316
+ ├── tool-handlers.ts # 14 tools (annotated) + dispatch
317
317
  ├── model-cache.ts # TTL + in-flight coalescing
318
318
  ├── openrouter-api.ts # REST client (chat + /videos)
319
319
  ├── errors.ts # Closed ErrorCode enum
@@ -338,6 +338,60 @@ src/
338
338
  └── validate-model.ts # Model existence check
339
339
  ```
340
340
 
341
+ ## Design Principles & Research
342
+
343
+ v4.5.0's design draws from three threads of research and industry guidance. Rather than building in isolation, every feature ties to a cited source so decisions can be re-examined later.
344
+
345
+ ### MCP-first design principles
346
+
347
+ We follow [Phil Schmid's production guide for MCP servers](https://www.philschmid.de/mcp-best-practices) (Jan 2026), which argues that an MCP server is "a user interface for AI agents, not a REST API wrapper":
348
+
349
+ - **Outcomes, not operations.** Our tools like `analyze_image` and `generate_video` encapsulate a whole workflow (fetch, validate, invoke, save) rather than exposing raw OpenRouter primitives.
350
+ - **Flattened arguments.** Top-level primitives with enums (`aspect_ratio`, `image_size`), no deeply nested configuration blobs. The one nested object (`provider`) is required by OpenRouter's routing schema.
351
+ - **Descriptions are context.** Every tool description includes "Fails when:" and "Works with:" sections (see next section for the research backing).
352
+ - **Curated surface.** 14 tools total. Each is a distinct outcome; no "helper" tools that exist only for internal composition.
353
+
354
+ Apigene's ["12 Rules for Production MCP Deployment"](https://apigene.ai/blog/mcp-best-practices) (March 2026) guided the error-handling posture: structured errors with `suggestions` and `retry_after_seconds` on `_meta` beat raw error strings the agent has to interpret.
355
+
356
+ ### MCP 2025-06-18 spec compliance
357
+
358
+ - **Structured outputs.** `validate_model`, `get_model_info`, `search_models`, `rerank_documents`, and `health_check` emit [`structuredContent` with `outputSchema`](https://modelcontextprotocol.io/specification/2025-06-18/server/tools#output-schema), per §5.2.6-7. Agents can validate responses typefully.
359
+ - **Progress notifications.** `generate_video` emits [`notifications/progress`](https://modelcontextprotocol.io/specification/2025-06-18/basic/utilities/progress) on every poll tick when the client passes a `progressToken` in `_meta`. Progress values are guaranteed strictly monotonic per spec.
360
+ - **Tool annotations.** Every tool carries `title` + `readOnlyHint` + `destructiveHint` + `idempotentHint` + `openWorldHint` so clients can render appropriate UI affordances.
361
+
362
+ ### Research-backed tool-design decisions
363
+
364
+ These papers shaped specific v4.5.0 choices:
365
+
366
+ | Finding | Source | How it shaped v4.5.0 |
367
+ | :--- | :--- | :--- |
368
+ | Failure-mode docs and inter-tool relationships measurably improve tool-selection accuracy | [Schlapbach, *Convergence of SGD & MCP*](https://arxiv.org/abs/2602.18764) (Feb 2026) | Every tool description has explicit "Fails when:" (ErrorCode triggers) and "Works with:" (related tools). |
369
+ | Tool-call success drops with parameter count and schema complexity | [Fu et al., *ROSBag MCP Server*](https://arxiv.org/abs/2511.03497) (Nov 2025) | `generate_video_from_image` is a narrower image-to-video wrapper around `generate_video` — fewer params, higher hit rate. |
370
+ | Indirect prompt injection via tool-returned content is a real attack vector | [Zhao et al., *ClawGuard*](https://arxiv.org/abs/2604.11790) (Apr 2026) · [Yu et al., *Defense via Tool Result Parsing*](https://arxiv.org/abs/2601.04795) (Jan 2026) | `analyze_image` / `analyze_audio` / `analyze_video` tag their output `_meta.content_is_untrusted: true`. Downstream agents know to treat that text as data, not instructions. |
371
+ | Provider-level tool-calling variance is large and persists across providers for the same model | [OpenRouter Auto Exacto announcement](https://openrouter.ai/announcements/auto-exacto) (Mar 2026) | `chat_completion` documents the `:exacto` model suffix alongside `:nitro` / `:floor`. 80-88% error reduction on top tool-calling models. |
372
+ | LLM JSON defects compound at scale | [OpenRouter Response Healing](https://openrouter.ai/announcements/response-healing-reduce-json-defects-by-80percent) (Dec 2025) | Structured outputs + outputSchema declarations give clients a parseable contract. (Response-healing plugin itself is opt-in on OpenRouter's side.) |
373
+ | MCP servers are vulnerable to preference-manipulation and tool-poisoning attacks | [Wang et al., *MPMA*](https://arxiv.org/abs/2505.11154) (May 2025) · [Turgut & Gümüş, *CASCADE*](https://arxiv.org/abs/2604.17125) (Apr 2026) | Tool descriptions audited for injection surface; audit logging (`logger.audit()`) captures every paid-op invocation with a prompt preview for forensics. |
374
+
375
+ ### OpenRouter platform parity
376
+
377
+ v4.5.0 surfaces platform features shipped between Q4 2025 and Q2 2026:
378
+
379
+ - [Response caching via `X-OpenRouter-Cache`](https://openrouter.ai/announcements/response-caching) (Apr 2026): zero tokens billed on identical request cache hits, 80-300ms latency.
380
+ - [Web search plugin](https://openrouter.ai/announcements/introducing-web-search-via-the-api) (Jan 2025): Exa-backed, enabled via `online: true`.
381
+ - [Reasoning tokens](https://openrouter.ai/announcements/reasoning-tokens-for-thinking-models) (Jan 2025): DeepSeek R1 / Gemini Thinking / Opus 4.7 chain-of-thought via `include_reasoning: true`.
382
+ - [Auto Exacto](https://openrouter.ai/announcements/auto-exacto) (Mar 2026): on-by-default for tool-calling; `:exacto` suffix for all other requests.
383
+ - [Rerank endpoint](https://openrouter.ai/announcements/april-release-spotlight) (Apr 2026): Cohere + Fireworks via the new `rerank_documents` tool.
384
+ - [Prompt caching with `cache_control`](https://openrouter.ai/docs/guides/best-practices/prompt-caching): Anthropic Claude 10x / Gemini 2.5+ 4x savings on repeated input media via `cache_input: true` on analyze_* tools.
385
+ - [Zero completion token insurance](https://openrouter.ai/announcements/never-pay-for-empty-ai-responses-again) (Mar 2025): automatic, no opt-in needed.
386
+
387
+ ### Security posture
388
+
389
+ - **Path sandbox.** All file writes (`save_path`) and reads (`input_images`, frame images) go through `resolveSafeOutputPath` / `resolveSafeInputPath`, which reject traversal escapes. Legacy bypass: `OPENROUTER_ALLOW_UNSAFE_PATHS=1`.
390
+ - **SSRF blocklist.** Loopback, private, link-local, multicast, 6to4, Teredo, ORCHID, and IPv4-mapped IPv6 all rejected at the fetch layer.
391
+ - **Audit logging.** `logger.audit()` emits a JSON line at level=audit for every `generate_video`, `generate_audio`, and `generate_image` call. Bypasses `OPENROUTER_LOG_LEVEL` so unintended spend is always traceable. 80-char prompt preview is the hard PII boundary.
392
+ - **Structured errors.** Closed `_meta.code` taxonomy means agents switch on failure modes without regex-parsing free text. Rate-limit errors include `retry_after_seconds` derived from `Retry-After` headers.
393
+ - **No credential leakage.** `OPENROUTER_API_KEY` is read once at startup, passed to the SDK, and never echoed in logs, tool responses, or error messages. Fatal-error logging whitelists fields explicitly (name / message / trimmed stack) — no raw error objects. Verified by an independent bug-hunter audit (Apr 2026).
394
+
341
395
  ## Development
342
396
 
343
397
  ```bash
package/dist/index.js CHANGED
@@ -5,14 +5,30 @@ config(); // Load .env file if present
5
5
  import { Server } from '@modelcontextprotocol/sdk/server/index.js';
6
6
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
7
7
  import { ToolHandlers } from './tool-handlers.js';
8
+ import { logger } from './logger.js';
9
+ import { SERVER_VERSION } from './version.js';
8
10
  const DEFAULT_MODEL = 'nvidia/nemotron-nano-12b-v2-vl:free';
9
- // Exit on fatal errors to prevent silent zombie processes (issue #5)
11
+ // Exit on fatal errors to prevent silent zombie processes (issue #5).
12
+ // We log an explicit whitelist of fields rather than the raw error object
13
+ // to avoid ever echoing sensitive SDK internals (request bodies, auth
14
+ // headers) in a future version. Defense-in-depth against a changed
15
+ // APIError.toString() in openai-node.
16
+ function logFatal(kind, err) {
17
+ const e = err;
18
+ logger.error('fatal', {
19
+ kind,
20
+ name: e?.name ?? 'unknown',
21
+ msg: e?.message ?? String(err),
22
+ // Stack traces are developer-only — trim to avoid unbounded log lines.
23
+ stack: e?.stack?.split('\n').slice(0, 10).join('\n'),
24
+ });
25
+ }
10
26
  process.on('uncaughtException', (err) => {
11
- console.error('[Fatal]', err);
27
+ logFatal('uncaughtException', err);
12
28
  process.exit(1);
13
29
  });
14
30
  process.on('unhandledRejection', (err) => {
15
- console.error('[Fatal]', err);
31
+ logFatal('unhandledRejection', err);
16
32
  process.exit(1);
17
33
  });
18
34
  const apiKey = process.env.OPENROUTER_API_KEY;
@@ -21,8 +37,8 @@ if (!apiKey) {
21
37
  process.exit(1);
22
38
  }
23
39
  const defaultModel = process.env.OPENROUTER_DEFAULT_MODEL || process.env.DEFAULT_MODEL || DEFAULT_MODEL;
24
- const server = new Server({ name: 'openrouter-multimodal-server', version: '4.0.1' }, { capabilities: { tools: {} } });
25
- server.onerror = (error) => console.error('[MCP Error]', error);
40
+ const server = new Server({ name: 'openrouter-multimodal-server', version: SERVER_VERSION }, { capabilities: { tools: {} } });
41
+ server.onerror = (error) => logFatal('mcpError', error);
26
42
  new ToolHandlers(server, apiKey, defaultModel);
27
43
  process.on('SIGINT', async () => {
28
44
  await server.close();
@@ -12,10 +12,23 @@ export declare class ModelCache {
12
12
  private static instance;
13
13
  private models;
14
14
  private fetchedAt;
15
+ /**
16
+ * Separate from `fetchedAt`: set whenever we successfully CALL the
17
+ * fetcher (even if the response happens to be empty). Used by
18
+ * `isValid()` so a successful-but-empty fetch still counts as "fresh"
19
+ * and we don't hot-loop re-fetching the upstream.
20
+ */
21
+ private populatedAt;
15
22
  private inflight;
16
23
  static getInstance(): ModelCache;
17
24
  isValid(): boolean;
18
25
  setModels(models: OpenRouterModelRecord[]): void;
26
+ /**
27
+ * Force the cache back into an uninitialized state. Used by tests that
28
+ * need to assert `ensureFresh()` actually calls the fetcher. Also useful
29
+ * for ops (`health_check --reset`) if we ever expose such a knob.
30
+ */
31
+ reset(): void;
19
32
  /**
20
33
  * Populate the cache using `fetcher` if stale, coalescing concurrent callers
21
34
  * so only one request hits the upstream API per stale window. Callers that
@@ -10,16 +10,36 @@ export class ModelCache {
10
10
  static instance;
11
11
  models = {};
12
12
  fetchedAt = 0;
13
+ /**
14
+ * Separate from `fetchedAt`: set whenever we successfully CALL the
15
+ * fetcher (even if the response happens to be empty). Used by
16
+ * `isValid()` so a successful-but-empty fetch still counts as "fresh"
17
+ * and we don't hot-loop re-fetching the upstream.
18
+ */
19
+ populatedAt = 0;
13
20
  inflight = null;
14
21
  static getInstance() {
15
22
  return (ModelCache.instance ??= new ModelCache());
16
23
  }
17
24
  isValid() {
18
- return Object.keys(this.models).length > 0 && Date.now() - this.fetchedAt < getCacheTtlMs();
25
+ const fresh = Date.now() - this.populatedAt < getCacheTtlMs();
26
+ return this.populatedAt > 0 && fresh;
19
27
  }
20
28
  setModels(models) {
21
29
  this.models = Object.fromEntries(models.map((m) => [m.id, m]));
22
30
  this.fetchedAt = Date.now();
31
+ this.populatedAt = this.fetchedAt;
32
+ }
33
+ /**
34
+ * Force the cache back into an uninitialized state. Used by tests that
35
+ * need to assert `ensureFresh()` actually calls the fetcher. Also useful
36
+ * for ops (`health_check --reset`) if we ever expose such a knob.
37
+ */
38
+ reset() {
39
+ this.models = {};
40
+ this.fetchedAt = 0;
41
+ this.populatedAt = 0;
42
+ this.inflight = null;
23
43
  }
24
44
  /**
25
45
  * Populate the cache using `fetcher` if stale, coalescing concurrent callers
@@ -20,7 +20,10 @@ export async function handleHealthCheck(_request, apiClient, modelCache) {
20
20
  errorMessage = err instanceof Error ? err.message : String(err);
21
21
  }
22
22
  const modelsCached = modelCache.isValid() ? modelCache.size() : 0;
23
- const ok = apiKeyValid && modelsCached > 0;
23
+ // `ok` means the API was reachable and the key was accepted. An empty
24
+ // catalog counts as success (the API just returned no models) — callers
25
+ // branch on `models_cached` if they care about the count.
26
+ const ok = apiKeyValid;
24
27
  return buildStructuredResult({
25
28
  ok,
26
29
  server_version: SERVER_VERSION,
@@ -14,5 +14,9 @@ import { type ToolErrorResult } from '../errors.js';
14
14
  * 2. Message heuristics for common OpenRouter strings (credits, ZDR,
15
15
  * "model does not exist", content policy, etc.).
16
16
  * 3. Default to INTERNAL to avoid leaking raw shapes.
17
+ *
18
+ * When the error carries a `Retry-After` header (on 429 / 503) we populate
19
+ * `_meta.retry_after_seconds` so agents can back off intelligently. We
20
+ * also attach canonical `suggestions[]` for common cases.
17
21
  */
18
- export declare function classifyUpstreamError(err: unknown, _contextMessage?: string): ToolErrorResult;
22
+ export declare function classifyUpstreamError(err: unknown, contextMessage?: string): ToolErrorResult;
@@ -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,78 @@ 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
113
  (lower.includes('does not exist') || lower.includes('not found') || lower.includes('invalid model'))) {
73
- return toolError(ErrorCode.MODEL_NOT_FOUND, fullMsg, { status });
114
+ return toolError(ErrorCode.MODEL_NOT_FOUND, fullMsg, { status }, {
115
+ suggestions: [
116
+ 'Use search_models to discover valid model ids',
117
+ 'Use validate_model to pre-flight a model id',
118
+ ],
119
+ });
74
120
  }
75
121
  // Content policy / moderation — surface as UPSTREAM_REFUSED so callers can distinguish from 5xx.
76
122
  if (lower.includes('content policy') || lower.includes('moderation') || lower.includes('refused')) {
77
- return toolError(ErrorCode.UPSTREAM_REFUSED, fullMsg, { status, reason: 'policy' });
123
+ return toolError(ErrorCode.UPSTREAM_REFUSED, fullMsg, { status, reason: 'policy' }, {
124
+ suggestions: ['Rephrase the prompt', 'Try a different provider via provider.order'],
125
+ });
78
126
  }
79
127
  // Rate-limit specific.
80
128
  if (status === 429 || lower.includes('rate limit')) {
81
- return toolError(ErrorCode.UPSTREAM_REFUSED, fullMsg, { status, reason: 'rate_limit' });
129
+ return toolError(ErrorCode.UPSTREAM_REFUSED, fullMsg, { status, reason: 'rate_limit' }, {
130
+ suggestions: [
131
+ retryAfterSeconds !== undefined
132
+ ? `Wait ${retryAfterSeconds}s and retry`
133
+ : 'Wait and retry with exponential backoff',
134
+ 'Append :nitro to the model slug to route to a faster provider',
135
+ ],
136
+ retry_after_seconds: retryAfterSeconds,
137
+ });
82
138
  }
83
139
  // Timeouts (AbortError from `AbortSignal.timeout`).
84
140
  if (lower.includes('timed out') ||
85
141
  lower.includes('timeout') ||
86
142
  lower.includes('aborted') ||
87
143
  (err instanceof Error && err.name === 'AbortError')) {
88
- return toolError(ErrorCode.UPSTREAM_TIMEOUT, fullMsg, { status });
144
+ return toolError(ErrorCode.UPSTREAM_TIMEOUT, fullMsg, { status }, {
145
+ suggestions: ['Retry', 'Raise max_wait_ms or max_tokens'],
146
+ });
89
147
  }
90
148
  // Anything in the 4xx band that isn't covered above — user supplied a bad request.
91
149
  if (typeof status === 'number' && status >= 400 && status < 500) {
@@ -93,7 +151,10 @@ export function classifyUpstreamError(err, _contextMessage) {
93
151
  }
94
152
  // 5xx / network errors.
95
153
  if (typeof status === 'number' && status >= 500) {
96
- return toolError(ErrorCode.UPSTREAM_HTTP, fullMsg, { status });
154
+ return toolError(ErrorCode.UPSTREAM_HTTP, fullMsg, { status }, {
155
+ suggestions: ['Retry after a brief delay', 'Check https://status.openrouter.ai'],
156
+ retry_after_seconds: retryAfterSeconds,
157
+ });
97
158
  }
98
159
  return toolError(ErrorCode.UPSTREAM_HTTP, fullMsg);
99
160
  }
@@ -20,16 +20,29 @@ function wrapToolArgs(a) {
20
20
  function buildProgressHook(server, progressToken) {
21
21
  if (progressToken === undefined)
22
22
  return undefined;
23
+ // MCP `notifications/progress` REQUIRES `progress` to be strictly
24
+ // monotonically increasing within a single progressToken. OpenRouter
25
+ // returns `progress: 0..100` on some ticks and omits it on others, so
26
+ // we anchor on a per-hook attempt counter and use the upstream number
27
+ // only as an informational `message`. This guarantees monotonicity
28
+ // regardless of what the upstream does (drops, duplicates, decreases).
29
+ //
30
+ // See MCP spec 2025-06-18 utilities/progress §Behavior Requirements:
31
+ // "The progress value MUST increase with each notification, even if
32
+ // the total is unknown."
33
+ let lastSent = -1;
23
34
  return ({ status, progress, attempt, video_id }) => {
35
+ // Always monotonic: at least attempt+1 (so initial attempt=0 → 0 stays
36
+ // reserved for the 'submitted' ping). If upstream has a real numeric
37
+ // progress that's higher than our counter, adopt that.
38
+ const candidate = typeof progress === 'number' ? Math.max(attempt, progress) : attempt;
39
+ const next = Math.max(lastSent + 1, candidate);
40
+ lastSent = next;
24
41
  void server.notification({
25
42
  method: 'notifications/progress',
26
43
  params: {
27
44
  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 } : {}),
45
+ progress: next,
33
46
  message: `video ${video_id} — ${status}${typeof progress === 'number' ? ` (${progress}%)` : ''}`,
34
47
  },
35
48
  });
@@ -119,23 +132,28 @@ const TOOL_DESCRIPTIONS = {
119
132
  '- UNSAFE_PATH: save_path or reference image paths escaped the sandbox\n' +
120
133
  '- UPSTREAM_REFUSED: content policy, credits, or bad request\n' +
121
134
  '- 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
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' +
124
138
  'Works with: get_video_status (resume timed-out jobs), generate_video_from_image (narrower image-to-video variant).',
125
139
  generate_video_from_image: 'Narrower convenience wrapper around generate_video for image-to-video workflows. Takes a single ' +
126
140
  '`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' +
141
+ 'fewer parameters improve tool-call hit rate. For last-frame conditioning or reference images, use ' +
142
+ 'generate_video directly.\n\n' +
128
143
  'Fails when:\n' +
129
144
  '- INVALID_INPUT: image or prompt missing\n' +
130
145
  '- UNSAFE_PATH: image path escaped the sandbox\n' +
131
- '- UPSTREAM_REFUSED / JOB_FAILED / JOB_STILL_RUNNING: same as generate_video\n\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' +
132
149
  'Works with: generate_video (full parameter surface), get_video_status.',
133
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' +
134
151
  'Fails when:\n' +
135
152
  '- INVALID_INPUT: video_id missing\n' +
136
153
  '- 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' +
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' +
139
157
  'Works with: generate_video, generate_video_from_image.',
140
158
  rerank_documents: 'Re-order a list of documents by relevance to a query using an OpenRouter reranker. Default model: ' +
141
159
  'cohere/rerank-english-v3.0.\n\n' +
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.1";
10
10
  /**
11
11
  * MCP protocol version our SDK speaks. Hardcoded to match the version
12
12
  * bundled with `@modelcontextprotocol/sdk`; update when upgrading the
package/dist/version.js CHANGED
@@ -6,7 +6,7 @@
6
6
  * Bumped in lockstep with package.json / server.json / smithery.yaml /
7
7
  * scripts/build-manifest.mjs during release prep.
8
8
  */
9
- export const SERVER_VERSION = '4.5.0';
9
+ export const SERVER_VERSION = '4.5.1';
10
10
  /**
11
11
  * MCP protocol version our SDK speaks. Hardcoded to match the version
12
12
  * bundled with `@modelcontextprotocol/sdk`; update when upgrading the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stabgan/openrouter-mcp-multimodal",
3
- "version": "4.5.0",
3
+ "version": "4.5.1",
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",