@audiolabtools/mcp-server 0.1.1 → 0.3.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.
Files changed (3) hide show
  1. package/README.md +32 -23
  2. package/hosted-server.mjs +227 -62
  3. package/package.json +43 -43
package/README.md CHANGED
@@ -3,7 +3,8 @@
3
3
  MCP ([Model Context Protocol](https://modelcontextprotocol.io)) server that gives any
4
4
  MCP-capable AI — Claude Desktop, Claude Code, Cursor, and others — eight audio-analysis
5
5
  tools, backed by the hosted **AudioLab** API. It is a thin HTTP client: **no local audio
6
- engine, no ffmpeg, nothing to compile.**
6
+ engine, no ffmpeg, nothing to compile.** It can analyse a **public URL** or a **local file**
7
+ on your machine.
7
8
 
8
9
  ## Install
9
10
 
@@ -30,25 +31,30 @@ Get a key: sign in at **https://audiolab.tools/account** and generate one (free
30
31
 
31
32
  ## Tools
32
33
 
33
- Every tool takes a **public https URL** to the audio; the API fetches and analyses it
34
- server-side and returns numbers only (nothing is uploaded from your machine).
35
-
36
- | Tool | Input | Returns |
37
- |---|---|---|
38
- | `analyze_loudness` | `{ url }` | Integrated LUFS (EBU R128 / BS.1770-4), true-peak (dBTP), LRA, crest factor, stereo correlation, mono compatibility, tonal balance |
39
- | `check_target` | `{ url, target, lufs?, tp? }` | Pass/fail vs a delivery target (`spotify` / `apple-music` / `youtube` / `tidal` / `amazon-music` / `podcast` / `ebu-broadcast` / `atsc-broadcast`, or `target:"custom"` + `lufs`+`tp`), with per-metric deltas and an ffmpeg loudnorm fix command |
40
- | `analyze_timeseries` | `{ url, waveformPoints? }` | Short-term LUFS over time + downsampled waveform peaks (for graphs/meters) |
41
- | `get_spectrum` | `{ url }` | FFT magnitude data + 7-band energies + dominant band |
42
- | `analyze_voice` | `{ url }` | Voice QA: speech/silence ratio, speaking rate, SNR, noise floor, room echo, sibilance & clipping risk |
43
- | `get_speech_segments` | `{ url }` | Voiced regions with start/end + per-segment RMS (auto-trim, chapters) |
44
- | `index_signal` | `{ url }` | Content-type guess, tags, clipping/silence regions, brightness & dynamics buckets |
45
- | `compare_loudness` | `{ urlA, urlB }` | Runs loudness on both and returns both results for an A/B comparison |
34
+ Every tool takes **one audio source** — a public `url` **or** a local `path`:
35
+
36
+ - `{ url: "https://…" }` — a public https URL the API fetches server-side.
37
+ - `{ path: "./mix.wav" }` — a file on the machine running this server. Files up to **4 MB**
38
+ are sent inline; larger files (up to **50 MB**) upload over a one-shot signed URL, are
39
+ analysed, and are then deleted. *(Local `path` works only in this stdio server, not the
40
+ remote `/mcp` endpoint.)*
41
+
42
+ | Tool | Returns |
43
+ |---|---|
44
+ | `analyze_loudness` | Integrated LUFS (EBU R128 / BS.1770-4), true-peak (dBTP), LRA, crest factor, stereo correlation, mono compatibility, tonal balance |
45
+ | `check_target` | Pass/fail vs a delivery target (`spotify` / `apple-music` / `youtube` / `tidal` / `amazon-music` / `podcast` / `ebu-broadcast` / `atsc-broadcast`, or `target:"custom"` + `lufs`+`tp`), with per-metric deltas and an ffmpeg loudnorm fix command |
46
+ | `analyze_timeseries` | Short-term LUFS over time + downsampled waveform peaks (`waveformPoints?`) |
47
+ | `get_spectrum` | FFT magnitude data + 7-band energies + dominant band |
48
+ | `analyze_voice` | Voice QA: speech/silence ratio, speaking rate, SNR, noise floor, room echo, sibilance & clipping risk |
49
+ | `get_speech_segments` | Voiced regions with start/end + per-segment RMS (auto-trim, chapters) |
50
+ | `index_signal` | Content-type guess, tags, clipping/silence regions, brightness & dynamics buckets |
51
+ | `compare_loudness` | A/B on two sources (`urlA`/`pathA` + `urlB`/`pathB`), returns both results |
46
52
 
47
53
  Example asks to your AI:
48
54
 
49
- - *“Analyze the loudness of https://example.com/track.wav”* → `analyze_loudness`
50
- - *“Does https://example.com/mix.wav pass Spotify?”* → `check_target` with `target:"spotify"`
51
- - *“A/B-compare masterA.wav vs masterB.wav for loudness”* → `compare_loudness`
55
+ - *“Analyze the loudness of https://example.com/track.wav”* → `analyze_loudness` with `url`
56
+ - *“Run loudness on ./master.wav”* → `analyze_loudness` with `path`
57
+ - *“Does ./mix.mp3 pass Spotify?”* → `check_target` with `path` + `target:"spotify"`
52
58
 
53
59
  ## Configuration (env)
54
60
 
@@ -60,20 +66,23 @@ Example asks to your AI:
60
66
 
61
67
  ## Privacy
62
68
 
63
- The tools send the **audio URL** and your **API key** to the AudioLab API (`audiolab.tools`),
64
- which fetches and analyses the audio server-side and returns numbers only — your audio is not
65
- stored (see https://audiolab.tools/privacy). This package has no telemetry and writes nothing
66
- to disk.
69
+ Analysis happens on the AudioLab API, so the audio **does reach `audiolab.tools`** — a `url`
70
+ is fetched server-side, and a local `path` is sent to the API (small files inline; larger
71
+ files via a private one-shot signed upload that is deleted right after analysis). The API
72
+ returns **numbers only** and does not retain your audio (see https://audiolab.tools/privacy).
73
+ This package has no telemetry and writes nothing to disk. If audio must never leave the
74
+ machine, don't use a hosted analyser.
67
75
 
68
76
  ## Limits
69
77
 
78
+ - Local files: up to **50 MB** (host bigger ones at a public URL).
70
79
  - One file per call (agents loop for many); one-shot (no streaming/realtime).
71
- - File-size and rate limits are enforced by the API, per key.
80
+ - Rate and monthly limits are enforced by the API, per key.
72
81
 
73
82
  ## Smoke test
74
83
 
75
84
  ```sh
76
- node hosted-server.mjs --selftest # verifies the 8 tools register + the missing-key guard; no network
85
+ node hosted-server.mjs --selftest # verifies the 9 tools + guards; no network
77
86
  ```
78
87
 
79
88
  ## License
package/hosted-server.mjs CHANGED
@@ -1,14 +1,18 @@
1
1
  #!/usr/bin/env node
2
- // AudioLab HOSTED-mode MCP server. Exposes the 8 AudioLab tools to any MCP-capable
2
+ // AudioLab HOSTED-mode MCP server. Exposes the 9 AudioLab tools to any MCP-capable
3
3
  // AI (Claude Desktop / Claude Code, Cursor, etc.) by calling the hosted API at
4
- // audiolab.tools/v1/*. Unlike mcp/server.mjs it contains NO engine code, just HTTP
5
- // calls, so it is safe to distribute publicly without exposing the proprietary engine.
4
+ // audiolab.tools/v1/*. It contains NO engine code, just HTTP calls, so it is safe to
5
+ // distribute publicly without exposing the proprietary engine.
6
6
  //
7
7
  // Two consumers share buildServer():
8
8
  // 1. This file run directly = a stdio server (the npm package @audiolabtools/mcp-server),
9
- // reading the key from AUDIOLAB_API_KEY.
9
+ // reading the key from AUDIOLAB_API_KEY. Started with { local: true } — so it can also
10
+ // analyse a LOCAL file via `{ path }` (small files POST raw; larger files are PUT to
11
+ // storage via a signed URL and analysed by objectPath). Nothing is exposed publicly.
10
12
  // 2. api/mcp.mjs = the remote streamable-HTTP endpoint at /mcp, which passes the
11
- // per-request Bearer key via buildServer({ apiKey }).
13
+ // per-request Bearer key via buildServer({ apiKey }). local defaults to FALSE there —
14
+ // a remote server must NEVER read a path off its own filesystem, so `{ path }` is
15
+ // rejected and only `{ url }` is accepted.
12
16
  //
13
17
  // Requires an API key (self-serve: sign in at https://audiolab.tools/account and generate one).
14
18
  // Config in Claude Desktop's claude_desktop_config.json:
@@ -17,13 +21,21 @@
17
21
  // "env": { "AUDIOLAB_API_KEY": "al_live_yourkey" } } } }
18
22
  //
19
23
  // Env: AUDIOLAB_API_KEY (required at call time for stdio mode), AUDIOLAB_API_BASE
20
- // (default https://audiolab.tools/v1). Smoke test (no network): node mcp/hosted-server.mjs --selftest
24
+ // (default https://audiolab.tools/v1, must be https), AUDIOLAB_TIMEOUT_MS (default 60000).
25
+ // Smoke test (no network): node hosted-server.mjs --selftest
21
26
 
22
27
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
23
28
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
29
+ import { readFile } from 'node:fs/promises';
30
+ import { basename } from 'node:path';
24
31
  import { pathToFileURL } from 'node:url';
25
32
  import { z } from 'zod';
26
33
 
34
+ // Vercel serverless caps a raw request body at ~4.5 MB, so small files POST directly and
35
+ // larger ones go through the signed-URL storage flow. The bucket policy caps at 50 MB.
36
+ const RAW_MAX = 4 * 1024 * 1024;
37
+ const STORAGE_MAX = 50 * 1024 * 1024;
38
+
27
39
  // Read env at CALL time (not module load) so the key can be injected by the MCP host
28
40
  // and so the missing-key guard is testable.
29
41
  const apiBase = () => {
@@ -33,102 +45,246 @@ const apiBase = () => {
33
45
  return base;
34
46
  };
35
47
 
36
- // key defaults to the env var (stdio mode); the remote endpoint passes a per-request key.
37
- export async function call(route, body, key = process.env.AUDIOLAB_API_KEY) {
38
- if (!key) throw new Error('AUDIOLAB_API_KEY is not set. Get a key at https://audiolab.tools/account.');
39
- // Time-box the request so a hung/slow API can't stall the calling agent forever.
40
- const timeoutMs = Number(process.env.AUDIOLAB_TIMEOUT_MS) || 60_000;
48
+ const timeoutMs = () => Number(process.env.AUDIOLAB_TIMEOUT_MS) || 60_000;
49
+
50
+ function requireKey(key) {
51
+ const k = key || process.env.AUDIOLAB_API_KEY;
52
+ if (!k) throw new Error('AUDIOLAB_API_KEY is not set. Get a key at https://audiolab.tools/account.');
53
+ return k;
54
+ }
55
+
56
+ // Guess an audio content-type from a filename extension.
57
+ const CT_BY_EXT = { mp3: 'audio/mpeg', wav: 'audio/wav', flac: 'audio/flac', m4a: 'audio/mp4', mp4: 'audio/mp4', aac: 'audio/aac', ogg: 'audio/ogg', oga: 'audio/ogg', opus: 'audio/opus', webm: 'audio/webm', aiff: 'audio/aiff', aif: 'audio/aiff' };
58
+ const ctForPath = (p) => CT_BY_EXT[String(p).split('.').pop()?.toLowerCase()] || 'application/octet-stream';
59
+
60
+ // Low-level JSON request with a hard timeout. Never echoes an arbitrary upstream body.
61
+ async function request(url, { method = 'POST', headers = {}, body } = {}) {
62
+ const ms = timeoutMs();
41
63
  let res;
42
64
  try {
43
- res = await fetch(`${apiBase()}/${route}`, {
44
- method: 'POST',
45
- headers: { 'content-type': 'application/json', authorization: `Bearer ${key}` },
46
- body: JSON.stringify(body),
47
- signal: AbortSignal.timeout(timeoutMs),
48
- });
65
+ res = await fetch(url, { method, headers, body, signal: AbortSignal.timeout(ms) });
49
66
  } catch (e) {
50
- if (e?.name === 'TimeoutError') throw new Error(`AudioLab API timed out after ${timeoutMs} ms.`);
67
+ if (e?.name === 'TimeoutError') throw new Error(`AudioLab API timed out after ${ms} ms.`);
51
68
  throw new Error(`Could not reach the AudioLab API: ${String(e?.message || e)}`);
52
69
  }
53
70
  const text = await res.text();
54
71
  let json;
55
- // Parse JSON only; never echo an arbitrary upstream body back to the agent.
56
72
  try { json = JSON.parse(text); }
57
73
  catch { throw new Error(res.ok ? 'AudioLab API returned a non-JSON response.' : `AudioLab API error ${res.status}.`); }
58
74
  if (!res.ok) throw new Error(String(json?.error?.message || json?.error || `API returned ${res.status}`));
59
75
  return json;
60
76
  }
61
77
 
78
+ // key defaults to the env var (stdio mode); the remote endpoint passes a per-request key.
79
+ export async function call(route, body, key = process.env.AUDIOLAB_API_KEY) {
80
+ const k = requireKey(key);
81
+ return request(`${apiBase()}/${route}`, {
82
+ headers: { 'content-type': 'application/json', authorization: `Bearer ${k}` },
83
+ body: JSON.stringify(body),
84
+ });
85
+ }
86
+
87
+ // Small local file → raw audio body straight to /v1 (fast, no storage round-trip).
88
+ // Tool extras (target/lufs/tp/waveformPoints/fileName) ride along as query params.
89
+ async function postRaw(route, buf, ct, extra, key) {
90
+ const k = requireKey(key);
91
+ const qs = new URLSearchParams();
92
+ for (const [name, v] of Object.entries(extra || {})) if (v !== undefined && v !== null && v !== '') qs.set(name, String(v));
93
+ const q = qs.toString();
94
+ return request(`${apiBase()}/${route}${q ? `?${q}` : ''}`, {
95
+ headers: { 'content-type': ct, authorization: `Bearer ${k}` },
96
+ body: buf,
97
+ });
98
+ }
99
+
100
+ // Mint a signed upload URL and PUT the bytes to storage; returns the objectPath.
101
+ // The objectPath is namespaced to this key server-side; only this key can read it.
102
+ async function uploadToStorage(buf, ct, key) {
103
+ const k = requireKey(key);
104
+ const origin = new URL(apiBase()).origin;
105
+ const mint = await request(`${origin}/api/upload-url`, {
106
+ headers: { 'content-type': 'application/json', authorization: `Bearer ${k}` },
107
+ body: JSON.stringify({ contentType: ct, size: buf.length }),
108
+ });
109
+ if (!mint?.signedUrl || !mint?.objectPath) throw new Error('Could not prepare the upload.');
110
+ let put;
111
+ try {
112
+ put = await fetch(mint.signedUrl, { method: 'PUT', headers: { 'content-type': ct }, body: buf, signal: AbortSignal.timeout(timeoutMs()) });
113
+ } catch (e) {
114
+ if (e?.name === 'TimeoutError') throw new Error(`Upload timed out after ${timeoutMs()} ms.`);
115
+ throw new Error(`Upload to storage failed: ${String(e?.message || e)}`);
116
+ }
117
+ if (!put.ok) throw new Error(`Upload to storage failed (HTTP ${put.status}).`);
118
+ return mint.objectPath;
119
+ }
120
+
121
+ // Larger local file → upload to storage, then analyse by objectPath.
122
+ async function postUpload(route, buf, ct, fileName, extra, key) {
123
+ const objectPath = await uploadToStorage(buf, ct, key);
124
+ return call(route, { objectPath, fileName, ...extra }, key);
125
+ }
126
+
127
+ // Read + size-check one local file for upload flows.
128
+ async function readLocal(p) {
129
+ let buf;
130
+ try { buf = await readFile(p); }
131
+ catch (e) { throw new Error(`Could not read local file "${p}": ${e?.code || e?.message || e}`); }
132
+ if (!buf.length) throw new Error(`Local file is empty: "${p}".`);
133
+ if (buf.length > STORAGE_MAX) throw new Error(`"${p}" is ${(buf.length / 1048576).toFixed(1)} MB; max ${STORAGE_MAX / 1048576} MB per file. Host it at a public https URL instead.`);
134
+ return buf;
135
+ }
136
+
137
+ // Batch: one analysis route over many sources in a single /v1/batch call.
138
+ // Local paths are uploaded to storage first (one-shot signed URLs), then the
139
+ // whole set goes out as ONE call with urls + objectPaths. Max 20 combined,
140
+ // matching the server-side cap; each item is metered as one call server-side.
141
+ const BATCH_MAX = 20;
142
+ async function batchSources({ urls = [], paths = [] }, endpoint, extra, key, local = false) {
143
+ if (paths.length && !local) throw new Error('Local file paths are only supported by the local (stdio) MCP server; use public https urls instead.');
144
+ const total = urls.length + paths.length;
145
+ if (!total) throw new Error('Provide urls (public https) and/or paths (local files).');
146
+ if (total > BATCH_MAX) throw new Error(`Batch too large: max ${BATCH_MAX} items per call. Split into smaller batches.`);
147
+ const objectPaths = [];
148
+ for (const p of paths) {
149
+ const buf = await readLocal(p);
150
+ objectPaths.push(await uploadToStorage(buf, ctForPath(p), key));
151
+ }
152
+ const args = Object.fromEntries(Object.entries(extra || {}).filter(([, v]) => v !== undefined && v !== null && v !== ''));
153
+ const body = { endpoint };
154
+ if (urls.length) body.urls = urls;
155
+ if (objectPaths.length) body.objectPaths = objectPaths;
156
+ if (Object.keys(args).length) body.args = args;
157
+ return call('batch', body, key);
158
+ }
159
+
160
+ // Resolve one audio source (url OR local path) to a route call. `local` gates path reads:
161
+ // the remote /mcp endpoint passes local=false and must NEVER touch the server filesystem.
162
+ async function analyzeSource(route, { url, path } = {}, extra = {}, key, local = false) {
163
+ if (url && path) throw new Error('Provide exactly one of url or path, not both.');
164
+ if (path) {
165
+ if (!local) throw new Error('Local file paths are only supported by the local (stdio) MCP server; use a public https url instead.');
166
+ let buf;
167
+ try { buf = await readFile(path); }
168
+ catch (e) { throw new Error(`Could not read local file "${path}": ${e?.code || e?.message || e}`); }
169
+ if (!buf.length) throw new Error('Local file is empty.');
170
+ if (buf.length > STORAGE_MAX) throw new Error(`Local file is ${(buf.length / 1048576).toFixed(1)} MB; max ${STORAGE_MAX / 1048576} MB. Host it at a public https URL for larger files.`);
171
+ const name = basename(path);
172
+ const ct = ctForPath(path);
173
+ return buf.length <= RAW_MAX
174
+ ? postRaw(route, buf, ct, { ...extra, fileName: name }, key) // small → raw POST (no storage)
175
+ : postUpload(route, buf, ct, name, extra, key); // large → signed-URL upload
176
+ }
177
+ if (url) return call(route, { url, ...extra }, key);
178
+ throw new Error('Provide a url (public https) or a path (local file).');
179
+ }
180
+
62
181
  const asContent = (obj) => ({ content: [{ type: 'text', text: JSON.stringify(obj) }] });
63
182
  const wrap = (fn) => async (input) => {
64
183
  try { return asContent(await fn(input)); }
65
184
  catch (e) { return { isError: true, content: [{ type: 'text', text: String(e?.message || e) }] }; }
66
185
  };
67
186
 
68
- const URL_IN = { url: z.string().url().describe('Public https URL to the audio file.') };
187
+ // Input source shape. Remote (url-only) keeps the original required url; local adds an
188
+ // optional path (exactly one of the two). Local-file support only exists in stdio mode.
189
+ const sourceShape = (local) => local
190
+ ? {
191
+ url: z.string().url().optional().describe('Public https URL to the audio file. Provide exactly one of url or path.'),
192
+ path: z.string().optional().describe('Path to a LOCAL audio file on this machine — analysed without hosting it publicly (files up to 4 MB are sent inline; larger ones up to 50 MB upload over a one-shot signed URL). Provide exactly one of url or path.'),
193
+ }
194
+ : { url: z.string().url().describe('Public https URL to the audio file.') };
69
195
 
70
- export function buildServer({ apiKey } = {}) {
71
- const server = new McpServer({ name: 'audiolab', version: '0.1.1' }); // keep in sync with package.json
196
+ export function buildServer({ apiKey, local = false } = {}) {
197
+ const server = new McpServer({ name: 'audiolab', version: '0.3.0' }); // keep in sync with package.json
72
198
  const toolNames = [];
73
199
  const tool = (name, def, handler) => { server.registerTool(name, def, handler); toolNames.push(name); };
74
- // Per-request API key threaded from the transport, so we never race on process.env
75
- // when several requests share one warm serverless instance.
76
- const api = (route, body) => call(route, body, apiKey);
200
+ const src = sourceShape(local);
201
+ const srcDoc = local ? ' Audio source: a public https URL, or a local file path (`path`).' : ' Audio source: public https URL.';
77
202
 
78
203
  tool('analyze_loudness', {
79
204
  title: 'Analyze loudness (MixLab)',
80
- description: 'Loudness and dynamics for a track: integrated LUFS (EBU R128 / BS.1770-4), true-peak (dBTP), loudness range (LRA), crest factor, stereo correlation, mono compatibility, and tonal balance. Audio source: public https URL.',
81
- inputSchema: URL_IN,
82
- }, wrap(({ url }) => api('mixlab/analyze', { url })));
205
+ description: 'Loudness and dynamics for a track: integrated LUFS (EBU R128 / BS.1770-4), true-peak (dBTP), loudness range (LRA), crest factor, stereo correlation, mono compatibility, and tonal balance.' + srcDoc,
206
+ inputSchema: src,
207
+ }, wrap((i) => analyzeSource('mixlab/analyze', i, {}, apiKey, local)));
83
208
 
84
209
  tool('check_target', {
85
210
  title: 'Check audio against a loudness target (Spotify, EBU, etc.)',
86
- description: 'Verify whether audio hits a delivery target (Spotify -14 LUFS / EBU broadcast -23 / podcast -16 / etc.). Returns pass/fail with per-metric deltas and a concrete ffmpeg loudnorm command to fix if failing. Presets: spotify, apple-music, youtube, tidal, amazon-music, podcast, ebu-broadcast, atsc-broadcast; or target="custom" with lufs+tp.',
87
- inputSchema: { ...URL_IN, target: z.string().describe('Preset id or "custom".'), lufs: z.number().optional(), tp: z.number().optional() },
88
- }, wrap(({ url, target, lufs, tp }) => api('mixlab/check-target', { url, target, lufs, tp })));
211
+ description: 'Verify whether audio hits a delivery target (Spotify -14 LUFS / EBU broadcast -23 / podcast -16 / etc.). Returns pass/fail with per-metric deltas and a concrete ffmpeg loudnorm command to fix if failing. Presets: spotify, apple-music, youtube, tidal, amazon-music, podcast, ebu-broadcast, atsc-broadcast; or target="custom" with lufs+tp.' + srcDoc,
212
+ inputSchema: { ...src, target: z.string().describe('Preset id or "custom".'), lufs: z.number().optional(), tp: z.number().optional() },
213
+ }, wrap((i) => analyzeSource('mixlab/check-target', i, { target: i.target, lufs: i.lufs, tp: i.tp }, apiKey, local)));
89
214
 
90
215
  tool('analyze_timeseries', {
91
216
  title: 'Loudness over time (for graphing / visualization)',
92
- description: 'Short-term LUFS samples over time (EBU R128, ~3s window) with their time-base, plus downsampled waveform peaks. Arrays suitable for loudness-over-time charts, level meters, and waveform UIs.',
93
- inputSchema: { ...URL_IN, waveformPoints: z.number().int().positive().optional().describe('Waveform peak count (default 200).') },
94
- }, wrap(({ url, waveformPoints }) => api('mixlab/timeseries', { url, waveformPoints })));
217
+ description: 'Short-term LUFS samples over time (EBU R128, ~3s window) with their time-base, plus downsampled waveform peaks. Arrays suitable for loudness-over-time charts, level meters, and waveform UIs.' + srcDoc,
218
+ inputSchema: { ...src, waveformPoints: z.number().int().positive().optional().describe('Waveform peak count (default 200).') },
219
+ }, wrap((i) => analyzeSource('mixlab/timeseries', i, { waveformPoints: i.waveformPoints }, apiKey, local)));
95
220
 
96
221
  tool('get_spectrum', {
97
222
  title: 'FFT spectrum data',
98
- description: 'Frequency-domain magnitude data (paired frequency/magnitude arrays) plus energies in 7 standard bands (sub/bass/lowMid/mid/highMid/presence/air) and the dominant band. For spectrum-analyzer UIs and tonal-balance analysis.',
99
- inputSchema: URL_IN,
100
- }, wrap(({ url }) => api('mixlab/spectrum', { url })));
223
+ description: 'Frequency-domain magnitude data (paired frequency/magnitude arrays) plus energies in 7 standard bands (sub/bass/lowMid/mid/highMid/presence/air) and the dominant band. For spectrum-analyzer UIs and tonal-balance analysis.' + srcDoc,
224
+ inputSchema: src,
225
+ }, wrap((i) => analyzeSource('mixlab/spectrum', i, {}, apiKey, local)));
101
226
 
