Skip to content

Commit ac801ad

Browse files
Merge pull request #23 from decibri/development
fix: misleading load errors, temp file buildup, leaky publish manifest
2 parents ae6aac7 + 22625a0 commit ac801ad

17 files changed

Lines changed: 739 additions & 31 deletions

.github/workflows/publish.yml

Lines changed: 48 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -116,14 +116,30 @@ jobs:
116116
- name: Run smoke tests
117117
run: npm test
118118

119+
- name: Build the publish package
120+
# The package is published from the generated dist/ directory, not
121+
# from the repo root: scripts/build-package.js writes a manifest
122+
# containing only an explicit allowlist of consumer-facing fields
123+
# (no scripts, no devDependencies) and copies the runtime files in.
124+
# The source package.json is read, never written.
125+
run: node scripts/build-package.js
126+
119127
- name: Verify npm package contents (pre-publish gate)
120128
# Defense-in-depth: verify npm package contents before any publish.
121-
# Runs in both publish and dry-run modes. The forbidden patterns exist
122-
# because the maintainer working tree holds gitignored registry
123-
# credentials (.mcpregistry_*), a publisher binary (mcp-publisher.exe),
124-
# and a local runbook (CLAUDE.local.md); this gate stops them ever
125-
# reaching a tarball if .gitignore or the package.json `files` list
126-
# slips.
129+
# Runs in both publish and dry-run modes, against the generated
130+
# dist/ directory that actually gets published. The forbidden
131+
# patterns exist because the maintainer working tree holds
132+
# gitignored registry credentials (.mcpregistry_*), a publisher
133+
# binary (mcp-publisher.exe), and a local runbook (CLAUDE.local.md);
134+
# this gate stops them ever reaching a tarball if .gitignore or the
135+
# build script slips.
136+
#
137+
# The manifest checks are the field-level equivalent of the file
138+
# checks: the published manifest is generated from an allowlist,
139+
# and this gate fails the release if a development-only field
140+
# (scripts, devDependencies) appears in it anyway. jq reads
141+
# dist/package.json directly because `npm pack --dry-run --json`
142+
# reports the file list, not the manifest contents.
127143
#
128144
# The shape check is not paranoia. npm 10 emitted an array of packed-
129145
# package records from `npm pack --dry-run --json`; npm 12 emits an
@@ -138,7 +154,7 @@ jobs:
138154
set -o pipefail
139155
FAIL=0
140156
141-
PACK_JSON=$(npm pack --dry-run --json)
157+
PACK_JSON=$(npm pack --dry-run --json ./dist)
142158
echo "Raw npm pack output:"
143159
echo "$PACK_JSON"
144160
echo ""
@@ -165,6 +181,7 @@ jobs:
165181
package.json \
166182
index.js \
167183
lib/audio.js \
184+
lib/cleanup.js \
168185
lib/llm.js \
169186
lib/transcribe.js \
170187
lib/validate.js \
@@ -193,6 +210,19 @@ jobs:
193210
fi
194211
done
195212
213+
echo ""
214+
echo "Published manifest (dist/package.json) field checks:"
215+
for field in scripts devDependencies
216+
do
217+
if jq -e --arg f "$field" 'has($f)' dist/package.json > /dev/null; then
218+
echo " [FAIL] published manifest must not carry: $field"
219+
echo " got: $(jq -c --arg f "$field" '.[$f]' dist/package.json)"
220+
FAIL=1
221+
else
222+
echo " [OK] manifest field absent: $field"
223+
fi
224+
done
225+
196226
echo ""
197227
if [ "$FAIL" -ne 0 ]; then
198228
echo "================================================"
@@ -255,14 +285,23 @@ jobs:
255285
fi
256286
echo "OK: npm ${NPM_VERSION} supports trusted publishing"
257287
288+
- name: Build the publish package
289+
# Same generated dist/ the verify job gated: the published manifest
290+
# is built from an explicit field allowlist (no scripts, no
291+
# devDependencies) and the runtime files are copied in. The build
292+
# needs no npm install; scripts/build-package.js uses only node:fs.
293+
run: node scripts/build-package.js
294+
258295
# npm Trusted Publishing (OIDC) supplies auth automatically given
259296
# `id-token: write` and the registered trusted publisher record. No
260297
# NODE_AUTH_TOKEN is used, and no --provenance flag is passed:
261298
# provenance is generated automatically from the OIDC token. The
262299
# publish is gated on either a tag-push trigger or a
263-
# workflow_dispatch run with `publish: true`.
300+
# workflow_dispatch run with `publish: true`. Published from the
301+
# generated dist/ directory (cd, matching the decibri publish
302+
# workflows), never from the repo root.
264303
- name: Publish mcp-listen
265-
run: npm publish --access public
304+
run: cd dist && npm publish --access public
266305

