@audiolabtools/mcp-server 0.1.0 → 0.2.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Nathan Renting
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,107 +1,90 @@
1
- # AudioLab MCP server
1
+ # @audiolabtools/mcp-server
2
2
 
3
- Exposes the proprietary AudioLab analysis engine as [Model Context Protocol](https://modelcontextprotocol.io)
4
- tools, so an AI agent (Claude Desktop, Claude Code, or any MCP client) can analyze audio.
3
+ MCP ([Model Context Protocol](https://modelcontextprotocol.io)) server that gives any
4
+ MCP-capable AI — Claude Desktop, Claude Code, Cursor, and others — eight audio-analysis
5
+ tools, backed by the hosted **AudioLab** API. It is a thin HTTP client: **no local audio
6
+ engine, no ffmpeg, nothing to compile.** It can analyse a **public URL** or a **local file**
7
+ on your machine.
5
8
 
6
- Audio sources accepted:
7
- - **Public URL** (`{url: "https://..."}`): fetched and decoded server-side
8
- - **Local file path** (`{path: "./song.wav"}`): read server-side from disk
9
+ ## Install
9
10
 
10
- The MCP server runs on stdio in the user's own process, so local-file access is the
11
- same threat model as a CLI the user installs themselves.
12
-
13
- Nothing is uploaded by the caller in either case. Responses are **curated** JSON
14
- (per-sample waveform/spectrum arrays are dropped so the response is useful to an
15
- LLM, not overwhelming).
16
-
17
- ## Two modes (pick by who runs it)
18
-
19
- | | `mcp/server.mjs` (local) | `mcp/hosted-server.mjs` (hosted) |
20
- |---|---|---|
21
- | Runs | the proprietary engine locally (bundled ffmpeg) | HTTP calls to the hosted API `audiolab.tools/v1/*` |
22
- | Audio source | `{url}` **or** `{path}` (on-device, never leaves the machine) | `{url}` only |
23
- | Needs | the repo + engine (private) | an `AUDIOLAB_API_KEY` |
24
- | Distribute? | **No** (it contains the proprietary engine; keep it repo-only) | **Yes** (no engine code, safe to hand to Claude & other AIs) |
25
-
26
- Give **hosted mode** to Claude Desktop / Claude Code / Cursor / any MCP client:
11
+ Point your MCP client at the package via `npx` (nothing to install globally):
27
12
 
28
13
  ```json
29
14
  {
30
15
  "mcpServers": {
31
16
  "audiolab": {
32
- "command": "node",
33
- "args": ["/abs/path/to/mcp/hosted-server.mjs"],
17
+ "command": "npx",
18
+ "args": ["-y", "@audiolabtools/mcp-server"],
34
19
  "env": { "AUDIOLAB_API_KEY": "al_live_yourkey" }
35
20
  }
36
21
  }
37
22
  }
38
23
  ```
39
24
 
40
- Get a key from partners@audiolab.tools (self-serve opens after the first partners).
41
- Both modes expose the identical eight tools below.
25
+ Get a key: sign in at **https://audiolab.tools/account** and generate one (free tier available).
42
26
 
43
- ## Tools: eight, all single-shot
27
+ ## Requirements
44
28
 
45
- | Tool | Lab | Input | Returns |
46
- |---|---|---|---|
47
- | `analyze_loudness` | MixLab | `{url}` or `{path}` | Integrated LUFS (EBU R128 / BS.1770-4), true-peak, loudness range, crest factor, stereo correlation, tonal balance, harshness/muddiness labels |
48
- | `check_target` | MixLab | `{url\|path, target, lufs?, tp?}` | Pass/fail vs Spotify -14 / Apple-Music -16 / YouTube -14 / TIDAL -14 / Amazon -14 / podcast -16 / EBU-broadcast -23 / ATSC-broadcast -24 (or `"custom"` + lufs+tp). Includes deltas, issue messages, ffmpeg loudnorm fix command |
49
- | `analyze_timeseries` | MixLab | `{url\|path, waveformPoints?}` | Short-term LUFS samples + downsampled waveform peaks over time (for graphing/UI) |
50
- | `get_spectrum` | MixLab | `{url}` or `{path}` | FFT bins (freqs+mags) + 7-band energies + centroid/rolloff/flatness |
51
- | `analyze_voice` | VoiceLab | `{url}` or `{path}` | Speech/silence ratio, speaking rate, SNR, noise floor, room echo, sibilance & clipping risk |
52
- | `get_speech_segments` | VoiceLab | `{url}` or `{path}` | Voiced regions with start/end timestamps + per-segment RMS (for auto-trim, chapters, speaker-turn detection) |
53
- | `index_signal` | SignalLab | `{url}` or `{path}` | Content-type guess, tags, clipping/silence regions, brightness & dynamics buckets, file facts |
54
- | `compare_loudness` | MixLab A/B | `{a: {url\|path}, b: {url\|path}}` | Run MixLab on both, return both results + per-metric deltas with verdict |
29
+ - **Node ≥ 18** — uses the built-in global `fetch` + `AbortSignal.timeout`.
30
+ - An `AUDIOLAB_API_KEY`. No ffmpeg, no native dependencies.
55
31
 
56
- Provide **exactly one** of `url` or `path` per source.
32
+ ## Tools
57
33
 
58
- ## Requirements
34
+ Every tool takes **one audio source** — a public `url` **or** a local `path`:
59
35
 
60
- - Node ≥ 22
61
- - **ffmpeg on `PATH`**: the engine's Node decoder shells out to it.
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.)*
62
41
 
63
- ## Run
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 |
64
52
 
65
- ```sh
66
- npm run mcp # or: node mcp/server.mjs
67
- node mcp/server.mjs --selftest # smoke test (4 tools), no network/ffmpeg needed
68
- ```
53
+ Example asks to your AI:
69
54
 
70
- The server speaks MCP over **stdio** (what desktop clients spawn). Streamable HTTP
71
- for a remote, keyed server is a later transport addition; deploy, auth keys,
72
- and billing are gated steps (see [`docs/PDD.md`](../docs/PDD.md) §0/§8).
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"`
73
58
 
74
- ## Add to Claude Desktop / Claude Code
59
+ ## Configuration (env)
75
60
 
76
- ```json
77
- {
78
- "mcpServers": {
79
- "audiolab": {
80
- "command": "node",
81
- "args": ["E:/New audiolab.dev/mcp/server.mjs"]
82
- }
83
- }
84
- }
85
- ```
86
-
87
- (Once the package ships on npm, this becomes
88
- `{"command": "npx", "args": ["-y", "@audiolab/mcp-server"]}`, gated on publish.)
61
+ | Var | Default | Purpose |
62
+ |---|---|---|
63
+ | `AUDIOLAB_API_KEY` | — (required) | Your API key. |
64
+ | `AUDIOLAB_API_BASE` | `https://audiolab.tools/v1` | Override the API base (must be `https://`). |
65
+ | `AUDIOLAB_TIMEOUT_MS` | `60000` | Per-request timeout in milliseconds. |
89
66
 
90
- Then ask:
67
+ ## Privacy
91
68
 
92
- - *"Analyze the loudness of https://example.com/track.wav"* → calls `analyze_loudness` with `{url}`
93
- - *"Run loudness on ./mix.wav"* → calls `analyze_loudness` with `{path}`
94
- - *"A/B compare ./master.wav against ./reference.wav for loudness"* → calls `compare_loudness`
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.
95
75
 
96
76
  ## Limits
97
77
 
98
- - 200 MB per file (size cap)
99
- - Any sample rate / channel count (engine preserves rate; multi-channel mixed to stereo for analysis)
100
- - One file per call (no batch; agent loops for many)
101
- - One-shot only (no streaming / realtime)
78
+ - Local files: up to **50 MB** (host bigger ones at a public URL).
79
+ - One file per call (agents loop for many); one-shot (no streaming/realtime).
80
+ - Rate and monthly limits are enforced by the API, per key.
81
+
82
+ ## Smoke test
83
+
84
+ ```sh
85
+ node hosted-server.mjs --selftest # verifies the 8 tools + guards; no network
86
+ ```
102
87
 
103
- ## Fastest path to first paying partner
88
+ ## License
104
89
 
105
- Deploy the hosted-API counterpart (`api/v1/[...endpoint].mjs`) + add one key +
106
- invoice them. No Stripe, no key DB, no metering platform required to start.
107
- Metering/credits/self-serve come later, behind the billing gate.
90
+ MIT © Nathan Renting
package/hosted-server.mjs CHANGED
@@ -1,115 +1,229 @@
1
1
  #!/usr/bin/env node
2
2
  // AudioLab HOSTED-mode MCP server. Exposes the 8 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
- // 1. This file run directly = a stdio server (the npm package @audiolab/mcp-server),
9
- // reading the key from AUDIOLAB_API_KEY.
8
+ // 1. This file run directly = a stdio server (the npm package @audiolabtools/mcp-server),
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
- // Requires an API key (the API is live for design partners; email partners@audiolab.tools).
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:
15
19
  // { "mcpServers": { "audiolab": {
16
- // "command": "npx", "args": ["-y", "@audiolab/mcp-server"],
20
+ // "command": "npx", "args": ["-y", "@audiolabtools/mcp-server"],
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
- const apiBase = () => (process.env.AUDIOLAB_API_BASE || 'https://audiolab.tools/v1').replace(/\/$/, '');
41
+ const apiBase = () => {
42
+ const base = (process.env.AUDIOLAB_API_BASE || 'https://audiolab.tools/v1').replace(/\/$/, '');
43
+ // Defense in depth: never send the Bearer key over a non-https (or attacker-injected) base.
44
+ if (!/^https:\/\//i.test(base)) throw new Error('AUDIOLAB_API_BASE must be an https:// URL.');
45
+ return base;
46
+ };
30
47
 
31
- // key defaults to the env var (stdio mode); the remote endpoint passes a per-request key.
32
- export async function call(route, body, key = process.env.AUDIOLAB_API_KEY) {
33
- if (!key) throw new Error('AUDIOLAB_API_KEY is not set. Get a key from partners@audiolab.tools.');
34
- const res = await fetch(`${apiBase()}/${route}`, {
35
- method: 'POST',
36
- headers: { 'content-type': 'application/json', authorization: `Bearer ${key}` },
37
- body: JSON.stringify(body),
38
- });
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();
63
+ let res;
64
+ try {
65
+ res = await fetch(url, { method, headers, body, signal: AbortSignal.timeout(ms) });
66
+ } catch (e) {
67
+ if (e?.name === 'TimeoutError') throw new Error(`AudioLab API timed out after ${ms} ms.`);
68
+ throw new Error(`Could not reach the AudioLab API: ${String(e?.message || e)}`);
69
+ }
39
70
  const text = await res.text();
40
71
  let json;
41
- try { json = JSON.parse(text); } catch { json = { raw: text.slice(0, 500) }; }
72
+ try { json = JSON.parse(text); }
73
+ catch { throw new Error(res.ok ? 'AudioLab API returned a non-JSON response.' : `AudioLab API error ${res.status}.`); }
42
74
  if (!res.ok) throw new Error(String(json?.error?.message || json?.error || `API returned ${res.status}`));
43
75
  return json;
44
76
  }
45
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
+ // Larger local file → mint a signed upload URL, PUT the bytes to storage, then analyse by
101
+ // objectPath. The objectPath is namespaced to this key server-side; only this key can read it.
102
+ async function postUpload(route, buf, ct, fileName, extra, 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 call(route, { objectPath: mint.objectPath, fileName, ...extra }, k);
119
+ }
120
+
121
+ // Resolve one audio source (url OR local path) to a route call. `local` gates path reads:
122
+ // the remote /mcp endpoint passes local=false and must NEVER touch the server filesystem.
123
+ async function analyzeSource(route, { url, path } = {}, extra = {}, key, local = false) {
124
+ if (url && path) throw new Error('Provide exactly one of url or path, not both.');
125
+ if (path) {
126
+ if (!local) throw new Error('Local file paths are only supported by the local (stdio) MCP server; use a public https url instead.');
127
+ let buf;
128
+ try { buf = await readFile(path); }
129
+ catch (e) { throw new Error(`Could not read local file "${path}": ${e?.code || e?.message || e}`); }
130
+ if (!buf.length) throw new Error('Local file is empty.');
131
+ 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.`);
132
+ const name = basename(path);
133
+ const ct = ctForPath(path);
134
+ return buf.length <= RAW_MAX
135
+ ? postRaw(route, buf, ct, { ...extra, fileName: name }, key) // small → raw POST (no storage)
136
+ : postUpload(route, buf, ct, name, extra, key); // large → signed-URL upload
137
+ }
138
+ if (url) return call(route, { url, ...extra }, key);
139
+ throw new Error('Provide a url (public https) or a path (local file).');
140
+ }
141
+
46
142
  const asContent = (obj) => ({ content: [{ type: 'text', text: JSON.stringify(obj) }] });
47
143
  const wrap = (fn) => async (input) => {
48
144
  try { return asContent(await fn(input)); }
49
145
  catch (e) { return { isError: true, content: [{ type: 'text', text: String(e?.message || e) }] }; }
50
146
  };
51
147
 
52
- const URL_IN = { url: z.string().url().describe('Public https URL to the audio file.') };
148
+ // Input source shape. Remote (url-only) keeps the original required url; local adds an
149
+ // optional path (exactly one of the two). Local-file support only exists in stdio mode.
150
+ const sourceShape = (local) => local
151
+ ? {
152
+ url: z.string().url().optional().describe('Public https URL to the audio file. Provide exactly one of url or path.'),
153
+ 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.'),
154
+ }
155
+ : { url: z.string().url().describe('Public https URL to the audio file.') };
53
156
 
54
- export function buildServer({ apiKey } = {}) {
55
- const server = new McpServer({ name: 'audiolab', version: '0.2.0' });
157
+ export function buildServer({ apiKey, local = false } = {}) {
158
+ const server = new McpServer({ name: 'audiolab', version: '0.2.0' }); // keep in sync with package.json
56
159
  const toolNames = [];
57
160
  const tool = (name, def, handler) => { server.registerTool(name, def, handler); toolNames.push(name); };
58
- // Per-request API key threaded from the transport, so we never race on process.env
59
- // when several requests share one warm serverless instance.
60
- const api = (route, body) => call(route, body, apiKey);
161
+ const src = sourceShape(local);
162
+ const srcDoc = local ? ' Audio source: a public https URL, or a local file path (`path`).' : ' Audio source: public https URL.';
61
163
 
62
164
  tool('analyze_loudness', {
63
165
  title: 'Analyze loudness (MixLab)',
64
- 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.',
65
- inputSchema: URL_IN,
66
- }, wrap(({ url }) => api('mixlab/analyze', { url })));
166
+ 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,
167
+ inputSchema: src,
168
+ }, wrap((i) => analyzeSource('mixlab/analyze', i, {}, apiKey, local)));
67
169
 
68
170
  tool('check_target', {
69
171
  title: 'Check audio against a loudness target (Spotify, EBU, etc.)',
70
- 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.',
71
- inputSchema: { ...URL_IN, target: z.string().describe('Preset id or "custom".'), lufs: z.number().optional(), tp: z.number().optional() },
72
- }, wrap(({ url, target, lufs, tp }) => api('mixlab/check-target', { url, target, lufs, tp })));
172
+ 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,
173
+ inputSchema: { ...src, target: z.string().describe('Preset id or "custom".'), lufs: z.number().optional(), tp: z.number().optional() },
174
+ }, wrap((i) => analyzeSource('mixlab/check-target', i, { target: i.target, lufs: i.lufs, tp: i.tp }, apiKey, local)));
73
175
 
74
176
  tool('analyze_timeseries', {
75
177
  title: 'Loudness over time (for graphing / visualization)',
76
- 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.',
77
- inputSchema: { ...URL_IN, waveformPoints: z.number().int().positive().optional().describe('Waveform peak count (default 200).') },
78
- }, wrap(({ url, waveformPoints }) => api('mixlab/timeseries', { url, waveformPoints })));
178
+ 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,
179
+ inputSchema: { ...src, waveformPoints: z.number().int().positive().optional().describe('Waveform peak count (default 200).') },
180
+ }, wrap((i) => analyzeSource('mixlab/timeseries', i, { waveformPoints: i.waveformPoints }, apiKey, local)));
79
181
 
80
182
  tool('get_spectrum', {
81
183
  title: 'FFT spectrum data',
82
- 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.',
83
- inputSchema: URL_IN,
84
- }, wrap(({ url }) => api('mixlab/spectrum', { url })));
184
+ 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,
185
+ inputSchema: src,
186
+ }, wrap((i) => analyzeSource('mixlab/spectrum', i, {}, apiKey, local)));
85
187
 
86
188
  tool('analyze_voice', {
87
189
  title: 'Analyze voice quality (VoiceLab)',
88
- 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.',
89
- inputSchema: URL_IN,
90
- }, wrap(({ url }) => api('voicelab/qa', { url })));
190
+ 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,
191
+ inputSchema: src,
192
+ }, wrap((i) => analyzeSource('voicelab/qa', i, {}, apiKey, local)));
91
193
 
92
194
  tool('get_speech_segments', {
93
195
  title: 'Speech segments (VoiceLab)',
94
- 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.',
95
- inputSchema: URL_IN,
96
- }, wrap(({ url }) => api('voicelab/segments', { url })));
196
+ 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,
197
+ inputSchema: src,
198
+ }, wrap((i) => analyzeSource('voicelab/segments', i, {}, apiKey, local)));
97
199
 
98
200
  tool('index_signal', {
99
201
  title: 'Index a signal (SignalLab)',
100
- 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.',
101
- inputSchema: URL_IN,
102
- }, wrap(({ url }) => api('signallab/index', { url })));
202
+ 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,
203
+ inputSchema: src,
204
+ }, wrap((i) => analyzeSource('signallab/index', i, {}, apiKey, local)));
103
205
 
206
+ // compare_loudness — two independent sources, each a url or (local only) a path.
207
+ const cmp = local
208
+ ? {
209
+ urlA: z.string().url().optional().describe('First source URL (e.g. master). Provide urlA or pathA.'),
210
+ pathA: z.string().optional().describe('First source as a local file path.'),
211
+ urlB: z.string().url().optional().describe('Second source URL (e.g. reference). Provide urlB or pathB.'),
212
+ pathB: z.string().optional().describe('Second source as a local file path.'),
213
+ }
214
+ : {
215
+ urlA: z.string().url().describe('First source (e.g. master).'),
216
+ urlB: z.string().url().describe('Second source (e.g. reference).'),
217
+ };
104
218
  tool('compare_loudness', {
105
219
  title: 'Compare two tracks (A/B)',
106
- 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.',
107
- inputSchema: {
108
- urlA: z.string().url().describe('First source (e.g. master).'),
109
- urlB: z.string().url().describe('Second source (e.g. reference).'),
110
- },
111
- }, wrap(async ({ urlA, urlB }) => {
112
- const [a, b] = await Promise.all([api('mixlab/analyze', { url: urlA }), api('mixlab/analyze', { url: urlB })]);
220
+ 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,
221
+ inputSchema: cmp,
222
+ }, wrap(async (i) => {
223
+ const [a, b] = await Promise.all([
224
+ analyzeSource('mixlab/analyze', { url: i.urlA, path: i.pathA }, {}, apiKey, local),
225
+ analyzeSource('mixlab/analyze', { url: i.urlB, path: i.pathB }, {}, apiKey, local),
226
+ ]);
113
227
  return { a, b };
114
228
  }));
115
229
 
@@ -122,21 +236,26 @@ const isMain = process.argv[1] && import.meta.url === pathToFileURL(process.argv
122
236
 
123
237
  if (isMain && process.argv.includes('--selftest')) {
124
238
  const assert = (await import('node:assert/strict')).default;
125
- const { toolNames } = buildServer();
126
- assert.deepEqual(
127
- toolNames.slice().sort(),
128
- ['analyze_loudness', 'analyze_timeseries', 'analyze_voice', 'check_target', 'compare_loudness', 'get_spectrum', 'get_speech_segments', 'index_signal'],
129
- 'eight hosted tools registered',
130
- );
239
+ const expected = ['analyze_loudness', 'analyze_timeseries', 'analyze_voice', 'check_target', 'compare_loudness', 'get_spectrum', 'get_speech_segments', 'index_signal'];
240
+ assert.deepEqual(buildServer().toolNames.slice().sort(), expected, 'eight hosted tools (remote / url-only)');
241
+ assert.deepEqual(buildServer({ local: true }).toolNames.slice().sort(), expected, 'eight hosted tools (local / url+path)');
242
+
131
243
  const prev = process.env.AUDIOLAB_API_KEY;
132
244
  delete process.env.AUDIOLAB_API_KEY;
133
245
  await assert.rejects(() => call('mixlab/analyze', { url: 'https://example.com/a.wav' }), /AUDIOLAB_API_KEY/, 'refuses without a key (before any fetch)');
134
- if (prev) process.env.AUDIOLAB_API_KEY = prev;
135
- console.log('selftest ok · 8 hosted tools (API-backed, no engine) + missing-key guard');
246
+
247
+ process.env.AUDIOLAB_API_KEY = 'al_live_selftest';
248
+ // A remote server (local=false) must refuse a path BEFORE any filesystem read.
249
+ 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');
250
+ // url + path together is a usage error.
251
+ 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');
252
+ if (prev) process.env.AUDIOLAB_API_KEY = prev; else delete process.env.AUDIOLAB_API_KEY;
253
+
254
+ console.log('selftest ok · 8 tools (remote url-only + local url|path) + missing-key guard + remote-path refusal');
136
255
  process.exit(0);
137
256
  }
138
257
 
139
258
  if (isMain) {
140
- const { server } = buildServer();
259
+ const { server } = buildServer({ local: true });
141
260
  await server.connect(new StdioServerTransport());
142
261
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@audiolabtools/mcp-server",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
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
5
  "type": "module",
6
6
  "bin": {
@@ -22,6 +22,7 @@
22
22
  "audiolab"
23
23
  ],
24
24
  "homepage": "https://audiolab.tools/api",
25
+ "bugs": { "url": "https://audiolab.tools/api" },
25
26
  "repository": {
26
27
  "type": "git",
27
28
  "url": "git+https://github.com/Audio-Launch/audiolab-tools.git",