102
227
  tool('analyze_voice', {
103
228
  title: 'Analyze voice quality (VoiceLab)',
104
- description: 'Speech-quality QA for a voice recording: speech/silence ratio, speaking-rate label, signal-to-noise, noise floor, room-echo label, sibilance risk, clipping severity. Gates a voice take.',
105
- inputSchema: URL_IN,
106
- }, wrap(({ url }) => api('voicelab/qa', { url })));
229
+ description: 'Speech-quality QA for a voice recording: speech/silence ratio, speaking-rate label, signal-to-noise, noise floor, room-echo label, sibilance risk, clipping severity. Gates a voice take.' + srcDoc,
230
+ inputSchema: src,
231
+ }, wrap((i) => analyzeSource('voicelab/qa', i, {}, apiKey, local)));
107
232
 
108
233
  tool('get_speech_segments', {
109
234
  title: 'Speech segments (VoiceLab)',
110
- description: 'List voiced speech regions with start/end timestamps and per-segment RMS. For auto-trim, chapter generation, and speaker-turn detection. RMS-based voice-activity detection, not speaker diarization.',
111
- inputSchema: URL_IN,
112
- }, wrap(({ url }) => api('voicelab/segments', { url })));
235
+ description: 'List voiced speech regions with start/end timestamps and per-segment RMS. For auto-trim, chapter generation, and speaker-turn detection. RMS-based voice-activity detection, not speaker diarization.' + srcDoc,
236
+ inputSchema: src,
237
+ }, wrap((i) => analyzeSource('voicelab/segments', i, {}, apiKey, local)));
113
238
 