267306
- name: Create GitHub Release
268307
if: github.event_name == 'push'

.gitignore

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,4 +11,7 @@ mcp-publisher.exe
1111
.mcpregistry_github_token
1212
.mcpregistry_registry_token
1313

14-
CLAUDE.local.md
14+
CLAUDE.local.md
15+
16+
# Generated publish package, assembled by scripts/build-package.js
17+
dist/

CHANGELOG.md

Lines changed: 53 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,57 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
## [0.4.0] - 2026-07-14
11+
12+
### Fixed
13+
14+
- A whisper addon that is installed but fails to load (for example, a
15+
missing native library) was reported as "not installed", telling the
16+
user to run an install command that was already satisfied and could
17+
not help. The two failure modes are now distinguished: a genuinely
18+
missing package keeps the install guidance, and a load failure
19+
reports itself as one and surfaces the underlying loader error, which
20+
names the thing the user can actually act on. The optional `ollama`
21+
client had the same defect and is fixed the same way.
22+
- `capture_audio` never removed the WAV files it wrote to the system
23+
temporary directory. An agent calling it repeatedly accumulated
24+
recordings of the user's microphone there indefinitely.
25+
26+
### Changed
27+
28+
- WAV files written by `capture_audio` to the temporary directory are
29+
now removed at server start once they are older than 24 hours. The
30+
path returned by a call remains valid for the session and well
31+
beyond it. The sweep matches only the exact file names the tool
32+
generates, never touches anything else, and cannot fail or delay
33+
startup. `voice_query` continues to delete its recording as soon as
34+
the query completes.
35+
- The published package no longer carries a `scripts` block. The
36+
package is built from a generated manifest holding an explicit
37+
allowlist of consumer-facing fields, so development-only fields
38+
cannot reach consumers. `npm test` in an installed copy now reports
39+
a missing script instead of failing on a test file that is
40+
deliberately not shipped. The pre-publish gate fails the release if
41+
the published manifest carries `scripts` or `devDependencies`. The
42+
tarball's runtime contents are unchanged.
43+
- Documentation brought current: the argument validation introduced in
44+
0.3.0 is documented, the whisper model section no longer implies the
45+
model downloads itself, Known Limitations describes the temp-file
46+
retention, a troubleshooting entry covers the addon load-failure
47+
message, and the security policy's supported-versions table reflects
48+
the 0.3+ line.
49+
50+
### Added
51+
52+
- Deterministic test coverage, on every platform, for paths the smoke
53+
suite could not previously reach: the optional-dependency load
54+
failures in the transcription and LLM layers (stubbed at the module
55+
loader, so no whisper model or Ollama daemon is needed), the
56+
temp-directory sweep, and the packed manifest (builds and packs the
57+
real tarball, asserts the source manifest is never modified, and
58+
asserts an installed copy's `npm test` cannot fail on a module that
59+
was never shipped).
60+
1061
## [0.3.0] - 2026-07-14
1162

1263
### Changed
@@ -200,7 +251,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
200251
listing, WAV output validation, and error responses.
201252
- Tag-triggered npm publish workflow.
202253

203-
[Unreleased]: https://github.com/decibri/mcp-listen/compare/v0.3.0...HEAD
254+
[Unreleased]: https://github.com/decibri/mcp-listen/compare/v0.4.0...HEAD
255+
[0.4.0]: https://github.com/decibri/mcp-listen/compare/v0.3.0...v0.4.0
204256
[0.3.0]: https://github.com/decibri/mcp-listen/compare/v0.2.1...v0.3.0
205257
[0.2.1]: https://github.com/decibri/mcp-listen/compare/v0.2.0...v0.2.1
206258
[0.2.0]: https://github.com/decibri/mcp-listen/compare/v0.1.3...v0.2.0

README.md

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,8 @@ Intel Mac (darwin-x64) is not supported: Apple has discontinued the platform and
9191

9292
## Tool Reference
9393

94+
Arguments are validated before anything is recorded or written. An argument a tool does not declare is rejected with an error naming it, rather than silently ignored. `duration_ms` must be an integer between 100 and 30000; `device` must be a non-negative integer index or a non-empty string id from `list_audio_devices`. A rejected call writes nothing to disk.
95+
9496
### list_audio_devices
9597

