Skip to content

Commit e2e2055

Browse files
committed
feat(MCP): enhance Desktop Extension with validation and fixes
Security & Configuration: - Security: Replace execSync with execFileSync to prevent command injection - Detect and handle unexpanded ${user_config.*} template variables - Require fully qualified paths in Extension Mode (file/directory pickers) - Auto-detect qsv binary from PATH with version validation (>= 13.0.0) Bug Fixes: - Fix tool schema: remove duplicate 'input' parameter (conflicted with 'input_file') - Add METADATA_CACHE_TTL_MS and QSV_VALIDATION_TIMEOUT_MS constants - Improve version regex to support pre-release versions (e.g., 13.0.0-mimalloc) Extension Mode Enhancements: - Validate qsv binary path on every config change (server restart) - Provide Extension Mode-specific error messages and fix instructions - Enhanced initialization logging with clear success/failure indicators Testing: - Add comprehensive integration tests (tests/qsv-integration.test.ts) - 10 tests covering qsv commands: count, headers, select, search, stats, sort, frequency - Test filesystem metadata caching and error handling All 27 MCP tools now working correctly in Claude Desktop Extension.
1 parent 5209c75 commit e2e2055

File tree

7 files changed

+498
-52
lines changed

7 files changed

+498
-52
lines changed

.claude/skills/manifest.json

