@stabgan/openrouter-mcp-multimodal 1.8.2 → 2.0.0
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 +69 -27
- package/dist/__tests__/audio-utils.test.d.ts +1 -0
- package/dist/__tests__/audio-utils.test.js +120 -0
- package/dist/__tests__/fetch-utils.test.d.ts +1 -0
- package/dist/__tests__/fetch-utils.test.js +76 -0
- package/dist/__tests__/generate-audio.test.d.ts +1 -0
- package/dist/__tests__/generate-audio.test.js +90 -0
- package/dist/__tests__/integration.test.js +108 -0
- package/dist/index.js +2 -0
- package/dist/tool-handlers/analyze-audio.d.ts +23 -0
- package/dist/tool-handlers/analyze-audio.js +34 -0
- package/dist/tool-handlers/audio-utils.d.ts +19 -0
- package/dist/tool-handlers/audio-utils.js +128 -0
- package/dist/tool-handlers/fetch-utils.d.ts +18 -0
- package/dist/tool-handlers/fetch-utils.js +161 -0
- package/dist/tool-handlers/generate-audio.d.ts +45 -0
- package/dist/tool-handlers/generate-audio.js +154 -0
- package/dist/tool-handlers/image-utils.d.ts +3 -4
- package/dist/tool-handlers/image-utils.js +10 -153
- package/dist/tool-handlers.js +36 -0
- package/package.json +7 -4
|
@@ -1,19 +1,15 @@
|
|
|
1
1
|
import path from 'path';
|
|
2
2
|
import { promises as fs } from 'fs';
|
|
3
|
-
import
|
|
3
|
+
import { readEnvInt, isBlockedIPv4 as _isBlockedIPv4, assertUrlSafeForFetch as _assertUrlSafeForFetch, fetchHttpResource, } from './fetch-utils.js';
|
|
4
|
+
// Re-export for backward compatibility (tests import from image-utils)
|
|
5
|
+
export const isBlockedIPv4 = _isBlockedIPv4;
|
|
6
|
+
export const assertUrlSafeForFetch = _assertUrlSafeForFetch;
|
|
4
7
|
const DEFAULT_MAX_DIMENSION = 800;
|
|
5
8
|
const DEFAULT_JPEG_QUALITY = 80;
|
|
6
9
|
const DEFAULT_FETCH_TIMEOUT_MS = 30_000;
|
|
7
10
|
const DEFAULT_MAX_DOWNLOAD_BYTES = 25 * 1024 * 1024;
|
|
8
11
|
const DEFAULT_MAX_REDIRECTS = 8;
|
|
9
12
|
const DEFAULT_MAX_DATA_URL_BYTES = 20 * 1024 * 1024;
|
|
10
|
-
function readEnvInt(name, fallback, min = 1) {
|
|
11
|
-
const raw = process.env[name];
|
|
12
|
-
if (raw === undefined || raw === '')
|
|
13
|
-
return fallback;
|
|
14
|
-
const n = parseInt(raw, 10);
|
|
15
|
-
return Number.isFinite(n) && n >= min ? n : fallback;
|
|
16
|
-
}
|
|
17
13
|
export function getMaxImageDimension() {
|
|
18
14
|
return readEnvInt('OPENROUTER_IMAGE_MAX_DIMENSION', DEFAULT_MAX_DIMENSION, 64);
|
|
19
15
|
}
|
|
@@ -60,152 +56,13 @@ export function getMimeType(filePath) {
|
|
|
60
56
|
};
|
|
61
57
|
return map[ext] || 'image/jpeg';
|
|
62
58
|
}
|
|
63
|
-
function ipv4ToUint(ip) {
|
|
64
|
-
const parts = ip.split('.').map((p) => parseInt(p, 10));
|
|
65
|
-
if (parts.length !== 4 || parts.some((p) => !Number.isInteger(p) || p < 0 || p > 255)) {
|
|
66
|
-
throw new Error('Invalid IPv4');
|
|
67
|
-
}
|
|
68
|
-
return ((parts[0] << 24) | (parts[1] << 16) | (parts[2] << 8) | parts[3]) >>> 0;
|
|
69
|
-
}
|
|
70
|
-
/** Blocks RFC1918, loopback, link-local, CGNAT, metadata (e.g. 169.254.169.254). */
|
|
71
|
-
export function isBlockedIPv4(ip) {
|
|
72
|
-
const n = ipv4ToUint(ip);
|
|
73
|
-
if (n >>> 24 === 127)
|
|
74
|
-
return true;
|
|
75
|
-
if (n >>> 24 === 10)
|
|
76
|
-
return true;
|
|
77
|
-
if (n >>> 20 === 0xac1)
|
|
78
|
-
return true;
|
|
79
|
-
if (n >>> 16 === 0xc0a8)
|
|
80
|
-
return true;
|
|
81
|
-
if (n >>> 16 === 0xa9fe)
|
|
82
|
-
return true;
|
|
83
|
-
if (n >>> 24 === 0)
|
|
84
|
-
return true;
|
|
85
|
-
if (n >= 0x64400000 && n <= 0x647fffff)
|
|
86
|
-
return true;
|
|
87
|
-
return false;
|
|
88
|
-
}
|
|
89
|
-
function isBlockedIPv6(ip) {
|
|
90
|
-
const raw = ip.includes('%') ? ip.split('%')[0] : ip;
|
|
91
|
-
const x = raw.toLowerCase();
|
|
92
|
-
if (x === '::1')
|
|
93
|
-
return true;
|
|
94
|
-
if (x.startsWith('fe80:') || x.startsWith('fec0:'))
|
|
95
|
-
return true;
|
|
96
|
-
const first = x.split(':').find((p) => p.length > 0);
|
|
97
|
-
if (first) {
|
|
98
|
-
const v = parseInt(first, 16);
|
|
99
|
-
if (!Number.isNaN(v) && v >= 0xfc00 && v <= 0xfdff)
|
|
100
|
-
return true;
|
|
101
|
-
}
|
|
102
|
-
return false;
|
|
103
|
-
}
|
|
104
|
-
function isIPv4Literal(host) {
|
|
105
|
-
return /^\d{1,3}(\.\d{1,3}){3}$/.test(host);
|
|
106
|
-
}
|
|
107
|
-
/** Resolve hostname and ensure the resolved address is not private/link-local. */
|
|
108
|
-
export async function assertUrlSafeForFetch(urlString) {
|
|
109
|
-
let url;
|
|
110
|
-
try {
|
|
111
|
-
url = new URL(urlString);
|
|
112
|
-
}
|
|
113
|
-
catch {
|
|
114
|
-
throw new Error('Invalid URL');
|
|
115
|
-
}
|
|
116
|
-
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
|
|
117
|
-
throw new Error('Only HTTP(S) image URLs are allowed');
|
|
118
|
-
}
|
|
119
|
-
if (url.username || url.password) {
|
|
120
|
-
throw new Error('URL with credentials is not allowed');
|
|
121
|
-
}
|
|
122
|
-
const host = url.hostname.toLowerCase();
|
|
123
|
-
if (host === 'localhost' || host.endsWith('.localhost')) {
|
|
124
|
-
throw new Error('Blocked host');
|
|
125
|
-
}
|
|
126
|
-
if (isIPv4Literal(host)) {
|
|
127
|
-
if (isBlockedIPv4(host))
|
|
128
|
-
throw new Error('Blocked host');
|
|
129
|
-
return url;
|
|
130
|
-
}
|
|
131
|
-
if (host.includes(':') && !host.startsWith('[')) {
|
|
132
|
-
if (isBlockedIPv6(host))
|
|
133
|
-
throw new Error('Blocked host');
|
|
134
|
-
return url;
|
|
135
|
-
}
|
|
136
|
-
let lookupHost = host;
|
|
137
|
-
if (host.startsWith('[') && host.endsWith(']')) {
|
|
138
|
-
lookupHost = host.slice(1, -1);
|
|
139
|
-
if (isBlockedIPv6(lookupHost))
|
|
140
|
-
throw new Error('Blocked host');
|
|
141
|
-
return url;
|
|
142
|
-
}
|
|
143
|
-
const records = await dns.lookup(lookupHost, { all: true, verbatim: true });
|
|
144
|
-
if (!records.length)
|
|
145
|
-
throw new Error('Could not resolve host');
|
|
146
|
-
for (const r of records) {
|
|
147
|
-
const { address, family } = r;
|
|
148
|
-
if (family === 4) {
|
|
149
|
-
if (isBlockedIPv4(address))
|
|
150
|
-
throw new Error('Blocked host');
|
|
151
|
-
}
|
|
152
|
-
else if (family === 6) {
|
|
153
|
-
if (isBlockedIPv6(address))
|
|
154
|
-
throw new Error('Blocked host');
|
|
155
|
-
}
|
|
156
|
-
}
|
|
157
|
-
return url;
|
|
158
|
-
}
|
|
159
|
-
async function readResponseBodyWithLimit(res, maxBytes) {
|
|
160
|
-
const reader = res.body?.getReader();
|
|
161
|
-
if (!reader) {
|
|
162
|
-
const buf = Buffer.from(await res.arrayBuffer());
|
|
163
|
-
if (buf.length > maxBytes)
|
|
164
|
-
throw new Error('Response too large');
|
|
165
|
-
return buf;
|
|
166
|
-
}
|
|
167
|
-
const chunks = [];
|
|
168
|
-
let total = 0;
|
|
169
|
-
for (;;) {
|
|
170
|
-
const { done, value } = await reader.read();
|
|
171
|
-
if (done)
|
|
172
|
-
break;
|
|
173
|
-
total += value.byteLength;
|
|
174
|
-
if (total > maxBytes)
|
|
175
|
-
throw new Error('Response too large');
|
|
176
|
-
chunks.push(Buffer.from(value));
|
|
177
|
-
}
|
|
178
|
-
return Buffer.concat(chunks);
|
|
179
|
-
}
|
|
180
59
|
export async function fetchHttpImage(urlString) {
|
|
181
|
-
const
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
const target = validated.href;
|
|
188
|
-
const controller = new AbortController();
|
|
189
|
-
const t = setTimeout(() => controller.abort(), timeoutMs);
|
|
190
|
-
let res;
|
|
191
|
-
try {
|
|
192
|
-
res = await fetch(target, { redirect: 'manual', signal: controller.signal });
|
|
193
|
-
}
|
|
194
|
-
finally {
|
|
195
|
-
clearTimeout(t);
|
|
196
|
-
}
|
|
197
|
-
if (res.status >= 300 && res.status < 400) {
|
|
198
|
-
const loc = res.headers.get('location');
|
|
199
|
-
if (!loc)
|
|
200
|
-
throw new Error('Redirect without Location header');
|
|
201
|
-
current = new URL(loc, target).href;
|
|
202
|
-
continue;
|
|
203
|
-
}
|
|
204
|
-
if (!res.ok)
|
|
205
|
-
throw new Error(`HTTP ${res.status}`);
|
|
206
|
-
return readResponseBodyWithLimit(res, maxBytes);
|
|
207
|
-
}
|
|
208
|
-
throw new Error('Too many redirects');
|
|
60
|
+
const { buffer } = await fetchHttpResource(urlString, {
|
|
61
|
+
timeoutMs: getFetchTimeoutMs(),
|
|
62
|
+
maxBytes: getMaxDownloadBytes(),
|
|
63
|
+
maxRedirects: getMaxRedirects(),
|
|
64
|
+
});
|
|
65
|
+
return buffer;
|
|
209
66
|
}
|
|
210
67
|
export async function fetchImage(source) {
|
|
211
68
|
if (source.startsWith('data:')) {
|
package/dist/tool-handlers.js
CHANGED
|
@@ -8,6 +8,8 @@ import { handleSearchModels } from './tool-handlers/search-models.js';
|
|
|
8
8
|
import { handleGetModelInfo } from './tool-handlers/get-model-info.js';
|
|
9
9
|
import { handleValidateModel } from './tool-handlers/validate-model.js';
|
|
10
10
|
import { handleGenerateImage } from './tool-handlers/generate-image.js';
|
|
11
|
+
import { handleAnalyzeAudio } from './tool-handlers/analyze-audio.js';
|
|
12
|
+
import { handleGenerateAudio } from './tool-handlers/generate-audio.js';
|
|
11
13
|
function wrapToolArgs(a) {
|
|
12
14
|
return { params: { arguments: a ?? {} } };
|
|
13
15
|
}
|
|
@@ -112,6 +114,36 @@ export class ToolHandlers {
|
|
|
112
114
|
required: ['prompt'],
|
|
113
115
|
},
|
|
114
116
|
},
|
|
117
|
+
{
|
|
118
|
+
name: 'analyze_audio',
|
|
119
|
+
description: 'Analyze or transcribe an audio file using a multimodal model',
|
|
120
|
+
inputSchema: {
|
|
121
|
+
type: 'object',
|
|
122
|
+
properties: {
|
|
123
|
+
audio_path: { type: 'string', description: 'File path, URL, or data URL (base64-encoded audio)' },
|
|
124
|
+
question: { type: 'string', description: 'Question or instruction about the audio (default: transcribe)' },
|
|
125
|
+
model: { type: 'string' },
|
|
126
|
+
},
|
|
127
|
+
required: ['audio_path'],
|
|
128
|
+
},
|
|
129
|
+
},
|
|
130
|
+
{
|
|
131
|
+
name: 'generate_audio',
|
|
132
|
+
description: 'Generate audio from a text prompt. Conversational models (e.g. openai/gpt-audio) respond in spoken audio. ' +
|
|
133
|
+
'Music models (e.g. google/lyria-3-clip-preview) need a structured prompt. ' +
|
|
134
|
+
'Output format is auto-detected and file extension is corrected automatically.',
|
|
135
|
+
inputSchema: {
|
|
136
|
+
type: 'object',
|
|
137
|
+
properties: {
|
|
138
|
+
prompt: { type: 'string', description: 'Text input' },
|
|
139
|
+
model: { type: 'string', description: 'Model ID (default: openai/gpt-audio)' },
|
|
140
|
+
voice: { type: 'string', description: 'Voice name (default: alloy)' },
|
|
141
|
+
format: { type: 'string', description: 'Requested format: pcm16 (default), mp3, flac, opus' },
|
|
142
|
+
save_path: { type: 'string', description: 'Path to save audio file. Extension auto-corrected.' },
|
|
143
|
+
},
|
|
144
|
+
required: ['prompt'],
|
|
145
|
+
},
|
|
146
|
+
},
|
|
115
147
|
],
|
|
116
148
|
}));
|
|
117
149
|
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
@@ -129,6 +161,10 @@ export class ToolHandlers {
|
|
|
129
161
|
return handleValidateModel(wrapToolArgs(args), this.modelCache, this.apiClient);
|
|
130
162
|
case 'generate_image':
|
|
131
163
|
return handleGenerateImage(wrapToolArgs(args), this.openai);
|
|
164
|
+
case 'analyze_audio':
|
|
165
|
+
return handleAnalyzeAudio(wrapToolArgs(args), this.openai, this.defaultModel);
|
|
166
|
+
case 'generate_audio':
|
|
167
|
+
return handleGenerateAudio(wrapToolArgs(args), this.openai);
|
|
132
168
|
default:
|
|
133
169
|
throw new McpError(ErrorCode.MethodNotFound, `Unknown tool: ${name}`);
|
|
134
170
|
}
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stabgan/openrouter-mcp-multimodal",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "2.0.0",
|
|
4
4
|
"mcpName": "io.github.stabgan/openrouter-multimodal",
|
|
5
|
-
"description": "MCP server for OpenRouter with text chat, image analysis, and
|
|
5
|
+
"description": "MCP server for OpenRouter with text chat, image analysis, image generation, audio analysis, and audio generation",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"main": "dist/index.js",
|
|
8
8
|
"bin": {
|
|
@@ -28,7 +28,10 @@
|
|
|
28
28
|
"ai",
|
|
29
29
|
"llm",
|
|
30
30
|
"vision",
|
|
31
|
-
"image-analysis"
|
|
31
|
+
"image-analysis",
|
|
32
|
+
"audio",
|
|
33
|
+
"transcription",
|
|
34
|
+
"text-to-speech"
|
|
32
35
|
],
|
|
33
36
|
"author": "stabgan",
|
|
34
37
|
"repository": {
|
|
@@ -45,13 +48,13 @@
|
|
|
45
48
|
},
|
|
46
49
|
"dependencies": {
|
|
47
50
|
"@modelcontextprotocol/sdk": "^1.27.1",
|
|
51
|
+
"dotenv": "^16.4.7",
|
|
48
52
|
"openai": "^4.89.1",
|
|
49
53
|
"sharp": "^0.33.5"
|
|
50
54
|
},
|
|
51
55
|
"devDependencies": {
|
|
52
56
|
"@eslint/js": "^9.39.2",
|
|
53
57
|
"@types/node": "^22.13.14",
|
|
54
|
-
"dotenv": "^16.4.7",
|
|
55
58
|
"eslint": "^9.39.2",
|
|
56
59
|
"eslint-config-prettier": "^10.1.8",
|
|
57
60
|
"prettier": "^3.7.4",
|