@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.
@@ -1,19 +1,15 @@
1
1
  import path from 'path';
2
2
  import { promises as fs } from 'fs';
3
- import dns from 'node:dns/promises';
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 maxBytes = getMaxDownloadBytes();
182
- const timeoutMs = getFetchTimeoutMs();
183
- const maxRedirects = getMaxRedirects();
184
- let current = urlString;
185
- for (let hop = 0; hop <= maxRedirects; hop++) {
186
- const validated = await assertUrlSafeForFetch(current);
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:')) {
@@ -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": "1.8.2",
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 image generation",
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",