@stabgan/openrouter-mcp-multimodal 1.8.2 → 1.9.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.
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,120 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { getAudioFormat, getAudioMimeType, prepareAudioData, isBlockedIPv4, assertUrlSafeForFetch, SUPPORTED_AUDIO_FORMATS, } from '../tool-handlers/audio-utils.js';
3
+ import path from 'path';
4
+ import { writeFileSync, unlinkSync } from 'fs';
5
+ import { tmpdir } from 'os';
6
+ describe('getAudioFormat', () => {
7
+ it('returns correct format for supported extensions', () => {
8
+ expect(getAudioFormat('audio.wav')).toBe('wav');
9
+ expect(getAudioFormat('audio.mp3')).toBe('mp3');
10
+ expect(getAudioFormat('audio.flac')).toBe('flac');
11
+ expect(getAudioFormat('audio.ogg')).toBe('ogg');
12
+ expect(getAudioFormat('audio.aac')).toBe('aac');
13
+ expect(getAudioFormat('audio.m4a')).toBe('m4a');
14
+ expect(getAudioFormat('audio.aiff')).toBe('aiff');
15
+ });
16
+ it('returns undefined for unsupported extensions', () => {
17
+ expect(getAudioFormat('audio.xyz')).toBeUndefined();
18
+ expect(getAudioFormat('audio.mid')).toBeUndefined();
19
+ expect(getAudioFormat('noext')).toBeUndefined();
20
+ });
21
+ it('returns undefined for API-only formats (pcm16/pcm24 are not file extensions)', () => {
22
+ expect(getAudioFormat('audio.pcm16')).toBeUndefined();
23
+ expect(getAudioFormat('audio.pcm24')).toBeUndefined();
24
+ });
25
+ it('handles uppercase extensions', () => {
26
+ expect(getAudioFormat('audio.WAV')).toBe('wav');
27
+ expect(getAudioFormat('audio.MP3')).toBe('mp3');
28
+ expect(getAudioFormat('audio.FLAC')).toBe('flac');
29
+ });
30
+ });
31
+ describe('getAudioMimeType', () => {
32
+ it('returns correct MIME types', () => {
33
+ expect(getAudioMimeType('wav')).toBe('audio/wav');
34
+ expect(getAudioMimeType('mp3')).toBe('audio/mpeg');
35
+ expect(getAudioMimeType('flac')).toBe('audio/flac');
36
+ expect(getAudioMimeType('ogg')).toBe('audio/ogg');
37
+ expect(getAudioMimeType('aac')).toBe('audio/aac');
38
+ expect(getAudioMimeType('m4a')).toBe('audio/mp4');
39
+ expect(getAudioMimeType('aiff')).toBe('audio/aiff');
40
+ expect(getAudioMimeType('pcm16')).toBe('audio/pcm');
41
+ expect(getAudioMimeType('pcm24')).toBe('audio/pcm');
42
+ });
43
+ });
44
+ describe('SUPPORTED_AUDIO_FORMATS', () => {
45
+ it('includes file formats and API formats', () => {
46
+ expect(SUPPORTED_AUDIO_FORMATS).toContain('wav');
47
+ expect(SUPPORTED_AUDIO_FORMATS).toContain('mp3');
48
+ expect(SUPPORTED_AUDIO_FORMATS).toContain('flac');
49
+ expect(SUPPORTED_AUDIO_FORMATS).toContain('ogg');
50
+ expect(SUPPORTED_AUDIO_FORMATS).toContain('aac');
51
+ expect(SUPPORTED_AUDIO_FORMATS).toContain('m4a');
52
+ expect(SUPPORTED_AUDIO_FORMATS).toContain('pcm16');
53
+ expect(SUPPORTED_AUDIO_FORMATS).toContain('pcm24');
54
+ });
55
+ });
56
+ describe('prepareAudioData', () => {
57
+ it('decodes base64 data URLs with correct format', async () => {
58
+ const audioData = Buffer.from('fake-audio-data').toString('base64');
59
+ const result = await prepareAudioData(`data:audio/wav;base64,${audioData}`);
60
+ expect(result.data).toBe(audioData);
61
+ expect(result.format).toBe('wav');
62
+ });
63
+ it('maps audio/mpeg MIME to mp3 format', async () => {
64
+ const audioData = Buffer.from('fake-audio-data').toString('base64');
65
+ const result = await prepareAudioData(`data:audio/mpeg;base64,${audioData}`);
66
+ expect(result.data).toBe(audioData);
67
+ expect(result.format).toBe('mp3');
68
+ });
69
+ it('rejects invalid data URLs', async () => {
70
+ await expect(prepareAudioData('data:invalid')).rejects.toThrow('Invalid data URL');
71
+ });
72
+ it('rejects unsupported MIME types', async () => {
73
+ const audioData = Buffer.from('fake').toString('base64');
74
+ await expect(prepareAudioData(`data:audio/xyz;base64,${audioData}`)).rejects.toThrow('Unsupported audio format');
75
+ });
76
+ it('reads local files and returns base64 with format', async () => {
77
+ const tmpFile = path.join(tmpdir(), `test-audio-${Date.now()}.wav`);
78
+ writeFileSync(tmpFile, Buffer.from('fake-audio-content'));
79
+ try {
80
+ const result = await prepareAudioData(tmpFile);
81
+ expect(result.data).toBe(Buffer.from('fake-audio-content').toString('base64'));
82
+ expect(result.format).toBe('wav');
83
+ }
84
+ finally {
85
+ unlinkSync(tmpFile);
86
+ }
87
+ });
88
+ it('throws on missing files', async () => {
89
+ await expect(prepareAudioData('/nonexistent/path/audio.wav')).rejects.toThrow();
90
+ });
91
+ it('throws on unsupported file extensions', async () => {
92
+ const tmpFile = path.join(tmpdir(), `test-audio-${Date.now()}.xyz`);
93
+ writeFileSync(tmpFile, Buffer.from('fake'));
94
+ try {
95
+ await expect(prepareAudioData(tmpFile)).rejects.toThrow('Unsupported audio format');
96
+ }
97
+ finally {
98
+ unlinkSync(tmpFile);
99
+ }
100
+ });
101
+ it('rejects private IPv4 URLs', async () => {
102
+ await expect(prepareAudioData('http://127.0.0.1:8080/audio.wav')).rejects.toThrow();
103
+ await expect(prepareAudioData('http://192.168.1.1/audio.mp3')).rejects.toThrow();
104
+ });
105
+ it('rejects localhost hostnames', async () => {
106
+ await expect(assertUrlSafeForFetch('http://localhost/audio.wav')).rejects.toThrow();
107
+ });
108
+ });
109
+ describe('isBlockedIPv4 (re-exported from fetch-utils)', () => {
110
+ it('identifies loopback and RFC1918', () => {
111
+ expect(isBlockedIPv4('127.0.0.1')).toBe(true);
112
+ expect(isBlockedIPv4('10.0.0.1')).toBe(true);
113
+ expect(isBlockedIPv4('192.168.1.1')).toBe(true);
114
+ expect(isBlockedIPv4('172.16.0.1')).toBe(true);
115
+ expect(isBlockedIPv4('8.8.8.8')).toBe(false);
116
+ });
117
+ it('blocks metadata endpoint IP', () => {
118
+ expect(isBlockedIPv4('169.254.169.254')).toBe(true);
119
+ });
120
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,76 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { readEnvInt, isBlockedIPv4, assertUrlSafeForFetch } from '../tool-handlers/fetch-utils.js';
3
+ describe('readEnvInt', () => {
4
+ it('returns fallback when env var is missing', () => {
5
+ delete process.env['TEST_MISSING_VAR'];
6
+ expect(readEnvInt('TEST_MISSING_VAR', 42)).toBe(42);
7
+ });
8
+ it('returns fallback when env var is empty', () => {
9
+ process.env['TEST_EMPTY_VAR'] = '';
10
+ expect(readEnvInt('TEST_EMPTY_VAR', 42)).toBe(42);
11
+ delete process.env['TEST_EMPTY_VAR'];
12
+ });
13
+ it('parses valid integer', () => {
14
+ process.env['TEST_INT_VAR'] = '100';
15
+ expect(readEnvInt('TEST_INT_VAR', 42)).toBe(100);
16
+ delete process.env['TEST_INT_VAR'];
17
+ });
18
+ it('returns fallback for non-numeric value', () => {
19
+ process.env['TEST_NAN_VAR'] = 'abc';
20
+ expect(readEnvInt('TEST_NAN_VAR', 42)).toBe(42);
21
+ delete process.env['TEST_NAN_VAR'];
22
+ });
23
+ it('returns fallback when value is below min', () => {
24
+ process.env['TEST_LOW_VAR'] = '0';
25
+ expect(readEnvInt('TEST_LOW_VAR', 42, 1)).toBe(42);
26
+ delete process.env['TEST_LOW_VAR'];
27
+ });
28
+ });
29
+ describe('isBlockedIPv4', () => {
30
+ it('blocks loopback', () => {
31
+ expect(isBlockedIPv4('127.0.0.1')).toBe(true);
32
+ expect(isBlockedIPv4('127.255.255.255')).toBe(true);
33
+ });
34
+ it('blocks RFC1918 10.x', () => {
35
+ expect(isBlockedIPv4('10.0.0.1')).toBe(true);
36
+ expect(isBlockedIPv4('10.255.255.255')).toBe(true);
37
+ });
38
+ it('blocks RFC1918 172.16-31.x', () => {
39
+ expect(isBlockedIPv4('172.16.0.1')).toBe(true);
40
+ expect(isBlockedIPv4('172.31.255.255')).toBe(true);
41
+ });
42
+ it('blocks RFC1918 192.168.x', () => {
43
+ expect(isBlockedIPv4('192.168.0.1')).toBe(true);
44
+ expect(isBlockedIPv4('192.168.255.255')).toBe(true);
45
+ });
46
+ it('blocks link-local 169.254.x', () => {
47
+ expect(isBlockedIPv4('169.254.169.254')).toBe(true);
48
+ });
49
+ it('blocks CGNAT 100.64-127.x', () => {
50
+ expect(isBlockedIPv4('100.64.0.1')).toBe(true);
51
+ expect(isBlockedIPv4('100.127.255.255')).toBe(true);
52
+ });
53
+ it('allows public IPs', () => {
54
+ expect(isBlockedIPv4('8.8.8.8')).toBe(false);
55
+ expect(isBlockedIPv4('1.1.1.1')).toBe(false);
56
+ expect(isBlockedIPv4('142.250.80.46')).toBe(false);
57
+ });
58
+ });
59
+ describe('assertUrlSafeForFetch', () => {
60
+ it('rejects localhost', async () => {
61
+ await expect(assertUrlSafeForFetch('http://localhost/foo')).rejects.toThrow('Blocked host');
62
+ });
63
+ it('rejects private IPv4', async () => {
64
+ await expect(assertUrlSafeForFetch('http://127.0.0.1/foo')).rejects.toThrow('Blocked host');
65
+ await expect(assertUrlSafeForFetch('http://192.168.1.1/foo')).rejects.toThrow('Blocked host');
66
+ });
67
+ it('rejects non-HTTP protocols', async () => {
68
+ await expect(assertUrlSafeForFetch('ftp://example.com/foo')).rejects.toThrow('Only HTTP(S)');
69
+ });
70
+ it('rejects URLs with credentials', async () => {
71
+ await expect(assertUrlSafeForFetch('http://user:pass@example.com/foo')).rejects.toThrow('credentials');
72
+ });
73
+ it('rejects invalid URLs', async () => {
74
+ await expect(assertUrlSafeForFetch('not-a-url')).rejects.toThrow('Invalid URL');
75
+ });
76
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,90 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { createWavHeader, detectAudioFormat, wrapPcmInWav, replaceExtension, } from '../tool-handlers/generate-audio.js';
3
+ describe('createWavHeader', () => {
4
+ it('produces a 44-byte buffer', () => {
5
+ const header = createWavHeader(1000);
6
+ expect(header.length).toBe(44);
7
+ });
8
+ it('starts with RIFF...WAVE', () => {
9
+ const header = createWavHeader(1000);
10
+ expect(header.subarray(0, 4).toString('ascii')).toBe('RIFF');
11
+ expect(header.subarray(8, 12).toString('ascii')).toBe('WAVE');
12
+ });
13
+ it('has correct file size field (36 + dataLength)', () => {
14
+ const header = createWavHeader(1000);
15
+ expect(header.readUInt32LE(4)).toBe(36 + 1000);
16
+ });
17
+ it('has PCM format (1)', () => {
18
+ const header = createWavHeader(1000);
19
+ expect(header.readUInt16LE(20)).toBe(1);
20
+ });
21
+ it('has correct data chunk size', () => {
22
+ const header = createWavHeader(2048);
23
+ expect(header.readUInt32LE(40)).toBe(2048);
24
+ });
25
+ });
26
+ describe('detectAudioFormat', () => {
27
+ it('detects MP3 with ID3 tag', () => {
28
+ const buf = Buffer.from([0x49, 0x44, 0x33, 0x00, 0x00]);
29
+ expect(detectAudioFormat(buf)).toEqual({ ext: 'mp3', mimeType: 'audio/mpeg' });
30
+ });
31
+ it('detects MP3 frame sync (MPEG1 Layer3 = 0xFF 0xFB)', () => {
32
+ const buf = Buffer.from([0xff, 0xfb, 0x90, 0x00]);
33
+ expect(detectAudioFormat(buf)).toEqual({ ext: 'mp3', mimeType: 'audio/mpeg' });
34
+ });
35
+ it('rejects reserved MP3 version bits (0x01)', () => {
36
+ // 0xFF 0xE8 → version bits = (0xE8 >> 3) & 0x03 = 0x01 (reserved)
37
+ const buf = Buffer.from([0xff, 0xe8, 0x00, 0x00]);
38
+ expect(detectAudioFormat(buf).ext).not.toBe('mp3');
39
+ });
40
+ it('detects WAV (RIFF...WAVE)', () => {
41
+ const buf = Buffer.alloc(12);
42
+ buf.write('RIFF', 0);
43
+ buf.writeUInt32LE(100, 4);
44
+ buf.write('WAVE', 8);
45
+ expect(detectAudioFormat(buf)).toEqual({ ext: 'wav', mimeType: 'audio/wav' });
46
+ });
47
+ it('detects FLAC', () => {
48
+ const buf = Buffer.from('fLaC\x00\x00', 'ascii');
49
+ expect(detectAudioFormat(buf)).toEqual({ ext: 'flac', mimeType: 'audio/flac' });
50
+ });
51
+ it('detects OGG', () => {
52
+ const buf = Buffer.from('OggS\x00\x00', 'ascii');
53
+ expect(detectAudioFormat(buf)).toEqual({ ext: 'ogg', mimeType: 'audio/ogg' });
54
+ });
55
+ it('defaults to pcm for unknown data', () => {
56
+ const buf = Buffer.from([0x00, 0x01, 0x02, 0x03]);
57
+ expect(detectAudioFormat(buf)).toEqual({ ext: 'pcm', mimeType: 'audio/pcm' });
58
+ });
59
+ it('defaults to pcm for empty buffer', () => {
60
+ expect(detectAudioFormat(Buffer.alloc(0)).ext).toBe('pcm');
61
+ });
62
+ });
63
+ describe('wrapPcmInWav', () => {
64
+ it('prepends 44-byte WAV header', () => {
65
+ const pcm = Buffer.from([0x00, 0x01, 0x02, 0x03]);
66
+ const wav = wrapPcmInWav(pcm);
67
+ expect(wav.length).toBe(44 + 4);
68
+ expect(wav.subarray(0, 4).toString('ascii')).toBe('RIFF');
69
+ expect(wav.subarray(8, 12).toString('ascii')).toBe('WAVE');
70
+ });
71
+ it('detected as WAV after wrapping', () => {
72
+ const pcm = Buffer.alloc(100);
73
+ const wav = wrapPcmInWav(pcm);
74
+ expect(detectAudioFormat(wav)).toEqual({ ext: 'wav', mimeType: 'audio/wav' });
75
+ });
76
+ });
77
+ describe('replaceExtension', () => {
78
+ it('replaces existing extension', () => {
79
+ expect(replaceExtension('output.wav', 'mp3')).toBe('output.mp3');
80
+ });
81
+ it('appends extension when none exists', () => {
82
+ expect(replaceExtension('output', 'wav')).toBe('output.wav');
83
+ });
84
+ it('handles nested paths', () => {
85
+ expect(replaceExtension('/tmp/audio/file.wav', 'mp3')).toBe('/tmp/audio/file.mp3');
86
+ });
87
+ it('handles dotfiles', () => {
88
+ expect(replaceExtension('.hidden.wav', 'mp3')).toBe('.hidden.mp3');
89
+ });
90
+ });
@@ -6,9 +6,12 @@ import { handleAnalyzeImage } from '../tool-handlers/analyze-image.js';
6
6
  import { handleSearchModels } from '../tool-handlers/search-models.js';
7
7
  import { handleGetModelInfo } from '../tool-handlers/get-model-info.js';
8
8
  import { handleValidateModel } from '../tool-handlers/validate-model.js';
9
+ import { handleAnalyzeAudio } from '../tool-handlers/analyze-audio.js';
10
+ import { handleGenerateAudio } from '../tool-handlers/generate-audio.js';
9
11
  import { OpenRouterAPIClient } from '../openrouter-api.js';
10
12
  import { ModelCache } from '../model-cache.js';
11
13
  import path from 'path';
14
+ import { promises as fsPromises } from 'fs';
12
15
  config(); // Load .env
13
16
  const API_KEY = process.env.OPENROUTER_API_KEY;
14
17
  const DEFAULT_MODEL = 'nvidia/nemotron-nano-12b-v2-vl:free';
@@ -109,3 +112,108 @@ describeIf('Integration: get_model_info + validate_model', () => {
109
112
  expect(parsed.valid).toBe(false);
110
113
  });
111
114
  });
115
+ describeIf('Integration: analyze_audio', () => {
116
+ let openai;
117
+ beforeAll(() => {
118
+ openai = new OpenAI({ apiKey: API_KEY, baseURL: 'https://openrouter.ai/api/v1' });
119
+ });
120
+ it('should analyze audio from a data URL', async () => {
121
+ // Create a minimal WAV file (44-byte header + tiny PCM data) as a data URL
122
+ const header = Buffer.alloc(44);
123
+ header.write('RIFF', 0);
124
+ header.writeUInt32LE(36 + 100, 4);
125
+ header.write('WAVE', 8);
126
+ header.write('fmt ', 12);
127
+ header.writeUInt32LE(16, 16);
128
+ header.writeUInt16LE(1, 20);
129
+ header.writeUInt16LE(1, 22);
130
+ header.writeUInt32LE(16000, 24);
131
+ header.writeUInt32LE(32000, 28);
132
+ header.writeUInt16LE(2, 32);
133
+ header.writeUInt16LE(16, 34);
134
+ header.write('data', 36);
135
+ header.writeUInt32LE(100, 40);
136
+ const pcmData = Buffer.alloc(100); // silence
137
+ const wavBuffer = Buffer.concat([header, pcmData]);
138
+ const b64 = wavBuffer.toString('base64');
139
+ const result = await handleAnalyzeAudio({
140
+ params: {
141
+ arguments: {
142
+ audio_path: `data:audio/wav;base64,${b64}`,
143
+ question: 'What do you hear?',
144
+ model: 'google/gemini-2.5-flash',
145
+ },
146
+ },
147
+ }, openai);
148
+ if (result.isError) {
149
+ // 402 = insufficient balance — code works, account needs credits
150
+ const errText = result.content[0].text;
151
+ console.log('analyze_audio error:', errText);
152
+ if (errText.includes('402') || errText.includes('balance')) {
153
+ // Expected when account has no audio credits — test the code path worked
154
+ expect(errText).toContain('402');
155
+ return;
156
+ }
157
+ }
158
+ expect(result.isError).toBeFalsy();
159
+ expect(result.content[0].text.length).toBeGreaterThan(0);
160
+ }, 30000);
161
+ it('should return error for missing audio_path', async () => {
162
+ const result = await handleAnalyzeAudio({ params: { arguments: { audio_path: '' } } }, openai);
163
+ expect(result.isError).toBe(true);
164
+ });
165
+ });
166
+ describeIf('Integration: generate_audio', () => {
167
+ let openai;
168
+ beforeAll(() => {
169
+ openai = new OpenAI({ apiKey: API_KEY, baseURL: 'https://openrouter.ai/api/v1' });
170
+ });
171
+ it('should generate audio from a text prompt', async () => {
172
+ const result = await handleGenerateAudio({
173
+ params: {
174
+ arguments: {
175
+ prompt: 'Say hello world',
176
+ model: 'openai/gpt-4o-mini-audio-preview',
177
+ voice: 'alloy',
178
+ },
179
+ },
180
+ }, openai);
181
+ // Either we get audio back or a graceful error (model availability varies)
182
+ expect(result.content.length).toBeGreaterThan(0);
183
+ if (!result.isError) {
184
+ const audioContent = result.content.find((c) => c.type === 'audio');
185
+ if (audioContent) {
186
+ expect(audioContent.data.length).toBeGreaterThan(0);
187
+ }
188
+ }
189
+ }, 60000);
190
+ it('should save audio to file and auto-correct extension', async () => {
191
+ const tmpPath = path.join('/tmp', `test-gen-audio-${Date.now()}.wav`);
192
+ const result = await handleGenerateAudio({
193
+ params: {
194
+ arguments: {
195
+ prompt: 'Say the word test',
196
+ model: 'openai/gpt-4o-mini-audio-preview',
197
+ voice: 'alloy',
198
+ save_path: tmpPath,
199
+ },
200
+ },
201
+ }, openai);
202
+ if (!result.isError) {
203
+ const textContent = result.content.find((c) => c.type === 'text');
204
+ expect(textContent.text).toContain('Audio saved to:');
205
+ // Clean up - the actual path may have been corrected
206
+ const savedPath = textContent.text.match(/Audio saved to: (.+?)(\s|\n|$)/)?.[1];
207
+ if (savedPath) {
208
+ try {
209
+ await fsPromises.unlink(savedPath);
210
+ }
211
+ catch { /* ignore */ }
212
+ }
213
+ }
214
+ }, 60000);
215
+ it('should return error for empty prompt', async () => {
216
+ const result = await handleGenerateAudio({ params: { arguments: { prompt: '' } } }, openai);
217
+ expect(result.isError).toBe(true);
218
+ });
219
+ });
package/dist/index.js CHANGED
@@ -1,5 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import { Readable } from 'node:stream';
3
+ import { config } from 'dotenv';
4
+ config(); // Load .env file if present
3
5
  import { Server } from '@modelcontextprotocol/sdk/server/index.js';
4
6
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
5
7
  import { ToolHandlers } from './tool-handlers.js';
@@ -0,0 +1,23 @@
1
+ import OpenAI from 'openai';
2
+ export interface AnalyzeAudioToolRequest {
3
+ audio_path: string;
4
+ question?: string;
5
+ model?: string;
6
+ }
7
+ export declare function handleAnalyzeAudio(request: {
8
+ params: {
9
+ arguments: AnalyzeAudioToolRequest;
10
+ };
11
+ }, openai: OpenAI, defaultModel?: string): Promise<{
12
+ content: {
13
+ type: string;
14
+ text: string;
15
+ }[];
16
+ isError: boolean;
17
+ } | {
18
+ content: {
19
+ type: string;
20
+ text: string;
21
+ }[];
22
+ isError?: undefined;
23
+ }>;
@@ -0,0 +1,34 @@
1
+ import { prepareAudioData } from './audio-utils.js';
2
+ const DEFAULT_MODEL = 'google/gemini-2.5-flash';
3
+ export async function handleAnalyzeAudio(request, openai, defaultModel) {
4
+ const { audio_path, question, model } = request.params.arguments;
5
+ if (!audio_path) {
6
+ return { content: [{ type: 'text', text: 'audio_path is required.' }], isError: true };
7
+ }
8
+ try {
9
+ const audioData = await prepareAudioData(audio_path);
10
+ const completion = await openai.chat.completions.create({
11
+ model: model || defaultModel || DEFAULT_MODEL,
12
+ messages: [
13
+ {
14
+ role: 'user',
15
+ content: [
16
+ { type: 'text', text: question || 'Please transcribe and analyze this audio file.' },
17
+ {
18
+ type: 'input_audio',
19
+ input_audio: {
20
+ data: audioData.data,
21
+ format: audioData.format,
22
+ },
23
+ },
24
+ ],
25
+ },
26
+ ],
27
+ });
28
+ return { content: [{ type: 'text', text: completion.choices[0].message.content || '' }] };
29
+ }
30
+ catch (error) {
31
+ const msg = error instanceof Error ? error.message : String(error);
32
+ return { content: [{ type: 'text', text: `Error: ${msg}` }], isError: true };
33
+ }
34
+ }
@@ -0,0 +1,19 @@
1
+ export { isBlockedIPv4, assertUrlSafeForFetch } from './fetch-utils.js';
2
+ /** File-extension formats (matchable by .ext) */
3
+ declare const FILE_AUDIO_FORMATS: readonly ["wav", "mp3", "aiff", "aac", "ogg", "flac", "m4a"];
4
+ export declare const SUPPORTED_AUDIO_FORMATS: readonly ["wav", "mp3", "aiff", "aac", "ogg", "flac", "m4a", "pcm16", "pcm24"];
5
+ export type AudioFormat = (typeof SUPPORTED_AUDIO_FORMATS)[number];
6
+ type FileAudioFormat = (typeof FILE_AUDIO_FORMATS)[number];
7
+ /** Get audio format from file extension. Returns undefined for non-audio or API-only formats. */
8
+ export declare function getAudioFormat(filePath: string): FileAudioFormat | undefined;
9
+ /** Get MIME type for an audio format. */
10
+ export declare function getAudioMimeType(format: AudioFormat): string;
11
+ export interface AudioData {
12
+ data: string;
13
+ format: AudioFormat;
14
+ }
15
+ /**
16
+ * Prepare audio from any source (data URL, HTTP URL, local file) as base64 + format.
17
+ * OpenRouter requires audio to be base64-encoded; direct URLs are NOT supported.
18
+ */
19
+ export declare function prepareAudioData(source: string): Promise<AudioData>;
@@ -0,0 +1,128 @@
1
+ /**
2
+ * Audio format detection, base64 encoding, and fetch utilities.
3
+ * Network/security logic is delegated to fetch-utils.ts (zero duplication).
4
+ */
5
+ import path from 'path';
6
+ import { promises as fs } from 'fs';
7
+ import { readEnvInt, fetchHttpResource } from './fetch-utils.js';
8
+ // Re-export for tests
9
+ export { isBlockedIPv4, assertUrlSafeForFetch } from './fetch-utils.js';
10
+ const DEFAULT_FETCH_TIMEOUT_MS = 30_000;
11
+ const DEFAULT_MAX_DOWNLOAD_BYTES = 25 * 1024 * 1024;
12
+ const DEFAULT_MAX_REDIRECTS = 8;
13
+ const DEFAULT_MAX_DATA_URL_BYTES = 20 * 1024 * 1024;
14
+ function getFetchTimeoutMs() {
15
+ return readEnvInt('OPENROUTER_AUDIO_FETCH_TIMEOUT_MS', DEFAULT_FETCH_TIMEOUT_MS, 1000);
16
+ }
17
+ function getMaxDownloadBytes() {
18
+ return readEnvInt('OPENROUTER_AUDIO_MAX_DOWNLOAD_BYTES', DEFAULT_MAX_DOWNLOAD_BYTES, 1024);
19
+ }
20
+ function getMaxRedirects() {
21
+ return readEnvInt('OPENROUTER_AUDIO_MAX_REDIRECTS', DEFAULT_MAX_REDIRECTS, 0);
22
+ }
23
+ function getMaxDataUrlBytes() {
24
+ return readEnvInt('OPENROUTER_AUDIO_MAX_DATA_URL_BYTES', DEFAULT_MAX_DATA_URL_BYTES, 1024);
25
+ }
26
+ /** File-extension formats (matchable by .ext) */
27
+ const FILE_AUDIO_FORMATS = ['wav', 'mp3', 'aiff', 'aac', 'ogg', 'flac', 'm4a'];
28
+ /** API-only formats (no real file extension) */
29
+ const API_AUDIO_FORMATS = ['pcm16', 'pcm24'];
30
+ export const SUPPORTED_AUDIO_FORMATS = [...FILE_AUDIO_FORMATS, ...API_AUDIO_FORMATS];
31
+ /** Get audio format from file extension. Returns undefined for non-audio or API-only formats. */
32
+ export function getAudioFormat(filePath) {
33
+ const ext = path.extname(filePath).toLowerCase().slice(1);
34
+ return FILE_AUDIO_FORMATS.includes(ext)
35
+ ? ext
36
+ : undefined;
37
+ }
38
+ /** Get MIME type for an audio format. */
39
+ export function getAudioMimeType(format) {
40
+ const map = {
41
+ wav: 'audio/wav',
42
+ mp3: 'audio/mpeg',
43
+ aiff: 'audio/aiff',
44
+ aac: 'audio/aac',
45
+ ogg: 'audio/ogg',
46
+ flac: 'audio/flac',
47
+ m4a: 'audio/mp4',
48
+ pcm16: 'audio/pcm',
49
+ pcm24: 'audio/pcm',
50
+ };
51
+ return map[format] || 'audio/wav';
52
+ }
53
+ /** Map a MIME subtype (e.g. "mpeg", "wave") to an AudioFormat. */
54
+ function mimeSubtypeToFormat(subtype) {
55
+ const aliasMap = {
56
+ mpeg: 'mp3',
57
+ wav: 'wav',
58
+ wave: 'wav',
59
+ mp3: 'mp3',
60
+ flac: 'flac',
61
+ ogg: 'ogg',
62
+ aac: 'aac',
63
+ 'x-aac': 'aac',
64
+ m4a: 'm4a',
65
+ mp4: 'm4a',
66
+ aiff: 'aiff',
67
+ 'x-aiff': 'aiff',
68
+ pcm: 'pcm16',
69
+ };
70
+ const lower = subtype.toLowerCase();
71
+ return (aliasMap[lower] ??
72
+ (SUPPORTED_AUDIO_FORMATS.includes(lower)
73
+ ? lower
74
+ : undefined));
75
+ }
76
+ /** Derive AudioFormat from a Content-Type header value. */
77
+ function formatFromContentType(ct) {
78
+ if (!ct)
79
+ return undefined;
80
+ const mime = ct.split(';')[0].trim().toLowerCase();
81
+ if (!mime.startsWith('audio/'))
82
+ return undefined;
83
+ return mimeSubtypeToFormat(mime.slice(6));
84
+ }
85
+ /**
86
+ * Prepare audio from any source (data URL, HTTP URL, local file) as base64 + format.
87
+ * OpenRouter requires audio to be base64-encoded; direct URLs are NOT supported.
88
+ */
89
+ export async function prepareAudioData(source) {
90
+ // --- data URL ---
91
+ if (source.startsWith('data:')) {
92
+ const match = source.match(/^data:([^;]+);base64,(.+)$/);
93
+ if (!match)
94
+ throw new Error('Invalid data URL format');
95
+ const mime = match[1];
96
+ const b64 = match[2];
97
+ const format = mimeSubtypeToFormat(mime.split('/')[1] ?? '');
98
+ if (!format) {
99
+ throw new Error(`Unsupported audio format from MIME: ${mime}. Supported: ${SUPPORTED_AUDIO_FORMATS.join(', ')}`);
100
+ }
101
+ const approxBytes = Math.ceil((b64.length * 3) / 4);
102
+ if (approxBytes > getMaxDataUrlBytes())
103
+ throw new Error('Data URL too large');
104
+ return { data: b64, format };
105
+ }
106
+ // --- HTTP(S) URL ---
107
+ if (source.startsWith('http://') || source.startsWith('https://')) {
108
+ const { buffer, contentType } = await fetchHttpResource(source, {
109
+ timeoutMs: getFetchTimeoutMs(),
110
+ maxBytes: getMaxDownloadBytes(),
111
+ maxRedirects: getMaxRedirects(),
112
+ });
113
+ // Try URL path extension first, fall back to Content-Type header
114
+ const urlPath = new URL(source).pathname;
115
+ const format = getAudioFormat(urlPath) ?? formatFromContentType(contentType);
116
+ if (!format) {
117
+ throw new Error(`Could not determine audio format from URL: ${source}. Supported: ${SUPPORTED_AUDIO_FORMATS.join(', ')}`);
118
+ }
119
+ return { data: buffer.toString('base64'), format };
120
+ }
121
+ // --- local file ---
122
+ const format = getAudioFormat(source);
123
+ if (!format) {
124
+ throw new Error(`Unsupported audio format for file: ${source}. Supported: ${SUPPORTED_AUDIO_FORMATS.join(', ')}`);
125
+ }
126
+ const buffer = await fs.readFile(source);
127
+ return { data: buffer.toString('base64'), format };
128
+ }
@@ -0,0 +1,18 @@
1
+ export declare function readEnvInt(name: string, fallback: number, min?: number): number;
2
+ /** Blocks RFC1918, loopback, link-local, CGNAT, metadata. */
3
+ export declare function isBlockedIPv4(ip: string): boolean;
4
+ /** Resolve hostname and ensure the resolved address is not private/link-local. */
5
+ export declare function assertUrlSafeForFetch(urlString: string): Promise<URL>;
6
+ export interface FetchOptions {
7
+ timeoutMs: number;
8
+ maxBytes: number;
9
+ maxRedirects: number;
10
+ }
11
+ /**
12
+ * Fetch a remote HTTP(S) resource with SSRF protection, size limits,
13
+ * redirect cap, and timeout. Returns body Buffer + Content-Type header.
14
+ */
15
+ export declare function fetchHttpResource(urlString: string, opts: FetchOptions): Promise<{
16
+ buffer: Buffer;
17
+ contentType: string | null;
18
+ }>;
@@ -0,0 +1,161 @@
1
+ /**
2
+ * Shared network/security utilities for fetching remote resources.
3
+ * Used by both image-utils and audio-utils to avoid duplication.
4
+ */
5
+ import dns from 'node:dns/promises';
6
+ export function readEnvInt(name, fallback, min = 1) {
7
+ const raw = process.env[name];
8
+ if (raw === undefined || raw === '')
9
+ return fallback;
10
+ const n = parseInt(raw, 10);
11
+ return Number.isFinite(n) && n >= min ? n : fallback;
12
+ }
13
+ function ipv4ToUint(ip) {
14
+ const parts = ip.split('.').map((p) => parseInt(p, 10));
15
+ if (parts.length !== 4 || parts.some((p) => !Number.isInteger(p) || p < 0 || p > 255)) {
16
+ throw new Error('Invalid IPv4');
17
+ }
18
+ return ((parts[0] << 24) | (parts[1] << 16) | (parts[2] << 8) | parts[3]) >>> 0;
19
+ }
20
+ /** Blocks RFC1918, loopback, link-local, CGNAT, metadata. */
21
+ export function isBlockedIPv4(ip) {
22
+ const n = ipv4ToUint(ip);
23
+ if (n >>> 24 === 127)
24
+ return true;
25
+ if (n >>> 24 === 10)
26
+ return true;
27
+ if (n >>> 20 === 0xac1)
28
+ return true;
29
+ if (n >>> 16 === 0xc0a8)
30
+ return true;
31
+ if (n >>> 16 === 0xa9fe)
32
+ return true;
33
+ if (n >>> 24 === 0)
34
+ return true;
35
+ if (n >= 0x64400000 && n <= 0x647fffff)
36
+ return true;
37
+ return false;
38
+ }
39
+ function isBlockedIPv6(ip) {
40
+ const raw = ip.includes('%') ? ip.split('%')[0] : ip;
41
+ const x = raw.toLowerCase();
42
+ if (x === '::1')
43
+ return true;
44
+ if (x.startsWith('fe80:') || x.startsWith('fec0:'))
45
+ return true;
46
+ const first = x.split(':').find((p) => p.length > 0);
47
+ if (first) {
48
+ const v = parseInt(first, 16);
49
+ if (!Number.isNaN(v) && v >= 0xfc00 && v <= 0xfdff)
50
+ return true;
51
+ }
52
+ return false;
53
+ }
54
+ function isIPv4Literal(host) {
55
+ return /^\d{1,3}(\.\d{1,3}){3}$/.test(host);
56
+ }
57
+ /** Resolve hostname and ensure the resolved address is not private/link-local. */
58
+ export async function assertUrlSafeForFetch(urlString) {
59
+ let url;
60
+ try {
61
+ url = new URL(urlString);
62
+ }
63
+ catch {
64
+ throw new Error('Invalid URL');
65
+ }
66
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') {
67
+ throw new Error('Only HTTP(S) URLs are allowed');
68
+ }
69
+ if (url.username || url.password) {
70
+ throw new Error('URL with credentials is not allowed');
71
+ }
72
+ const host = url.hostname.toLowerCase();
73
+ if (host === 'localhost' || host.endsWith('.localhost')) {
74
+ throw new Error('Blocked host');
75
+ }
76
+ if (isIPv4Literal(host)) {
77
+ if (isBlockedIPv4(host))
78
+ throw new Error('Blocked host');
79
+ return url;
80
+ }
81
+ if (host.includes(':') && !host.startsWith('[')) {
82
+ if (isBlockedIPv6(host))
83
+ throw new Error('Blocked host');
84
+ return url;
85
+ }
86
+ let lookupHost = host;
87
+ if (host.startsWith('[') && host.endsWith(']')) {
88
+ lookupHost = host.slice(1, -1);
89
+ if (isBlockedIPv6(lookupHost))
90
+ throw new Error('Blocked host');
91
+ return url;
92
+ }
93
+ const records = await dns.lookup(lookupHost, { all: true, verbatim: true });
94
+ if (!records.length)
95
+ throw new Error('Could not resolve host');
96
+ for (const r of records) {
97
+ const { address, family } = r;
98
+ if (family === 4) {
99
+ if (isBlockedIPv4(address))
100
+ throw new Error('Blocked host');
101
+ }
102
+ else if (family === 6) {
103
+ if (isBlockedIPv6(address))
104
+ throw new Error('Blocked host');
105
+ }
106
+ }
107
+ return url;
108
+ }
109
+ async function readResponseBodyWithLimit(res, maxBytes) {
110
+ const reader = res.body?.getReader();
111
+ if (!reader) {
112
+ const buf = Buffer.from(await res.arrayBuffer());
113
+ if (buf.length > maxBytes)
114
+ throw new Error('Response too large');
115
+ return buf;
116
+ }
117
+ const chunks = [];
118
+ let total = 0;
119
+ for (;;) {
120
+ const { done, value } = await reader.read();
121
+ if (done)
122
+ break;
123
+ total += value.byteLength;
124
+ if (total > maxBytes)
125
+ throw new Error('Response too large');
126
+ chunks.push(Buffer.from(value));
127
+ }
128
+ return Buffer.concat(chunks);
129
+ }
130
+ /**
131
+ * Fetch a remote HTTP(S) resource with SSRF protection, size limits,
132
+ * redirect cap, and timeout. Returns body Buffer + Content-Type header.
133
+ */
134
+ export async function fetchHttpResource(urlString, opts) {
135
+ let current = urlString;
136
+ for (let hop = 0; hop <= opts.maxRedirects; hop++) {
137
+ const validated = await assertUrlSafeForFetch(current);
138
+ const target = validated.href;
139
+ const controller = new AbortController();
140
+ const t = setTimeout(() => controller.abort(), opts.timeoutMs);
141
+ let res;
142
+ try {
143
+ res = await fetch(target, { redirect: 'manual', signal: controller.signal });
144
+ }
145
+ finally {
146
+ clearTimeout(t);
147
+ }
148
+ if (res.status >= 300 && res.status < 400) {
149
+ const loc = res.headers.get('location');
150
+ if (!loc)
151
+ throw new Error('Redirect without Location header');
152
+ current = new URL(loc, target).href;
153
+ continue;
154
+ }
155
+ if (!res.ok)
156
+ throw new Error(`HTTP ${res.status}`);
157
+ const buffer = await readResponseBodyWithLimit(res, opts.maxBytes);
158
+ return { buffer, contentType: res.headers.get('content-type') };
159
+ }
160
+ throw new Error('Too many redirects');
161
+ }
@@ -0,0 +1,45 @@
1
+ import OpenAI from 'openai';
2
+ export interface GenerateAudioToolRequest {
3
+ prompt: string;
4
+ model?: string;
5
+ voice?: string;
6
+ format?: string;
7
+ save_path?: string;
8
+ }
9
+ /** Create a 44-byte WAV header for raw PCM16 data. */
10
+ export declare function createWavHeader(dataLength: number, sampleRate?: number): Buffer;
11
+ /**
12
+ * Detect audio container format from magic bytes.
13
+ * Uses subarray (not deprecated slice). Tighter MP3 frame sync validation.
14
+ */
15
+ export declare function detectAudioFormat(data: Buffer): {
16
+ ext: string;
17
+ mimeType: string;
18
+ };
19
+ export declare function wrapPcmInWav(pcmData: Buffer): Buffer;
20
+ /** Strip existing extension (if any) and append a new one. */
21
+ export declare function replaceExtension(filePath: string, newExt: string): string;
22
+ export declare function handleGenerateAudio(request: {
23
+ params: {
24
+ arguments: GenerateAudioToolRequest;
25
+ };
26
+ }, openai: OpenAI): Promise<{
27
+ content: {
28
+ type: string;
29
+ text: string;
30
+ }[];
31
+ isError: boolean;
32
+ } | {
33
+ content: ({
34
+ type: string;
35
+ text: string;
36
+ mimeType?: undefined;
37
+ data?: undefined;
38
+ } | {
39
+ type: string;
40
+ mimeType: string;
41
+ data: string;
42
+ text?: undefined;
43
+ })[];
44
+ isError?: undefined;
45
+ }>;
@@ -0,0 +1,154 @@
1
+ import { promises as fs } from 'fs';
2
+ import { dirname, extname } from 'path';
3
+ const DEFAULT_MODEL = 'openai/gpt-audio';
4
+ const DEFAULT_VOICE = 'alloy';
5
+ const DEFAULT_FORMAT = 'pcm16';
6
+ const VALID_FORMATS = ['wav', 'mp3', 'flac', 'opus', 'pcm16'];
7
+ const PCM_SAMPLE_RATE = 24000;
8
+ const PCM_BITS_PER_SAMPLE = 16;
9
+ const PCM_NUM_CHANNELS = 1;
10
+ /** Create a 44-byte WAV header for raw PCM16 data. */
11
+ export function createWavHeader(dataLength, sampleRate = PCM_SAMPLE_RATE) {
12
+ const header = Buffer.alloc(44);
13
+ const byteRate = sampleRate * PCM_NUM_CHANNELS * (PCM_BITS_PER_SAMPLE / 8);
14
+ const blockAlign = PCM_NUM_CHANNELS * (PCM_BITS_PER_SAMPLE / 8);
15
+ header.write('RIFF', 0);
16
+ header.writeUInt32LE(36 + dataLength, 4);
17
+ header.write('WAVE', 8);
18
+ header.write('fmt ', 12);
19
+ header.writeUInt32LE(16, 16);
20
+ header.writeUInt16LE(1, 20);
21
+ header.writeUInt16LE(PCM_NUM_CHANNELS, 22);
22
+ header.writeUInt32LE(sampleRate, 24);
23
+ header.writeUInt32LE(byteRate, 28);
24
+ header.writeUInt16LE(blockAlign, 32);
25
+ header.writeUInt16LE(PCM_BITS_PER_SAMPLE, 34);
26
+ header.write('data', 36);
27
+ header.writeUInt32LE(dataLength, 40);
28
+ return header;
29
+ }
30
+ /**
31
+ * Detect audio container format from magic bytes.
32
+ * Uses subarray (not deprecated slice). Tighter MP3 frame sync validation.
33
+ */
34
+ export function detectAudioFormat(data) {
35
+ if (data.length >= 3) {
36
+ if (data[0] === 0x49 && data[1] === 0x44 && data[2] === 0x33) {
37
+ return { ext: 'mp3', mimeType: 'audio/mpeg' };
38
+ }
39
+ if (data[0] === 0xff && (data[1] & 0xe0) === 0xe0) {
40
+ const versionBits = (data[1] >> 3) & 0x03;
41
+ if (versionBits !== 0x01) {
42
+ return { ext: 'mp3', mimeType: 'audio/mpeg' };
43
+ }
44
+ }
45
+ }
46
+ if (data.length >= 12) {
47
+ const riff = data.subarray(0, 4).toString('ascii');
48
+ const wave = data.subarray(8, 12).toString('ascii');
49
+ if (riff === 'RIFF' && wave === 'WAVE') {
50
+ return { ext: 'wav', mimeType: 'audio/wav' };
51
+ }
52
+ }
53
+ if (data.length >= 4) {
54
+ const magic = data.subarray(0, 4).toString('ascii');
55
+ if (magic === 'fLaC')
56
+ return { ext: 'flac', mimeType: 'audio/flac' };
57
+ if (magic === 'OggS')
58
+ return { ext: 'ogg', mimeType: 'audio/ogg' };
59
+ }
60
+ return { ext: 'pcm', mimeType: 'audio/pcm' };
61
+ }
62
+ export function wrapPcmInWav(pcmData) {
63
+ return Buffer.concat([createWavHeader(pcmData.length), pcmData]);
64
+ }
65
+ /** Strip existing extension (if any) and append a new one. */
66
+ export function replaceExtension(filePath, newExt) {
67
+ const current = extname(filePath);
68
+ const base = current ? filePath.slice(0, -current.length) : filePath;
69
+ return `${base}.${newExt}`;
70
+ }
71
+ export async function handleGenerateAudio(request, openai) {
72
+ const { prompt, model, voice, format, save_path } = request.params.arguments;
73
+ if (!prompt?.trim()) {
74
+ return { content: [{ type: 'text', text: 'Prompt is required.' }], isError: true };
75
+ }
76
+ const selectedFormat = VALID_FORMATS.includes(format ?? '')
77
+ ? format
78
+ : DEFAULT_FORMAT;
79
+ const selectedVoice = voice?.trim() || DEFAULT_VOICE;
80
+ try {
81
+ const stream = await openai.chat.completions.create({
82
+ model: model || DEFAULT_MODEL,
83
+ messages: [{ role: 'user', content: prompt }],
84
+ modalities: ['text', 'audio'],
85
+ audio: { voice: selectedVoice, format: selectedFormat },
86
+ stream: true,
87
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
88
+ });
89
+ const audioChunks = [];
90
+ const transcriptChunks = [];
91
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
92
+ for await (const chunk of stream) {
93
+ const delta = chunk.choices?.[0]?.delta;
94
+ if (delta?.audio) {
95
+ if (delta.audio.data)
96
+ audioChunks.push(delta.audio.data);
97
+ if (delta.audio.transcript)
98
+ transcriptChunks.push(delta.audio.transcript);
99
+ }
100
+ }
101
+ const fullAudioBase64 = audioChunks.join('');
102
+ const transcript = transcriptChunks.join('');
103
+ if (!fullAudioBase64) {
104
+ return { content: [{ type: 'text', text: transcript || 'No audio generated.' }] };
105
+ }
106
+ let audioBuffer = Buffer.from(fullAudioBase64, 'base64');
107
+ const detected = detectAudioFormat(audioBuffer);
108
+ // Always wrap raw PCM in WAV so it's playable
109
+ if (detected.ext === 'pcm') {
110
+ audioBuffer = wrapPcmInWav(audioBuffer);
111
+ detected.ext = 'wav';
112
+ detected.mimeType = 'audio/wav';
113
+ }
114
+ const returnBase64 = audioBuffer.toString('base64');
115
+ if (save_path) {
116
+ const dir = dirname(save_path);
117
+ await fs.mkdir(dir, { recursive: true });
118
+ const fileExt = extname(save_path).toLowerCase().slice(1);
119
+ const actualSavePath = fileExt === detected.ext ? save_path : replaceExtension(save_path, detected.ext);
120
+ await fs.writeFile(actualSavePath, audioBuffer);
121
+ const formatNote = actualSavePath !== save_path
122
+ ? ` (detected ${detected.ext.toUpperCase()}, saved as ${actualSavePath})`
123
+ : '';
124
+ const result = transcript
125
+ ? `Audio saved to: ${actualSavePath}${formatNote}\nTranscript: ${transcript}`
126
+ : `Audio saved to: ${actualSavePath}${formatNote}`;
127
+ return {
128
+ content: [
129
+ { type: 'text', text: result },
130
+ { type: 'audio', mimeType: detected.mimeType, data: returnBase64 },
131
+ ],
132
+ };
133
+ }
134
+ return {
135
+ content: [
136
+ { type: 'text', text: transcript || 'Audio generated successfully.' },
137
+ { type: 'audio', mimeType: detected.mimeType, data: returnBase64 },
138
+ ],
139
+ };
140
+ }
141
+ catch (error) {
142
+ let msg;
143
+ if (error instanceof Error) {
144
+ msg = error.message;
145
+ const oaiErr = error;
146
+ if (oaiErr.error?.message)
147
+ msg = `${msg} - ${oaiErr.error.message}`;
148
+ }
149
+ else {
150
+ msg = String(error);
151
+ }
152
+ return { content: [{ type: 'text', text: `Error: ${msg}` }], isError: true };
153
+ }
154
+ }
@@ -1,10 +1,9 @@
1
+ import { isBlockedIPv4 as _isBlockedIPv4, assertUrlSafeForFetch as _assertUrlSafeForFetch } from './fetch-utils.js';
2
+ export declare const isBlockedIPv4: typeof _isBlockedIPv4;
3
+ export declare const assertUrlSafeForFetch: typeof _assertUrlSafeForFetch;
1
4
  export declare function getMaxImageDimension(): number;
2
5
  export declare function getImageJpegQuality(): number;
3
6
  export declare function getMimeType(filePath: string): string;
4
- /** Blocks RFC1918, loopback, link-local, CGNAT, metadata (e.g. 169.254.169.254). */
5
- export declare function isBlockedIPv4(ip: string): boolean;
6
- /** Resolve hostname and ensure the resolved address is not private/link-local. */
7
- export declare function assertUrlSafeForFetch(urlString: string): Promise<URL>;
8
7
  export declare function fetchHttpImage(urlString: string): Promise<Buffer>;
9
8
  export declare function fetchImage(source: string): Promise<Buffer>;
10
9
  export declare function optimizeImage(buffer: Buffer): Promise<string>;
@@ -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": "1.9.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",