@stabgan/openrouter-mcp-multimodal 1.8.2 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +69 -27
- package/dist/__tests__/audio-utils.test.d.ts +1 -0
- package/dist/__tests__/audio-utils.test.js +120 -0
- package/dist/__tests__/fetch-utils.test.d.ts +1 -0
- package/dist/__tests__/fetch-utils.test.js +76 -0
- package/dist/__tests__/generate-audio.test.d.ts +1 -0
- package/dist/__tests__/generate-audio.test.js +90 -0
- package/dist/__tests__/integration.test.js +108 -0
- package/dist/index.js +2 -0
- package/dist/tool-handlers/analyze-audio.d.ts +23 -0
- package/dist/tool-handlers/analyze-audio.js +34 -0
- package/dist/tool-handlers/audio-utils.d.ts +19 -0
- package/dist/tool-handlers/audio-utils.js +128 -0
- package/dist/tool-handlers/fetch-utils.d.ts +18 -0
- package/dist/tool-handlers/fetch-utils.js +161 -0
- package/dist/tool-handlers/generate-audio.d.ts +45 -0
- package/dist/tool-handlers/generate-audio.js +154 -0
- package/dist/tool-handlers/image-utils.d.ts +3 -4
- package/dist/tool-handlers/image-utils.js +10 -153
- package/dist/tool-handlers.js +36 -0
- package/package.json +7 -4
package/README.md
CHANGED
|
@@ -7,9 +7,22 @@
|
|
|
7
7
|
[](https://github.com/stabgan/openrouter-mcp-multimodal/actions/workflows/publish.yml)
|
|
8
8
|
[](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** | [](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
|
-
| `
|
|
34
|
-
| `
|
|
35
|
-
| `
|
|
36
|
-
| `
|
|
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
|
-
"
|
|
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
|
-
├──
|
|
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
|
|
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
|
|
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
|
+
}>;
|