9698
Returns a JSON array of available audio input devices.
@@ -194,9 +196,9 @@ The model is ~150MB and downloads once. You can also set the `WHISPER_MODEL_PATH
194196

195197
1. **Fixed recording duration.** You specify how long to record. There is no "stop when I stop talking" mode yet.
196198
2. **`voice_query` requires Ollama running.** If Ollama isn't running, the tool returns a clear error message.
197-
3. **Whisper model downloads on first use.** The first `voice_query` call requires a pre-downloaded model (~150MB).
199+
3. **Whisper model must be downloaded before first use.** `voice_query` does not download the model itself; the first call requires a pre-downloaded model (~150MB). See [Whisper Model Setup](#whisper-model-setup).
198200
4. **No streaming.** MCP's request/response pattern means the entire recording is captured, then transcribed, then sent to the LLM. No real-time partial results.
199-
5. **Temp files.** `capture_audio` writes WAV files to the system temp directory. They are not automatically cleaned up. `voice_query` cleans up after itself.
201+
5. **Temp files.** `capture_audio` writes WAV files to the system temp directory and returns the path, so the file has to outlive the call for the caller to read it. Recordings older than 24 hours are removed the next time the server starts; recordings made since the last restart persist until then. `voice_query` deletes its recording as soon as the query completes.
200202

201203
## Troubleshooting
202204

@@ -209,6 +211,9 @@ Some Ollama installations start as a background service automatically. If you se
209211
**Whisper: "model not found"**
210212
The whisper model file must be downloaded before first use. See [Whisper Model Setup](#whisper-model-setup) for instructions.
211213

214+
**Whisper: "installed but failed to load"**
215+
The `@kutalia/whisper-node-addon` package is present but a native library it depends on is missing or incompatible on your system. The error includes the underlying loader message naming the library. Reinstalling the package will not help; resolve the named library instead.
216+
212217
## License
213218

214219
Apache-2.0. See [LICENSE](LICENSE) for details.

SECURITY.md

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ Understanding what the server touches is useful context when assessing the impac
2828
mcp-listen:
2929

3030
- Reads from audio input devices when a capture tool is invoked
31-
- Writes WAV files to the system temporary directory. `capture_audio` returns the recording's path to the caller; `voice_query` deletes its recording when the query completes.
31+
- Writes WAV files to the system temporary directory. `capture_audio` returns the recording's path to the caller; `voice_query` deletes its recording when the query completes. At startup the server removes its own recordings older than 24 hours from the temporary directory, matching only the file names it generates, so `capture_audio` recordings do not accumulate indefinitely.
3232
- Transcribes captured audio locally, in process, using whisper.cpp. Audio never leaves the machine.
3333
- Sends transcribed text to the local Ollama daemon at `127.0.0.1:11434` when `voice_query` is used. This is the only network call in the codebase. An Ollama daemon configured with cloud models will relay that text off the machine; that relay is a property of the user's Ollama configuration, not of mcp-listen, but it is worth knowing when assessing where text can travel.
3434
- Reads the `WHISPER_MODEL_PATH` environment variable to locate the local Whisper model. It reads no API credentials: none are used. The bundled Ollama client library can read `OLLAMA_API_KEY`, but it attaches that credential only to requests to `ollama.com`, and mcp-listen only ever calls the local daemon, so that code path is unreachable.
@@ -55,7 +55,8 @@ mcp-listen is published to npm as a single JavaScript package. It ships no binar
5555

5656
- The package is published exclusively from GitHub Actions on GitHub-hosted runners. Nothing is published manually.
5757
- Publishing is triggered only by tagged releases and is gated behind a protected GitHub environment requiring manual approval, restricted to `v*` tags.
58-
- A pre-publish verification gate asserts that every file the package ships is present in the tarball and fails the release if any credential, publisher binary, or test fixture is present.
58+
- The published package is assembled into a build directory by a generator that copies the runtime files and writes a manifest containing only an explicit allowlist of consumer-facing fields. The development manifest, with its scripts and tooling, is never published.
59+
- A pre-publish verification gate asserts that every file the package ships is present in the tarball, that no credential, publisher binary, or test fixture is present, and that the published manifest carries no scripts and no development dependencies. It fails the release otherwise.
5960
- The full build and release configuration is open source and auditable in `.github/workflows/publish.yml`.
6061

6162
### Publishing and authentication
@@ -79,8 +80,8 @@ This security policy applies to the following versions:
7980

8081
| Version | Supported |
8182
| --- | --- |
82-
| 0.2.x | Yes |
83-
| < 0.2 | No |
83+
| 0.4.x | Yes |
84+
| < 0.4 | No |
8485

8586
Security fixes are applied to the latest release only. mcp-listen is pre-1.0 and older versions are not backported. Upgrade to the latest release.
8687

index.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ const { version } = require('./package.json');
99
const { listDevices, captureAudio, getActiveMic } = require('./lib/audio');
1010
const { transcribe } = require('./lib/transcribe');
1111
const { chat } = require('./lib/llm');
12+
const { sweepStaleRecordings } = require('./lib/cleanup');
1213
const {
1314
validateListDevicesArgs,
1415
validateCaptureArgs,
@@ -220,6 +221,10 @@ process.on('SIGTERM', shutdown);
220221
// ── Start ──────────────���────────────────────────────────────
221222

222223
(async () => {
224+
// Not awaited: the sweep is best-effort housekeeping and must never
225+
// delay or prevent the server coming up.
226+
sweepStaleRecordings();
227+
223228
const transport = new StdioServerTransport();
224229
await server.connect(transport);
225230
console.error('mcp-listen server started');

lib/cleanup.js

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
'use strict';
2+
3+
const fs = require('fs');
4+
const path = require('path');
5+
const os = require('os');
6+
7+
// capture_audio's contract is to return the recording's path, so its WAV
8+
// cannot be deleted when the call completes; before this sweep existed,
9+
// those files simply accumulated in the temp directory forever, which is
10+
// a privacy problem as much as a disk one (they are microphone
11+
// recordings). The sweep runs once at server start and removes only
12+
// recordings old enough that no caller can still be using them.
13+
//
14+
// The retention window is deliberately generous: a caller is expected to
15+
// consume the file within its session, and 24 hours outlives any
16+
// plausible session while still bounding accumulation to one day of
17+
// recordings between restarts.
18+
const RETENTION_MS = 24 * 60 * 60 * 1000;
19+
20+
// Exactly the names capture_audio generates: the prefix, a millisecond
21+
// timestamp, the extension. Anything else in the temp directory, however
22+
// similar, was not created by this tool and is never touched.
23+
const RECORDING_NAME = /^mcp-listen-\d+\.wav$/;
24+
25+
// Best-effort by design: an unreadable directory, a locked file, or a
26+
// file deleted underneath us must neither throw nor stop the sweep. The
27+
// caller fires this without awaiting it, so server startup never blocks
28+
// on it. Returns the number of files removed.
29+
async function sweepStaleRecordings({ dir = os.tmpdir(), maxAgeMs = RETENTION_MS } = {}) {
30+
let names;
31+
try {
32+
names = await fs.promises.readdir(dir);
33+
} catch {
34+
return 0;
35+
}
36+
37+
const cutoff = Date.now() - maxAgeMs;
38+
let removed = 0;
39+
for (const name of names) {
40+
if (!RECORDING_NAME.test(name)) continue;
41+
const filePath = path.join(dir, name);
42+
try {
43+
const stat = await fs.promises.stat(filePath);
44+
if (!stat.isFile() || stat.mtimeMs > cutoff) continue;
45+
await fs.promises.unlink(filePath);
46+
removed++;
47+
} catch {
48+
// Locked, already gone, or unreadable: leave it and keep going.
49+
}
50+
}
51+
return removed;
52+
}
53+
54+
module.exports = { sweepStaleRecordings, RETENTION_MS };

lib/llm.js

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,23 @@
11
'use strict';
22

3-
let Ollama;
3+
// Optional dependency: a load failure must not stop the server, so it
4+
// degrades to an error result at call time. The caught error is kept
5+
// because "not installed" and "installed but failed to load" demand
6+
// different actions: only a MODULE_NOT_FOUND naming the package itself
7+
// is fixed by npm install; any other failure needs the underlying error.
8+
// Same reasoning as the whisper addon handling in lib/transcribe.js.
9+
let Ollama = null;
10+
let ollamaLoadError = null;
411
try {
512
({ Ollama } = require('ollama'));
6-
} catch {
7-
Ollama = null;
13+
} catch (err) {
14+
ollamaLoadError = err;
15+
}
16+
17+
function isNotInstalled(err) {
18+
return err.code === 'MODULE_NOT_FOUND' &&
19+
typeof err.message === 'string' &&
20+
err.message.includes("'ollama'");
821
}
922

1023
const REQUEST_TIMEOUT_MS = 60000;
@@ -19,8 +32,15 @@ function isConnectionError(err) {
1932

2033
async function chat({ text, model = 'llama3.2', systemPrompt = 'You are a helpful assistant.', host } = {}) {
2134
if (!Ollama) {
35+
if (ollamaLoadError && !isNotInstalled(ollamaLoadError)) {
36+
return {
37+
error: `ollama package is installed but failed to load: ${ollamaLoadError.message}.`,
38+
cause: ollamaLoadError
39+
};
40+
}
2241
return {
23-
error: 'ollama package is not installed. Install it with: npm install ollama'
42+
error: 'ollama package is not installed. Install it with: npm install ollama',
43+
cause: ollamaLoadError
2444
};
2545
}
2646

0 commit comments

Comments
 (0)