114
239
  tool('index_signal', {
115
240
  title: 'Index a signal (SignalLab)',
116
- description: 'A metadata index for any audio file: content-type guess (voice/music/mixed/noise/silence) with confidence, brightness & dynamics buckets, dominant band, clipping/silence regions, and tag suggestions. For triage or auto-tagging a library.',
117
- inputSchema: URL_IN,
118
- }, wrap(({ url }) => api('signallab/index', { url })));
241
+ description: 'A metadata index for any audio file: content-type guess (voice/music/mixed/noise/silence) with confidence, brightness & dynamics buckets, dominant band, clipping/silence regions, and tag suggestions. For triage or auto-tagging a library.' + srcDoc,
242
+ inputSchema: src,
243
+ }, wrap((i) => analyzeSource('signallab/index', i, {}, apiKey, local)));
119
244
 
245
+ // compare_loudness — two independent sources, each a url or (local only) a path.
246
+ const cmp = local
247
+ ? {
248
+ urlA: z.string().url().optional().describe('First source URL (e.g. master). Provide urlA or pathA.'),
249
+ pathA: z.string().optional().describe('First source as a local file path.'),
250
+ urlB: z.string().url().optional().describe('Second source URL (e.g. reference). Provide urlB or pathB.'),
251
+ pathB: z.string().optional().describe('Second source as a local file path.'),
252
+ }
253
+ : {
254
+ urlA: z.string().url().describe('First source (e.g. master).'),
255
+ urlB: z.string().url().describe('Second source (e.g. reference).'),
256
+ };
120
257
  tool('compare_loudness', {
121
258
  title: 'Compare two tracks (A/B)',
122
- description: 'Run loudness analysis on TWO URLs and return both results for an A/B comparison: master vs reference, before vs after a fix, two encoders, two cuts.',
123
- inputSchema: {
124
- urlA: z.string().url().describe('First source (e.g. master).'),
125
- urlB: z.string().url().describe('Second source (e.g. reference).'),
126
- },
127
- }, wrap(async ({ urlA, urlB }) => {
128
- const [a, b] = await Promise.all([api('mixlab/analyze', { url: urlA }), api('mixlab/analyze', { url: urlB })]);
259
+ description: 'Run loudness analysis on TWO sources and return both results for an A/B comparison: master vs reference, before vs after a fix, two encoders, two cuts.' + srcDoc,
260
+ inputSchema: cmp,
261
+ }, wrap(async (i) => {
262
+ const [a, b] = await Promise.all([
263
+ analyzeSource('mixlab/analyze', { url: i.urlA, path: i.pathA }, {}, apiKey, local),
264
+ analyzeSource('mixlab/analyze', { url: i.urlB, path: i.pathB }, {}, apiKey, local),
265
+ ]);
129
266
  return { a, b };
130
267
  }));
131
268
 
269
+ // analyze_batch — one route over many sources, a single metered-per-item call.
270
+ const batchSrc = local
271
+ ? {
272
+ urls: z.array(z.string().url()).optional().describe('Public https URLs to audio files. Combined max 20 items with paths.'),
273
+ paths: z.array(z.string()).optional().describe('LOCAL audio file paths; each uploads over a one-shot signed URL, is analysed, then deleted. Combined max 20 items with urls.'),
274
+ }
275
+ : { urls: z.array(z.string().url()).min(1).max(20).describe('Public https URLs to audio files (max 20).') };
276
+ tool('analyze_batch', {
277
+ title: 'Batch: one analysis route over many files',
278
+ description: 'Run one analysis route over up to 20 audio sources in a single call. Returns per-item ok/data/error; each item is metered as one call. Routes: mixlab/analyze (default), mixlab/check-target, mixlab/timeseries, mixlab/spectrum, voicelab/qa, voicelab/segments, signallab/index. Use for folder QA, library indexing, or checking a whole release against a loudness target.' + (local ? ' Sources: public https urls and/or local file paths.' : ' Sources: public https urls.'),
279
+ inputSchema: {
280
+ ...batchSrc,
281
+ endpoint: z.string().optional().describe('Analysis route to run for every item (default mixlab/analyze).'),
282
+ target: z.string().optional().describe('For mixlab/check-target: preset id (spotify, ebu-broadcast, podcast, …) or "custom".'),
283
+ lufs: z.number().optional().describe('For target="custom": integrated LUFS target.'),
284
+ tp: z.number().optional().describe('For target="custom": max true-peak (dBTP).'),
285
+ },
286
+ }, wrap((i) => batchSources({ urls: i.urls || [], paths: i.paths || [] }, i.endpoint || 'mixlab/analyze', { target: i.target, lufs: i.lufs, tp: i.tp }, apiKey, local)));
287
+
132
288
  return { server, toolNames };
133
289
  }
