@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 CHANGED
@@ -7,9 +7,22 @@
7
7
  [![Build Status](https://github.com/stabgan/openrouter-mcp-multimodal/actions/workflows/publish.yml/badge.svg)](https://github.com/stabgan/openrouter-mcp-multimodal/actions/workflows/publish.yml)
8
8
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
9
9
 
10
- An OpenRouter MCP server with native vision, image generation, and smart image optimization in one package.
10
+ An OpenRouter MCP server with native vision, image generation, audio analysis, audio generation, and smart image optimization in one package.
11
11
 
12
- Access 300+ LLMs through [OpenRouter](https://openrouter.ai) via the [Model Context Protocol](https://modelcontextprotocol.io), with first-class support for multimodal workflows: analyze images, generate images, and chat — using free or paid models.
12
+ Access 300+ LLMs through [OpenRouter](https://openrouter.ai) via the [Model Context Protocol](https://modelcontextprotocol.io), with first-class support for multimodal workflows: analyze images, analyze audio, generate images, generate audio (conversational, speech, or music), and chat — using free or paid models.
13
+
14
+ ## One-Click Install
15
+
16
+ Add this MCP server to your favorite AI tool:
17
+
18
+ | Tool | Install |
19
+ | ---- | ------- |
20
+ | **Kiro** | [![Add to Kiro](https://img.shields.io/badge/Add_to-Kiro-blue?logo=data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIiBzdHJva2U9IndoaXRlIiBzdHJva2Utd2lkdGg9IjIiPjxwYXRoIGQ9Ik0xMiAydjIwTTIgMTJoMjAiLz48L3N2Zz4=)](https://kiro.dev/mcp?url=https://github.com/stabgan/openrouter-mcp-multimodal) |
21
+ | **Claude Desktop** | [Install Guide](#option-1-npx-no-install) — Add to `claude_desktop_config.json` |
22
+ | **Cursor** | [Install Guide](#option-1-npx-no-install) — Add to `.cursor/mcp.json` |
23
+ | **Windsurf** | [Install Guide](#option-1-npx-no-install) — Add to `~/.codeium/windsurf/mcp_config.json` |
24
+ | **Cline** | [Install Guide](#option-1-npx-no-install) — Add via Cline MCP settings |
25
+ | **Smithery** | `npx -y @smithery/cli install @stabgan/openrouter-mcp-multimodal --client claude` |
13
26
 
14
27
  ## Why This One?
15
28
 
@@ -17,6 +30,8 @@ Access 300+ LLMs through [OpenRouter](https://openrouter.ai) via the [Model Cont
17
30
  | ---------------------------- | ----------------------------------------------------------------- |
18
31
  | Text chat with 300+ models | ✅ |
19
32
  | Image analysis (vision) | ✅ Native with sharp optimization |
33
+ | Audio analysis | ✅ Transcription and analysis with base64 encoding |
34
+ | Audio generation | ✅ Conversational audio, speech, and music generation with format auto-detection |
20
35
  | Image generation | ✅ |
21
36
  | Auto image resize & compress | ✅ (configurable; defaults 800px max, JPEG 80%) |
22
37
  | Model search & validation | ✅ |
@@ -26,14 +41,16 @@ Access 300+ LLMs through [OpenRouter](https://openrouter.ai) via the [Model Cont
26
41
 
27
42
  ## Tools
28
43
 
29
- | Tool | Description |
30
- | ----------------- | ------------------------------------------------------------------------------- |
31
- | `chat_completion` | Send messages to any OpenRouter model. Supports text and multimodal content. |
32
- | `analyze_image` | Analyze images from local files, URLs, or data URIs. Auto-optimized with sharp. |
33
- | `generate_image` | Generate images from text prompts. Optionally save to disk. |
34
- | `search_models` | Search/filter models by name, provider, or capabilities (e.g. vision-only). |
35
- | `get_model_info` | Get pricing, context length, and capabilities for any model. |
36
- | `validate_model` | Check if a model ID exists on OpenRouter. |
44
+ | Tool | Description |
45
+ | ----------------- | --------------------------------------------------------------------------------------------------- |
46
+ | `chat_completion` | Send messages to any OpenRouter model. Supports text and multimodal content. |
47
+ | `analyze_image` | Analyze images from local files, URLs, or data URIs. Auto-optimized with sharp. |
48
+ | `analyze_audio` | Analyze/transcribe audio from local files, URLs, or data URIs. Supports WAV, MP3, FLAC, OGG, etc. |
49
+ | `generate_audio` | Generate audio from text using conversational or music generation models. Auto-detects output format. |
50
+ | `generate_image` | Generate images from text prompts. Optionally save to disk. |
51
+ | `search_models` | Search/filter models by name, provider, or capabilities (e.g. vision, audio). |
52
+ | `get_model_info` | Get pricing, context length, and capabilities for any model. |
53
+ | `validate_model` | Check if a model ID exists on OpenRouter. |
37
54
 
38
55
  ## Quick Start
39
56
 
@@ -65,11 +82,8 @@ Get a free API key from [openrouter.ai/keys](https://openrouter.ai/keys).
65
82
  "openrouter": {
66
83
  "command": "docker",
67
84
  "args": [
68
- "run",
69
- "--rm",
70
- "-i",
71
- "-e",
72
- "OPENROUTER_API_KEY=sk-or-v1-...",
85
+ "run", "--rm", "-i",
86
+ "-e", "OPENROUTER_API_KEY=sk-or-v1-...",
73
87
  "stabgan/openrouter-mcp-multimodal:latest"
74
88
  ]
75
89
  }
@@ -118,10 +132,16 @@ npx -y @smithery/cli install @stabgan/openrouter-mcp-multimodal --client claude
118
132
  | `OPENROUTER_IMAGE_MAX_DOWNLOAD_BYTES` | No | `26214400` | Max bytes when downloading an image URL (~25 MB) |
119
133
  | `OPENROUTER_IMAGE_MAX_REDIRECTS` | No | `8` | Max HTTP redirects when fetching an image URL |
120
134
  | `OPENROUTER_IMAGE_MAX_DATA_URL_BYTES` | No | `20971520` | Approx max decoded size for base64 data URLs (~20 MB) |
135
+ | `OPENROUTER_AUDIO_FETCH_TIMEOUT_MS` | No | `30000` | Per-request timeout for audio URLs |
136
+ | `OPENROUTER_AUDIO_MAX_DOWNLOAD_BYTES` | No | `26214400` | Max bytes when downloading an audio URL (~25 MB) |
137
+ | `OPENROUTER_AUDIO_MAX_REDIRECTS` | No | `8` | Max HTTP redirects when fetching an audio URL |
138
+ | `OPENROUTER_AUDIO_MAX_DATA_URL_BYTES` | No | `20971520` | Approx max decoded size for base64 audio data URLs |
121
139
 
122
140
  ### Security notes
123
141
 
124
142
  - **`analyze_image`** can read **local files** the Node process can read and can **fetch HTTP(S) URLs**. URL fetches block private/link-local/reserved IPv4 and IPv6 targets (SSRF mitigation) and cap response size; they are still **server-side** requests—avoid pointing at internal-only hosts you rely on staying private.
143
+ - **`analyze_audio`** can read **local audio files** and **fetch HTTP(S) URLs**. Same SSRF protections apply. Audio is base64-encoded before sending to OpenRouter (handled automatically).
144
+ - **`generate_audio`** can **save audio files** to disk wherever the process has permission. Uses streaming to receive audio chunks. Output format (MP3, WAV, PCM) depends on the model — conversational models return raw PCM16 (auto-wrapped as WAV), music models return MP3. File extension is auto-corrected.
125
145
  - **`generate_image`** `save_path` writes to disk wherever the process has permission; treat prompts and paths like shell input from the MCP client user.
126
146
 
127
147
  ## Usage Examples
@@ -138,6 +158,28 @@ Use chat_completion to explain quantum computing in simple terms.
138
158
  Use analyze_image on /path/to/photo.jpg and tell me what you see.
139
159
  ```
140
160
 
161
+ ### Analyze Audio
162
+
163
+ ```
164
+ Use analyze_audio on /path/to/recording.mp3 with model "google/gemini-2.5-flash" to transcribe it.
165
+ ```
166
+
167
+ ### Generate Conversational Audio
168
+
169
+ ```
170
+ Use generate_audio with prompt "Explain what a neural network is" and voice "alloy", save to ./response.wav
171
+ ```
172
+
173
+ ### Generate Music (Lyria)
174
+
175
+ ```
176
+ Use generate_audio with model "google/lyria-3-clip-preview" and prompt:
177
+ "upbeat jazz piano trio with walking bass and brushed snare"
178
+ Save to ./jazz.wav
179
+ ```
180
+
181
+ The file will be auto-saved as `jazz.mp3` since Lyria returns MP3.
182
+
141
183
  ### Find Vision Models
142
184
 
143
185
  ```
@@ -159,10 +201,14 @@ src/
159
201
  ├── model-cache.ts # In-memory model cache (1hr TTL)
160
202
  ├── openrouter-api.ts # OpenRouter REST client (native fetch)
161
203
  └── tool-handlers/
204
+ ├── fetch-utils.ts # Shared SSRF protection, bounded fetch (used by image + audio)
162
205
  ├── chat-completion.ts # Text & multimodal chat
163
206
  ├── analyze-image.ts # Vision analysis pipeline
207
+ ├── analyze-audio.ts # Audio transcription and analysis
164
208
  ├── generate-image.ts # Image generation
165
- ├── image-utils.ts # Sharp optimization, format detection, fetch
209
+ ├── generate-audio.ts # Audio generation with streaming + format detection
210
+ ├── image-utils.ts # Sharp optimization, format detection
211
+ ├── audio-utils.ts # Audio format detection, base64 encoding
166
212
  ├── search-models.ts # Model search with filtering
167
213
  ├── get-model-info.ts # Model detail lookup
168
214
  └── validate-model.ts # Model existence check
@@ -170,10 +216,12 @@ src/
170
216
 
171
217
  Key design decisions:
172
218
 
173
- - **Native `fetch`** for OpenRouter and image URLs (no axios / node-fetch dependency in this package)
219
+ - **Native `fetch`** for OpenRouter and media URLs (no axios / node-fetch dependency)
220
+ - **Shared security layer** — SSRF protection, IP blocking, bounded fetches in `fetch-utils.ts` (single source of truth for image + audio)
174
221
  - **Lazy sharp loading** — `sharp` is loaded on first image operation, not at startup
175
222
  - **Singleton model cache** — shared across tool handlers with configurable TTL (default 1 hour)
176
- - **Bounded URL fetches** — timeouts, size limits, redirect cap, and blocked private networks for image URLs
223
+ - **Bounded URL fetches** — timeouts, size limits, redirect cap, and blocked private networks
224
+ - **Audio format auto-detection** — magic-byte detection for MP3/WAV/FLAC/OGG, raw PCM auto-wrapped in WAV
177
225
  - **Graceful error handling** — tools return structured errors instead of crashing the server
178
226
  - **Process safety** — uncaught exceptions and unhandled rejections exit the process (no zombie servers)
179
227
 
@@ -191,14 +239,8 @@ npm start
191
239
  ### Run Tests
192
240
 
193
241
  ```bash
194
- npm test
195
- ```
196
-
197
- `npm test` runs **unit tests** only (fast, no API key). With `OPENROUTER_API_KEY` in `.env`, run **`npm run test:integration`** for live OpenRouter tests (slower; may time out on congested networks).
198
-
199
- **npm releases:** This repo’s `publish-npm` job uses [npm trusted publishing](https://docs.npmjs.com/trusted-publishers/) (GitHub Actions OIDC). The package on npmjs.com must list this repository and workflow `publish.yml` under **Settings → Trusted publisher**. No long-lived `NPMJS_TOKEN` is required for publish once that is configured.
200
-
201
- ```bash
242
+ npm test # Unit tests only (fast, no API key needed)
243
+ npm run test:integration # Live API tests (needs OPENROUTER_API_KEY in .env)
202
244
  npm run lint
203
245
  npm run format:check
204
246
  ```
@@ -216,9 +258,9 @@ Multi-stage build: 345MB final image (Alpine + vips runtime only).
216
258
 
217
259
  Works with any MCP client:
218
260
 
261
+ - [Kiro](https://kiro.dev)
219
262
  - [Claude Desktop](https://claude.ai/download)
220
263
  - [Cursor](https://cursor.sh)
221
- - [Kiro](https://kiro.dev)
222
264
  - [Windsurf](https://codeium.com/windsurf)
223
265
  - [Cline](https://github.com/cline/cline)
224
266
  - Any MCP-compatible client
@@ -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
+ }>;