fix: prevent server hang and output corruption in spawn handling (#23)

- Add process 'error' event handler to catch spawn failures (e.g., yt-dlp not installed)
- Separate stdout/stderr to prevent yt-dlp warnings from corrupting parsed output
- Add try-catch for RegExp construction from YTDLP_SANITIZE_ILLEGAL_CHARS env var
- Add NaN validation for YTDLP_MAX_FILENAME_LENGTH env var
- Sync VERSION constant with package.json (0.8.4)
- Update tests for new output format and null handling
- Add version sync guidance to CLAUDE.md
This commit is contained in:
kevinwatt 2026-01-05 01:47:59 +08:00
parent 47da207c57
commit 0e5a30d10c
9 changed files with 50 additions and 15 deletions

View File

@ -7,10 +7,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [Unreleased]
---
## [0.8.4] - 2026-01-04
### Fixed
- **Critical**: Added missing process error handler in `_spawnPromise()` to prevent server hang when yt-dlp is not installed or fails to spawn ([#23](https://github.com/kevinwatt/yt-dlp-mcp/issues/23))
- **Critical**: Fixed stdout/stderr mixing in `_spawnPromise()` that caused yt-dlp warnings to corrupt parsed output
- Fixed VERSION constant mismatch (was `0.7.0`, now synced with package.json)
- Added try-catch for RegExp construction from `YTDLP_SANITIZE_ILLEGAL_CHARS` env var to prevent startup crash on invalid regex
- Added validation for `YTDLP_MAX_FILENAME_LENGTH` env var to handle NaN values gracefully
- Fixed test expectations for search output format and metadata `creators` field null handling
### Changed ### Changed
- **Documentation**: Added warning about JavaScript runtime (deno) requirement when using cookie authentication - **Documentation**: Added warning about JavaScript runtime (deno) requirement when using cookie authentication
- YouTube authenticated API endpoints require JS challenge solving - YouTube authenticated API endpoints require JS challenge solving
- Without deno, downloads will fail with "n challenge solving failed" error - Without deno, downloads will fail with "n challenge solving failed" error
- **Documentation**: Added version sync guidance to CLAUDE.md (package.json + src/index.mts)
--- ---

View File

@ -5,6 +5,9 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## Development Guidelines ## Development Guidelines
- **Always update CHANGELOG.md** when making changes to the codebase - **Always update CHANGELOG.md** when making changes to the codebase
- **Version updates require TWO files**:
1. `package.json` - line 3: `"version": "x.x.x"`
2. `src/index.mts` - line 24: `const VERSION = 'x.x.x'`
## Development Commands ## Development Commands

4
package-lock.json generated
View File

@ -1,12 +1,12 @@
{ {
"name": "@kevinwatt/yt-dlp-mcp", "name": "@kevinwatt/yt-dlp-mcp",
"version": "0.8.2", "version": "0.8.3",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "@kevinwatt/yt-dlp-mcp", "name": "@kevinwatt/yt-dlp-mcp",
"version": "0.8.2", "version": "0.8.3",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@modelcontextprotocol/sdk": "0.7.0", "@modelcontextprotocol/sdk": "0.7.0",

View File

@ -1,6 +1,6 @@
{ {
"name": "@kevinwatt/yt-dlp-mcp", "name": "@kevinwatt/yt-dlp-mcp",
"version": "0.8.3", "version": "0.8.4",
"description": "An MCP server implementation that integrates with yt-dlp, providing video and audio content download capabilities (e.g. YouTube, Facebook, Tiktok, etc.) for LLMs.", "description": "An MCP server implementation that integrates with yt-dlp, providing video and audio content download capabilities (e.g. YouTube, Facebook, Tiktok, etc.) for LLMs.",
"keywords": [ "keywords": [
"mcp", "mcp",

View File

@ -83,8 +83,9 @@ describe('Video Metadata Extraction', () => {
if (metadata.description !== undefined) { if (metadata.description !== undefined) {
expect(typeof metadata.description).toBe('string'); expect(typeof metadata.description).toBe('string');
} }
if (metadata.creators !== undefined) { if (metadata.creators !== undefined && metadata.creators !== null) {
expect(Array.isArray(metadata.creators)).toBe(true); // creators can be an array or a string depending on the video
expect(Array.isArray(metadata.creators) || typeof metadata.creators === 'string').toBe(true);
} }
if (metadata.timestamp !== undefined) { if (metadata.timestamp !== undefined) {
expect(typeof metadata.timestamp).toBe('number'); expect(typeof metadata.timestamp).toBe('number');

View File

@ -48,7 +48,7 @@ describe('Search functionality tests', () => {
const result = await searchVideos('react tutorial', 2, 0, 'markdown', CONFIG); const result = await searchVideos('react tutorial', 2, 0, 'markdown', CONFIG);
// Check for proper formatting // Check for proper formatting
expect(result).toMatch(/Found \d+ videos?:/); expect(result).toMatch(/Found \d+ videos? \(showing \d+\):/);
expect(result).toMatch(/\d+\. \*\*.*\*\*/); // Numbered list with bold titles expect(result).toMatch(/\d+\. \*\*.*\*\*/); // Numbered list with bold titles
expect(result).toMatch(/📺 Channel: .+/); expect(result).toMatch(/📺 Channel: .+/);
expect(result).toMatch(/⏱️ Duration: .+/); expect(result).toMatch(/⏱️ Duration: .+/);

View File

@ -109,13 +109,26 @@ function loadEnvConfig(): DeepPartial<Config> {
sanitize: { sanitize: {
replaceChar: process.env.YTDLP_SANITIZE_REPLACE_CHAR, replaceChar: process.env.YTDLP_SANITIZE_REPLACE_CHAR,
truncateSuffix: process.env.YTDLP_SANITIZE_TRUNCATE_SUFFIX, truncateSuffix: process.env.YTDLP_SANITIZE_TRUNCATE_SUFFIX,
illegalChars: process.env.YTDLP_SANITIZE_ILLEGAL_CHARS ? new RegExp(process.env.YTDLP_SANITIZE_ILLEGAL_CHARS) : undefined, illegalChars: (() => {
if (!process.env.YTDLP_SANITIZE_ILLEGAL_CHARS) return undefined;
try {
return new RegExp(process.env.YTDLP_SANITIZE_ILLEGAL_CHARS);
} catch {
console.warn('[yt-dlp-mcp] Invalid regex in YTDLP_SANITIZE_ILLEGAL_CHARS, using default');
return undefined;
}
})(),
reservedNames: process.env.YTDLP_SANITIZE_RESERVED_NAMES?.split(',') reservedNames: process.env.YTDLP_SANITIZE_RESERVED_NAMES?.split(',')
} }
}; };
if (process.env.YTDLP_MAX_FILENAME_LENGTH) { if (process.env.YTDLP_MAX_FILENAME_LENGTH) {
fileConfig.maxFilenameLength = parseInt(process.env.YTDLP_MAX_FILENAME_LENGTH); const parsed = parseInt(process.env.YTDLP_MAX_FILENAME_LENGTH, 10);
if (!isNaN(parsed) && parsed >= 5) {
fileConfig.maxFilenameLength = parsed;
} else {
console.warn('[yt-dlp-mcp] Invalid YTDLP_MAX_FILENAME_LENGTH, using default');
}
} }
if (process.env.YTDLP_DOWNLOADS_DIR) { if (process.env.YTDLP_DOWNLOADS_DIR) {
fileConfig.downloadsDir = process.env.YTDLP_DOWNLOADS_DIR; fileConfig.downloadsDir = process.env.YTDLP_DOWNLOADS_DIR;

View File

@ -21,7 +21,7 @@ import { searchVideos } from "./modules/search.js";
import { getVideoMetadata, getVideoMetadataSummary } from "./modules/metadata.js"; import { getVideoMetadata, getVideoMetadataSummary } from "./modules/metadata.js";
import { getVideoComments, getVideoCommentsSummary } from "./modules/comments.js"; import { getVideoComments, getVideoCommentsSummary } from "./modules/comments.js";
const VERSION = '0.7.0'; const VERSION = '0.8.4';
// Response format enum // Response format enum
enum ResponseFormat { enum ResponseFormat {

View File

@ -91,21 +91,26 @@ export async function safeCleanup(directory: string): Promise<void> {
export function _spawnPromise(command: string, args: string[]): Promise<string> { export function _spawnPromise(command: string, args: string[]): Promise<string> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const process = spawn(command, args); const process = spawn(command, args);
let output = ''; let stdout = '';
let stderr = '';
process.on('error', (err) => {
reject(new Error(`Failed to spawn ${command}: ${err.message}`));
});
process.stdout.on('data', (data) => { process.stdout.on('data', (data) => {
output += data.toString(); stdout += data.toString();
}); });
process.stderr.on('data', (data) => { process.stderr.on('data', (data) => {
output += data.toString(); stderr += data.toString();
}); });
process.on('close', (code) => { process.on('close', (code) => {
if (code === 0) { if (code === 0) {
resolve(output); resolve(stdout);
} else { } else {
reject(new Error(`Failed with exit code: ${code}\n${output}`)); reject(new Error(`Failed with exit code: ${code}\n${stderr}\n${stdout}`));
} }
}); });
}); });