Lines changed: 5 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,6 @@
3939
"args": ["${__dirname}/server/mcp-server.js"],
4040
"env": {
4141
"QSV_MCP_BIN_PATH": "${user_config.qsv_path}",
42-
"QSV_MCP_WORKING_DIR": "${user_config.working_dir}",
4342
"QSV_MCP_ALLOWED_DIRS": "${user_config.allowed_dirs}",
4443
"QSV_MCP_TIMEOUT_MS": "${user_config.timeout_ms}",
4544
"QSV_MCP_MAX_OUTPUT_SIZE": "${user_config.max_output_size}",
@@ -52,7 +51,6 @@
5251
"win32": {
5352
"env": {
5453
"QSV_MCP_BIN_PATH": "${user_config.qsv_path}",
55-
"QSV_MCP_WORKING_DIR": "${user_config.working_dir}",
5654
"QSV_MCP_ALLOWED_DIRS": "${user_config.allowed_dirs}"
5755
}
5856
}
@@ -61,17 +59,16 @@
6159
},
6260
"user_config": {
6361
"qsv_path": {
64-
"type": "string",
62+
"type": "file",
6563
"title": "qsv Binary Path",
66-
"description": "Path to qsv executable. Install qsv first from: https://github.com/dathere/qsv#installation",
67-
"required": false,
68-
"default": "qsv"
64+
"description": "Full path to qsv executable. Click 'Browse' to locate it (usually /usr/local/bin/qsv or /opt/homebrew/bin/qsv on macOS). Install from: https://github.com/dathere/qsv#installation",
65+
"required": true
6966
},
7067
"working_dir": {
7168
"type": "directory",
7269
"title": "Default Working Directory",
73-
"description": "Directory where qsv commands run by default. Leave empty to use Downloads folder automatically. Extension only accesses files within this directory and allowed directories below.",
74-
"required": false
70+
"description": "Directory where qsv commands run by default. Click 'Browse' to select your Downloads folder or another directory. Extension only accesses files within this directory and allowed directories below.",
71+
"required": true
7572
},
7673
"allowed_dirs": {
7774
"type": "directory",

.claude/skills/qsv-mcp-server.mcpb

7.75 KB
Binary file not shown.

.claude/skills/src/config.ts

Lines changed: 51 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,11 @@ import { homedir, tmpdir } from 'os';
99
import { join } from 'path';
1010
import { execSync, execFileSync } from 'child_process';
1111

12+
/**
13+
* Timeout for qsv binary validation commands in milliseconds (5 seconds)
14+
*/
15+
const QSV_VALIDATION_TIMEOUT_MS = 5000;
16+
1217
/**
1318
* Expand template variables in strings
1419
* Supports: ${HOME}, ${USERPROFILE}, ${DESKTOP}, ${DOCUMENTS}, ${DOWNLOADS}, ${TEMP}, ${TMPDIR}
@@ -96,6 +101,12 @@ function parseFloatEnv(envVar: string, defaultValue: number, min?: number, max?:
96101
return parsed;
97102
}
98103

104+
/**
105+
* Regular expression to detect unexpanded template variables from Claude Desktop
106+
* Matches ${user_config.*} patterns that indicate an empty/unset configuration field
107+
*/
108+
const UNEXPANDED_TEMPLATE_REGEX = /\$\{user_config\.[^}]+\}/;
109+
99110
/**
100111
* Get string from environment variable with default
101112
* Expands template variables like ${HOME}, ${USERPROFILE}, etc.
@@ -106,7 +117,7 @@ function getStringEnv(envVar: string, defaultValue: string): string {
106117
const value = process.env[envVar];
107118
// Treat empty string, null, undefined, or unexpanded template as missing - use default
108119
// Unexpanded template happens when Claude Desktop config field is empty
109-
if (!value || value.trim() === '' || value.includes('${user_config.')) {
120+
if (!value || value.trim() === '' || UNEXPANDED_TEMPLATE_REGEX.test(value)) {
110121
return expandTemplateVars(defaultValue);
111122
}
112123
return expandTemplateVars(value);
@@ -184,10 +195,14 @@ function detectQsvBinaryPath(): string | null {
184195

185196
/**
186197
* Parse version string from qsv --version output
187-
* Example: "qsv 0.135.0" -> "0.135.0"
198+
* Examples:
199+
* "qsv 0.135.0" -> "0.135.0"
200+
* "qsv 0.135.0-alpha.1" -> "0.135.0-alpha.1"
201+
* "qsv 0.135.0+build.123" -> "0.135.0+build.123"
188202
*/
189203
function parseQsvVersion(versionOutput: string): string | null {
190-
const match = versionOutput.match(/qsv\s+(\d+\.\d+\.\d+)/);
204+
// Match semantic version with optional pre-release and build metadata
205+
const match = versionOutput.match(/qsv\s+(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?)/);
191206
return match ? match[1] : null;
192207
}
193208

@@ -218,7 +233,7 @@ export function validateQsvBinary(binPath: string): QsvValidationResult {
218233
const result = execFileSync(binPath, ['--version'], {
219234
encoding: 'utf8',
220235
stdio: ['ignore', 'pipe', 'pipe'],
221-
timeout: 5000 // 5 second timeout
236+
timeout: QSV_VALIDATION_TIMEOUT_MS
222237
});
223238

224239
const version = parseQsvVersion(result);
@@ -254,16 +269,26 @@ export function validateQsvBinary(binPath: string): QsvValidationResult {
254269

255270
/**
256271
* Initialize qsv binary path with auto-detection and validation
272+
*
273+
* This function runs at server startup and validates the qsv binary.
274+
* In Extension Mode, Claude Desktop restarts the server whenever the user
275+
* changes the qsv binary path setting, so validation occurs on every change.
276+
*
257277
* Priority:
258-
* 1. Explicit QSV_MCP_BIN_PATH environment variable
259-
* 2. Auto-detected path from system PATH
260-
* 3. Fall back to 'qsv' (will likely fail validation if not in PATH)
278+
* 1. Explicit QSV_MCP_BIN_PATH environment variable (user-configured path)
279+
* 2. Auto-detected path from system PATH (via which/where command)
280+
* 3. Fall back to 'qsv' (legacy MCP mode only)
281+
*
282+
* In Extension Mode, always requires a fully qualified path and valid qsv binary
283+
* (version >= 13.0.0). Invalid paths or versions will fail validation with clear
284+
* error messages shown in server logs.
261285
*/
262286
function initializeQsvBinaryPath(): { path: string; validation: QsvValidationResult } {
287+
const inExtensionMode = getBooleanEnv('MCPB_EXTENSION_MODE', false);
263288
const explicitPath = process.env['QSV_MCP_BIN_PATH'];
264289

265-
// If user explicitly configured a path, use it
266-
if (explicitPath) {
290+
// If user explicitly configured a path (non-empty), use it
291+
if (explicitPath && explicitPath.trim() !== '' && !UNEXPANDED_TEMPLATE_REGEX.test(explicitPath)) {
267292
const expanded = expandTemplateVars(explicitPath);
268293
const validation = validateQsvBinary(expanded);
269294
return { path: expanded, validation };
@@ -274,12 +299,26 @@ function initializeQsvBinaryPath(): { path: string; validation: QsvValidationRes
274299
if (detectedPath) {
275300
const validation = validateQsvBinary(detectedPath);
276301
if (validation.valid) {
302+
// In extension mode, ensure path is fully qualified
277303
return { path: detectedPath, validation };
278304
}
279-
// Detected but invalid - fall through to default
305+
// Detected but invalid version - continue to fallback
306+
}
307+
308+
// Extension mode requires fully qualified, valid qsv binary
309+
if (inExtensionMode) {
310+
return {
311+
path: detectedPath || 'qsv',
312+
validation: {
313+
valid: false,
314+
error: detectedPath
315+
? `qsv binary found at ${detectedPath} but version validation failed. Please install qsv ${MINIMUM_QSV_VERSION} or higher from https://github.com/dathere/qsv#installation`
316+
: `qsv binary not found in PATH. Extension mode requires qsv to be installed. Please install from https://github.com/dathere/qsv#installation and ensure it's in your system PATH.`,
317+
},
318+
};
280319
}
281320

282-
// Fall back to 'qsv' (will work if in PATH, otherwise will fail)
321+
// Legacy MCP mode: Fall back to 'qsv' (will work if in PATH, otherwise will fail)
283322
const fallbackPath = 'qsv';
284323
const validation = validateQsvBinary(fallbackPath);
285324
return { path: fallbackPath, validation };
@@ -330,7 +369,7 @@ export const config = {
330369
allowedDirs: (() => {
331370
const envValue = process.env['QSV_MCP_ALLOWED_DIRS'];
332371
// Treat empty, undefined, or unexpanded template as empty array
333-
if (!envValue || envValue.trim() === '' || envValue.includes('${user_config.')) {
372+
if (!envValue || envValue.trim() === '' || UNEXPANDED_TEMPLATE_REGEX.test(envValue)) {
334373
return [];
335374
}
336375

.claude/skills/src/mcp-filesystem.ts

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,11 @@ import type { McpResource, McpResourceContent, FileInfo, FileMetadata } from './
1212
import { formatBytes } from './utils.js';
1313
import { config } from './config.js';
1414

15+
/**
16+
* Cache expiration time in milliseconds (1 minute)
17+
*/
18+
const METADATA_CACHE_TTL_MS = 60000;
19+
1520
export interface FilesystemConfig {
1621
/**
1722
* Working directory for relative paths (defaults to process.cwd())
@@ -261,7 +266,7 @@ export class FilesystemResourceProvider {
261266
if (cached) {
262267
const age = Date.now() - cached.cachedAt;
263268
// Cache for 60 seconds
264-
if (age < 60000) {
269+
if (age < METADATA_CACHE_TTL_MS) {
265270
console.error(`[Filesystem] Using cached metadata for ${basename(filePath)} (age: ${Math.round(age / 1000)}s)`);
266271
return cached;
267272
}
@@ -324,7 +329,19 @@ export class FilesystemResourceProvider {
324329
}
325330

326331
/**
327-
* Run a qsv command and return stdout
332+
* Execute a qsv command and return its stdout output
333+
*
334+
* @param args - Command arguments to pass to qsv binary
335+
* @returns Promise resolving to the command's stdout output
336+
* @throws Error if the command fails or exits with non-zero code
337+
*
338+
* @remarks
339+
* This method spawns the qsv binary specified in config and accumulates
340+
* stdout/stderr output in memory. For commands that may produce large
341+
* outputs, consider adding size limits or streaming mechanisms.
342+
*
343+
* Note: This method does not set a timeout, so it may hang indefinitely
344+
* if the qsv process becomes unresponsive.
328345
*/
329346
private async runQsvCommand(args: string[]): Promise<string> {
330347
return new Promise((resolve, reject) => {

.claude/skills/src/mcp-server.ts

Lines changed: 45 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,6 @@ import {
1313
ListResourcesRequestSchema,
1414
ListToolsRequestSchema,
1515
ReadResourceRequestSchema,
16-
ListPromptsRequestSchema,
17-
GetPromptRequestSchema,
1816
} from '@modelcontextprotocol/sdk/types.js';
1917

2018
import { SkillLoader } from './loader.js';
@@ -90,10 +88,17 @@ class QsvMcpServer {
9088
* Initialize the server and register handlers
9189
*/
9290
async initialize(): Promise<void> {
91+
console.error('');
92+
console.error('='.repeat(60));
93+
console.error('QSV MCP SERVER INITIALIZATION');
94+
console.error('='.repeat(60));
95+
console.error('');
96+
9397
// Load all skills
94-
console.error('Loading QSV skills...');
98+
console.error('[Init] Loading QSV skills...');
9599
const skills = await this.loader.loadAll();
96-
console.error(`Loaded ${skills.size} skills`);
100+
console.error(`[Init] ✓ Loaded ${skills.size} skills`);
101+
console.error('');
97102

98103
// Validate qsv binary
99104
this.logQsvValidation();
@@ -102,22 +107,21 @@ class QsvMcpServer {
102107
await this.checkForUpdates();
103108

104109
// Register tool handlers
105-
console.error('About to register tool handlers...');
110+
console.error('[Init] Registering tool handlers...');
106111
this.registerToolHandlers();
107-
console.error('Tool handlers registered');
112+
console.error('[Init] ✓ Tool handlers registered');
113+
console.error('');
108114

109115
// Register resource handlers
110-
console.error('About to register resource handlers...');
116+
console.error('[Init] Registering resource handlers...');
111117
this.registerResourceHandlers();
112-
console.error('Resource handlers registered');
113-
114-
// Prompts converted to tools (qsv_welcome and qsv_examples)
115-
// Prompt handlers no longer needed
116-
// console.error('About to register prompt handlers...');
117-
// this.registerPromptHandlers();
118-
// console.error('Prompt handlers registered');
118+
console.error('[Init] ✓ Resource handlers registered');
119+
console.error('');
119120

120-
console.error('QSV MCP Server initialized successfully');
121+
console.error('='.repeat(60));
122+
console.error('✅ QSV MCP SERVER READY');
123+
console.error('='.repeat(60));
124+
console.error('');
121125
}
122126

123127
/**
@@ -137,13 +141,23 @@ class QsvMcpServer {
137141
console.error('❌ qsv binary validation FAILED');
138142
console.error(` ${validation.error}`);
139143
console.error('');
140-
console.error('⚠️ The MCP server may not function correctly without a valid qsv binary');
144+
console.error('⚠️ The extension will not function without a valid qsv binary');
141145
console.error('');
142-
console.error('To fix this:');
143-
console.error(' 1. Install qsv from: https://github.com/dathere/qsv#installation');
144-
console.error(' 2. Ensure qsv is in your PATH, or');
145-
console.error(' 3. Set QSV_MCP_BIN_PATH to the absolute path of your qsv binary');
146-
console.error(' 4. Restart the MCP server or Claude Desktop');
146+
147+
if (config.isExtensionMode) {
148+
console.error('To fix this in Claude Desktop:');
149+
console.error(' 1. Install qsv from: https://github.com/dathere/qsv#installation');
150+
console.error(' 2. Ensure qsv is in your system PATH');
151+
console.error(' 3. Open Claude Desktop Settings > Extensions > qsv');
152+
console.error(` 4. Update "qsv Binary Path" to the correct path (or leave as "qsv" if in PATH)`);
153+
console.error(' 5. Save settings (extension will auto-restart and re-validate)');
154+
} else {
155+
console.error('To fix this:');
156+
console.error(' 1. Install qsv from: https://github.com/dathere/qsv#installation');
157+
console.error(' 2. Ensure qsv is in your PATH, or');
158+
console.error(' 3. Set QSV_MCP_BIN_PATH to the absolute path of your qsv binary');
159+
console.error(' 4. Restart the MCP server');
160+
}
147161
console.error('');
148162
}
149163
}
@@ -224,13 +238,15 @@ class QsvMcpServer {
224238
if (skill) {
225239
const toolDef = createToolDefinition(skill);
226240
tools.push(toolDef);
241+
console.error(`[Server] ✓ Loaded tool: ${toolDef.name}`);
227242
} else {
228-
console.error(`Warning: Failed to load skill ${skillName}`);
243+
console.error(`[Server] ✗ Failed to load skill: ${skillName}`);
229244
}
230245
} catch (error) {
231-
console.error(`Error creating tool definition for ${skillName}:`, error);
246+
console.error(`[Server] ✗ Error creating tool definition for ${skillName}:`, error);
232247
}
233248
}
249+
console.error(`[Server] Loaded ${tools.length} common command tools`);
234250

235251
// Add generic qsv_command tool
236252
console.error('[Server] Adding generic command tool...');
@@ -311,9 +327,13 @@ class QsvMcpServer {
311327
},
312328
});
313329

314-
console.error(`Registered ${tools.length} tools`);
330+
console.error(`[Server] Registered ${tools.length} tools`);
331+
console.error(`[Server] Tool names: ${tools.map(t => t.name).join(', ')}`);
332+
333+
const response = { tools };
334+
console.error(`[Server] Returning ${response.tools.length} tools to client`);
315335

316-
return { tools };
336+
return response;
317337
});
318338
console.error('[Server] Tool handlers registered successfully');
319339
} catch (error) {

.claude/skills/src/mcp-tools.ts

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,11 @@ import { formatBytes, findSimilarFiles } from './utils.js';
1818
*/
1919
const AUTO_INDEX_SIZE_MB = 10;
2020

21+
/**
22+
* Maximum number of files to show in welcome message
23+
*/
24+
const MAX_WELCOME_FILES = 10;
25+
2126
/**
2227
* Commands that always return full CSV data and should use temp files
2328
*/
@@ -304,6 +309,11 @@ export function createToolDefinition(skill: QsvSkill): McpToolDefinition {
304309
// Add positional arguments
305310
if (skill.command.args && Array.isArray(skill.command.args)) {
306311
for (const arg of skill.command.args) {
312+
// Skip 'input' argument - we already have 'input_file' which maps to this
313+
if (arg.name === 'input') {
314+
continue;
315+
}
316+
307317
properties[arg.name] = {
308318
type: mapArgumentType(arg.type),
309319
description: arg.description,
@@ -916,8 +926,7 @@ export async function handleWelcomeTool(filesystemProvider?: FilesystemProviderE
916926
const { resources } = await filesystemProvider.listFiles(undefined, false);
917927

918928
if (resources.length > 0) {
919-
const maxFiles = 10;
920-
const filesToShow = resources.slice(0, maxFiles);
929+
const filesToShow = resources.slice(0, MAX_WELCOME_FILES);
921930
const workingDir = filesystemProvider.getWorkingDirectory();
922931

923932
fileListingSection = `\n## 📁 Available Files in Your Working Directory
@@ -950,11 +959,13 @@ I found ${resources.length} file${resources.length !== 1 ? 's' : ''} in \`${work
950959
fileListingSection += `| ${fileName} | ${fileSize} | ${fileType} | ${fileDate} |\n`;
951960
});
952961

953-
if (resources.length > maxFiles) {
954-
fileListingSection += `\n_... and ${resources.length - maxFiles} more file${resources.length - maxFiles !== 1 ? 's' : ''}_\n`;
962+
if (resources.length > MAX_WELCOME_FILES) {
963+
fileListingSection += `\n_... and ${resources.length - MAX_WELCOME_FILES} more file${resources.length - MAX_WELCOME_FILES !== 1 ? 's' : ''}_\n`;
955964
}
956965

957-
fileListingSection += `\n**Tip:** Use these file names in qsv commands, for example:\n- \`qsv_stats with input_file: "${filesToShow[0].name}"\`\n- \`qsv_headers with input_file: "${filesToShow[0].name}"\`\n`;
966+
if (filesToShow.length > 0) {
967+
fileListingSection += `\n**Tip:** Use these file names in qsv commands, for example:\n- \`qsv_stats with input_file: "${filesToShow[0].name}"\`\n- \`qsv_headers with input_file: "${filesToShow[0].name}"\`\n`;
968+
}
958969
}
959970
} catch (error) {
960971
console.error('Error listing files for welcome tool:', error);

0 commit comments

Comments
 (0)