Skip to content

Commit 28c1fe6

Browse files
committed
harden error handling, add smoke tests, and prep for v0.1.0 release
Pre-ship audit fixes: - Wrap mic.stop() in try-catch in error and safety timeout handlers - Add defensive JSON.parse in voice_query pipeline - Add 60s AbortController timeout on Ollama chat calls - Improve Ollama error detection with recursive cause chain checking - Validate whisper transcription result structure before parsing - Remove .claude/settings.local.json from repo, add .claude/ to .gitignore Smoke test suite (test/smoke.js): - Server initialization and version match - All 3 tools advertised in tools/list - list_audio_devices returns valid device data - capture_audio produces WAV with valid RIFF header - Invalid device and unknown tool return isError - Skips mic-dependent tests gracefully on CI (no hardware) Publish workflow improvements: - Add npm install + npm test before publish - Add tag/version mismatch check against package.json README additions: - Troubleshooting section (Windows mic permissions, Ollama, whisper model)
1 parent 662d6cd commit 28c1fe6

File tree

10 files changed

+281
-50
lines changed

10 files changed

+281
-50
lines changed

.claude/settings.local.json

Lines changed: 0 additions & 43 deletions
This file was deleted.

.github/workflows/publish.yml

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,19 @@ jobs:
1919
node-version: '20'
2020
registry-url: 'https://registry.npmjs.org'
2121

22+
- run: npm install --ignore-scripts
23+
24+
- run: npm test
25+
26+
- name: Verify tag matches package.json version
27+
run: |
28+
PKG_VERSION="v$(node -p "require('./package.json').version")"
29+
TAG="${GITHUB_REF_NAME}"
30+
if [ "$PKG_VERSION" != "$TAG" ]; then
31+
echo "::error::Tag $TAG does not match package.json version $PKG_VERSION"
32+
exit 1
33+
fi
34+
2235
- run: npm publish --access public
2336
env:
2437
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
node_modules/
2+
.claude/
23
*.wav
34
*.bin
45
models/

README.md

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
<!-- markdownlint-disable MD033 MD041 -->
2+
13
# mcp-listen
24

35
**The first MCP server that can hear.**
@@ -26,7 +28,6 @@ Give your AI agents the ability to listen. Microphone capture and speech-to-text
2628
<!-- badges: end -->
2729
</div>
2830

29-
3031
## Tools
3132

