@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 +55 -1
- package/dist/index.js +21 -5
- package/dist/model-cache.d.ts +13 -0
- package/dist/model-cache.js +21 -1
- package/dist/tool-handlers/health-check.js +4 -1
- package/dist/tool-handlers/openrouter-errors.d.ts +5 -1
- package/dist/tool-handlers/openrouter-errors.js +72 -11
- package/dist/tool-handlers.js +28 -10
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
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 #
|
|
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
|
-
|
|
27
|
+
logFatal('uncaughtException', err);
|
|
12
28
|
process.exit(1);
|
|
13
29
|
});
|
|
14
30
|
process.on('unhandledRejection', (err) => {
|
|
15
|
-
|
|
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:
|
|
25
|
-
server.onerror = (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();
|
package/dist/model-cache.d.ts
CHANGED
|
@@ -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
|
package/dist/model-cache.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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,
|
|
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,
|
|
54
|
-
const
|
|
80
|
+
export function classifyUpstreamError(err, contextMessage) {
|
|
81
|
+
const rawMsg = extractMessage(err);
|
|
55
82
|
const status = extractStatus(err);
|
|
56
|
-
const lower =
|
|
57
|
-
|
|
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
|
}
|
package/dist/tool-handlers.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
|
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
|
-
'
|
|
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.
|
|
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.
|
|
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.
|
|
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",
|