134
290
 
@@ -138,21 +294,30 @@ const isMain = process.argv[1] && import.meta.url === pathToFileURL(process.argv
138
294
 
139
295
  if (isMain && process.argv.includes('--selftest')) {
140
296
  const assert = (await import('node:assert/strict')).default;
141
- const { toolNames } = buildServer();
142
- assert.deepEqual(
143
- toolNames.slice().sort(),
144
- ['analyze_loudness', 'analyze_timeseries', 'analyze_voice', 'check_target', 'compare_loudness', 'get_spectrum', 'get_speech_segments', 'index_signal'],
145
- 'eight hosted tools registered',
146
- );
297
+ const expected = ['analyze_batch', 'analyze_loudness', 'analyze_timeseries', 'analyze_voice', 'check_target', 'compare_loudness', 'get_spectrum', 'get_speech_segments', 'index_signal'];
298
+ assert.deepEqual(buildServer().toolNames.slice().sort(), expected, 'nine hosted tools (remote / url-only)');
299
+ assert.deepEqual(buildServer({ local: true }).toolNames.slice().sort(), expected, 'nine hosted tools (local / url+path)');
300
+
147
301
  const prev = process.env.AUDIOLAB_API_KEY;
148
302
  delete process.env.AUDIOLAB_API_KEY;
149
303
  await assert.rejects(() => call('mixlab/analyze', { url: 'https://example.com/a.wav' }), /AUDIOLAB_API_KEY/, 'refuses without a key (before any fetch)');
150
- if (prev) process.env.AUDIOLAB_API_KEY = prev;
151
- console.log('selftest ok · 8 hosted tools (API-backed, no engine) + missing-key guard');
304
+
305
+ process.env.AUDIOLAB_API_KEY = 'al_live_selftest';
306
+ // A remote server (local=false) must refuse a path BEFORE any filesystem read.
307
+ await assert.rejects(() => analyzeSource('mixlab/analyze', { path: '/etc/hostname' }, {}, 'al_live_selftest', false), /only supported by the local/, 'remote MCP never reads a server-side path');
308
+ // url + path together is a usage error.
309
+ await assert.rejects(() => analyzeSource('mixlab/analyze', { url: 'https://x/a.wav', path: '/tmp/a.wav' }, {}, 'al_live_selftest', true), /exactly one/, 'url+path together rejected');
310
+ // Batch guards fire BEFORE any network or filesystem work.
311
+ await assert.rejects(() => batchSources({ paths: ['/tmp/a.wav'] }, 'mixlab/analyze', {}, 'al_live_selftest', false), /only supported by the local/, 'remote batch never reads server-side paths');
312
+ await assert.rejects(() => batchSources({ urls: [], paths: [] }, 'mixlab/analyze', {}, 'al_live_selftest', true), /Provide urls/, 'empty batch rejected');
313
+ await assert.rejects(() => batchSources({ urls: Array.from({ length: 21 }, (_, n) => `https://x/${n}.wav`) }, 'mixlab/analyze', {}, 'al_live_selftest', false), /max 20/, 'oversized batch rejected before network');
314
+ if (prev) process.env.AUDIOLAB_API_KEY = prev; else delete process.env.AUDIOLAB_API_KEY;
315
+
316
+ console.log('selftest ok · 9 tools (incl. analyze_batch) + missing-key guard + remote-path refusals + batch guards');
152
317
  process.exit(0);
153
318
  }
154
319
 
155
320
  if (isMain) {
156
- const { server } = buildServer();
321
+ const { server } = buildServer({ local: true });
157
322
  await server.connect(new StdioServerTransport());
158
323
  }
package/package.json CHANGED
@@ -1,43 +1,43 @@
1
- {
2
- "name": "@audiolabtools/mcp-server",
3
- "version": "0.1.1",
4
- "description": "MCP server for AudioLab — loudness (EBU R128 / BS.1770-4), true-peak, voice-quality, and signal analysis for AI agents (Claude, Cursor, any MCP client), via the AudioLab hosted API. No local audio engine required.",
5
- "type": "module",
6
- "bin": {
7
- "audiolabtools-mcp-server": "hosted-server.mjs"
8
- },
9
- "files": ["hosted-server.mjs", "README.md"],
10
- "keywords": [
11
- "mcp",
12
- "model-context-protocol",
13
- "claude",
14
- "audio",
15
- "audio-analysis",
16
- "loudness",
17
- "lufs",
18
- "ebu-r128",
19
- "bs-1770",
20
- "true-peak",
21
- "voice-quality",
22
- "audiolab"
23
- ],
24
- "homepage": "https://audiolab.tools/api",
25
- "bugs": { "url": "https://audiolab.tools/api" },
26
- "repository": {
27
- "type": "git",
28
- "url": "git+https://github.com/Audio-Launch/audiolab-tools.git",
29
- "directory": "mcp"
30
- },
31
- "license": "MIT",
32
- "author": "Nathan Renting",
33
- "publishConfig": {
34
- "access": "public"
35
- },
36
- "engines": {
37
- "node": ">=18"
38
- },
39
- "dependencies": {
40
- "@modelcontextprotocol/sdk": "^1.29.0",
41
- "zod": "^4.4.3"
42
- }
43
- }
1
+ {
2
+ "name": "@audiolabtools/mcp-server",
3
+ "version": "0.3.0",
4
+ "description": "MCP server for AudioLab \u2014 loudness (EBU R128 / BS.1770-4), true-peak, voice-quality, and signal analysis for AI agents (Claude, Cursor, any MCP client), via the AudioLab hosted API. No local audio engine required.",
5
+ "type": "module",
6
+ "bin": {
7
+ "audiolabtools-mcp-server": "hosted-server.mjs"
8
+ },
9
+ "files": [
10
+ "hosted-server.mjs",
11
+ "README.md"
12
+ ],
13
+ "keywords": [
14
+ "mcp",
15
+ "model-context-protocol",
16
+ "claude",
17
+ "audio",
18
+ "audio-analysis",
19
+ "loudness",
20
+ "lufs",
21
+ "ebu-r128",
22
+ "bs-1770",
23
+ "true-peak",
24
+ "voice-quality",
25
+ "audiolab"
26
+ ],
27
+ "homepage": "https://audiolab.tools/api",
28
+ "bugs": {
29
+ "url": "https://audiolab.tools/contact"
30
+ },
31
+ "license": "MIT",
32
+ "author": "Nathan Renting",
33
+ "publishConfig": {
34
+ "access": "public"
35
+ },
36
+ "engines": {
37
+ "node": ">=18"
38
+ },
39
+ "dependencies": {
40
+ "@modelcontextprotocol/sdk": "^1.29.0",
41
+ "zod": "^4.4.3"
42
+ }
43
+ }