3233
| Tool | Description |
@@ -185,6 +186,17 @@ The model is ~150MB and downloads once. You can also set the `WHISPER_MODEL_PATH
185186
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.
186187
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.
187188

189+
## Troubleshooting
190+
191+
**Windows: "Error opening microphone"**
192+
Windows may block microphone access by default. Go to **Settings > Privacy & security > Microphone** and ensure microphone access is enabled for desktop apps.
193+
194+
**Ollama: "Ollama is not running"**
195+
Some Ollama installations start as a background service automatically. If you see this error, run `ollama serve` manually or check that the Ollama service is running.
196+
197+
**Whisper: "model not found"**
198+
The whisper model file must be downloaded before first use. See [Whisper Model Setup](#whisper-model-setup) for instructions.
199+
188200
## Powered By
189201

190202
- [decibri](https://decibri.dev): Cross-platform microphone capture for Node.js

index.js

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -114,7 +114,15 @@ async function voiceQuery(args) {
114114

115115
if (captureResult.isError) return captureResult;
116116

117-
const captureData = JSON.parse(captureResult.content[0].text);
117+
let captureData;
118+
try {
119+
captureData = JSON.parse(captureResult.content[0].text);
120+
} catch {
121+
return {
122+
content: [{ type: 'text', text: 'Error: Failed to parse capture result.' }],
123+
isError: true
124+
};
125+
}
118126
const wavPath = captureData.path;
119127

120128
try {

lib/audio.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,7 @@ function captureAudio({ durationMs = 5000, device, outputPath } = {}) {
8989
});
9090

9191
mic.on('error', (err) => {
92-
if (mic.isOpen) mic.stop();
92+
try { if (mic.isOpen) mic.stop(); } catch {}
9393
finish({
9494
content: [{ type: 'text', text: `Microphone error during recording: ${err.message}` }],
9595
isError: true
@@ -131,7 +131,7 @@ function captureAudio({ durationMs = 5000, device, outputPath } = {}) {
131131

132132
// Safety timeout in case 'end' never fires
133133
const safetyTimer = setTimeout(() => {
134-
if (mic.isOpen) mic.stop();
134+
try { if (mic.isOpen) mic.stop(); } catch {}
135135
finish({
136136
content: [{ type: 'text', text: 'Error: Recording timed out. The microphone did not stop cleanly.' }],
137137
isError: true

lib/llm.js

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,16 @@ try {
77
Ollama = null;
88
}
99

10+
const REQUEST_TIMEOUT_MS = 60000;
11+
12+
function isConnectionError(err) {
13+
const codes = ['ECONNREFUSED', 'ECONNRESET', 'EHOSTUNREACH', 'ETIMEDOUT'];
14+
if (err.code && codes.includes(err.code)) return true;
15+
if (err.cause) return isConnectionError(err.cause);
16+
if (err.message && /connect|refused|unreachable/i.test(err.message)) return true;
17+
return false;
18+
}
19+
1020
async function chat({ text, model = 'llama3.2', systemPrompt = 'You are a helpful assistant.', host } = {}) {
1121
if (!Ollama) {
1222
return {
@@ -17,28 +27,37 @@ async function chat({ text, model = 'llama3.2', systemPrompt = 'You are a helpfu
1727
const options = {};
1828
if (host) options.host = host;
1929

30+
const controller = new AbortController();
31+
const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
32+
2033
try {
2134
const ollama = new Ollama(options);
2235
const result = await ollama.chat({
2336
model,
2437
messages: [
2538
{ role: 'system', content: systemPrompt },
2639
{ role: 'user', content: text }
27-
]
40+
],
41+
signal: controller.signal
2842
});
2943

3044
return {
3145
response: result.message.content,
3246
model
3347
};
3448
} catch (err) {
35-
if (err.code === 'ECONNREFUSED' || err.cause?.code === 'ECONNREFUSED') {
49+
if (err.name === 'AbortError') {
50+
return { error: `Ollama request timed out after ${REQUEST_TIMEOUT_MS / 1000} seconds.` };
51+
}
52+
if (isConnectionError(err)) {
3653
return { error: 'Ollama is not running. Start it with: ollama serve' };
3754
}
3855
if (err.message && err.message.includes('not found')) {
3956
return { error: `Model "${model}" not found. Pull it with: ollama pull ${model}` };
4057
}
4158
return { error: `LLM error: ${err.message}` };
59+
} finally {
60+
clearTimeout(timeout);
4261
}
4362
}
4463

lib/transcribe.js

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,8 +65,16 @@ async function transcribe({ filePath, modelPath, language = 'en' } = {}) {
6565
language
6666
});
6767

68+
if (!result || !Array.isArray(result.transcription)) {
69+
return { error: 'Unexpected whisper response format. Expected { transcription: [] }.' };
70+
}
71+
6872
const text = result.transcription
69-
.map(segment => Array.isArray(segment) ? segment[2] : segment)
73+
.map(segment => {
74+
if (typeof segment === 'string') return segment;
75+
if (Array.isArray(segment) && typeof segment[2] === 'string') return segment[2];
76+
return '';
77+
})
7078
.join(' ')
7179
.trim();
7280

package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,9 @@
2727
"stt",
2828
"transcription"
2929
],
30+
"scripts": {
31+
"test": "node test/smoke.js"
32+
},
3033
"author": "Analytics in Motion",
3134
"license": "Apache-2.0",
3235
"repository": {

0 commit comments

Comments
 (0)