@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.
- package/README.md +367 -283
- package/dist/index.js +1 -1
- package/dist/model-cache.d.ts +22 -12
- package/dist/model-cache.js +58 -21
- package/dist/tool-descriptions.d.ts +19 -0
- package/dist/tool-descriptions.js +423 -0
- package/dist/tool-handlers/analyze-audio.js +5 -1
- package/dist/tool-handlers/analyze-image.js +6 -5
- package/dist/tool-handlers/analyze-video.js +6 -5
- package/dist/tool-handlers/audio-utils.js +4 -2
- package/dist/tool-handlers/chat-completion.js +1 -1
- package/dist/tool-handlers/fetch-utils.js +16 -2
- package/dist/tool-handlers/generate-audio.js +2 -4
- package/dist/tool-handlers/generate-image-input.d.ts +3 -0
- package/dist/tool-handlers/generate-image-input.js +38 -0
- package/dist/tool-handlers/generate-image.d.ts +13 -51
- package/dist/tool-handlers/generate-image.js +32 -119
- package/dist/tool-handlers/generate-video.js +28 -24
- package/dist/tool-handlers/image-utils.d.ts +1 -0
- package/dist/tool-handlers/image-utils.js +26 -16
- package/dist/tool-handlers/openrouter-errors.js +6 -2
- package/dist/tool-handlers/provider-routing.js +7 -2
- package/dist/tool-handlers/rerank.js +2 -5
- package/dist/tool-handlers/search-models.d.ts +2 -2
- package/dist/tool-handlers/search-models.js +2 -6
- package/dist/tool-handlers/structured-output.d.ts +8 -0
- package/dist/tool-handlers/structured-output.js +11 -0
- package/dist/tool-handlers/video-utils.js +6 -9
- package/dist/tool-handlers.js +25 -123
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
138
|
-
|
|
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
|
-
|
|
151
|
+
resized = pipeline.resize(opts);
|
|
142
152
|
}
|
|
143
|
-
const out = await
|
|
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') ||
|
|
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') ||
|
|
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'
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
164
|
-
const
|
|
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
|
}
|
package/dist/tool-handlers.js
CHANGED
|
@@ -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:
|
|
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:
|
|
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: {
|
|
297
|
-
|
|
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: '
|
|
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: '
|
|
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: [
|
|
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.
|
|
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.
|
|
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.
|
|
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": "
|
|
23
|
-
"test:
|
|
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": ">=
|
|
57
|
+
"node": ">=20.0.0"
|
|
48
58
|
},
|
|
49
59
|
"dependencies": {
|
|
50
|
-
"@modelcontextprotocol/sdk": "^1.
|
|
51
|
-
"dotenv": "^
|
|
52
|
-
"openai": "^4.
|
|
53
|
-
"sharp": "^0.
|
|
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.
|
|
67
|
+
"@types/node": "^22.20.0",
|
|
58
68
|
"eslint": "^9.39.2",
|
|
59
69
|
"eslint-config-prettier": "^10.1.8",
|
|
60
|
-
"
|
|
61
|
-
"
|
|
62
|
-
"
|
|
63
|
-
"
|
|
64
|
-
"
|
|
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
|
}
|