feat: implement Obsidian MCP Bundle MVP (Phase 1-3)

- Complete project setup with TypeScript, Jest, MCPB manifest
- Implement foundational infrastructure (CLI executor, logger, error handler)
- Add 9 file operation tools for User Story 1
- Full MCP protocol compliance with stdio transport
- Input validation and sanitization for security
- Comprehensive error handling with actionable messages
- Constitutional compliance: all 6 principles satisfied

MVP includes:
- obsidian_create_note, read, append, prepend, delete, move, rename, open, file_info
- Zod validation schemas for all parameters
- 30s timeout configuration with per-command overrides
- Stderr-only logging with sanitized output
- Graceful shutdown handling

Build:  0 errors, 0 vulnerabilities
Tasks: 48/167 complete (MVP milestone)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
2026-03-22 11:21:38 -05:00
parent e9e0112240
commit 622b28e42c
35 changed files with 5139 additions and 35 deletions

117
src/cli/executor.ts Normal file
View File

@@ -0,0 +1,117 @@
/**
* CLI Executor for Obsidian commands
* Constitutional Principle IV: Defensive Programming
* Uses spawn for better streaming and timeout control
*/
import { spawn } from 'child_process';
import { CLICommand, CLIResult } from '../utils/types.js';
import { logger } from '../utils/logger.js';
import { getCommandTimeout } from '../config/timeouts.js';
/**
* Execute an Obsidian CLI command with timeout
*/
export async function executeCommand(cmd: CLICommand): Promise<CLIResult> {
const timeout = cmd.timeout || getCommandTimeout(cmd.command);
logger.debug('Executing CLI command', {
command: cmd.command,
argCount: cmd.args.length,
timeout,
});
return new Promise((resolve) => {
const child = spawn(cmd.command, cmd.args, {
cwd: cmd.cwd || process.cwd(),
shell: true,
});
let stdout = '';
let stderr = '';
let timedOut = false;
// Set timeout
const timeoutId = setTimeout(() => {
timedOut = true;
child.kill('SIGTERM');
logger.warn('CLI command timed out', {
command: cmd.command,
timeout,
});
}, timeout);
// Collect stdout
child.stdout?.on('data', (data) => {
stdout += data.toString();
});
// Collect stderr
child.stderr?.on('data', (data) => {
stderr += data.toString();
});
// Handle completion
child.on('close', (code) => {
clearTimeout(timeoutId);
const result: CLIResult = {
stdout: stdout.trim(),
stderr: stderr.trim(),
exitCode: code || 0,
timedOut,
};
if (result.exitCode === 0) {
logger.debug('CLI command succeeded', {
command: cmd.command,
outputLength: result.stdout.length,
});
} else {
logger.warn('CLI command failed', {
command: cmd.command,
exitCode: result.exitCode,
timedOut: result.timedOut,
});
}
resolve(result);
});
// Handle spawn errors
child.on('error', (error) => {
clearTimeout(timeoutId);
logger.error('CLI command spawn error', { error: error.message });
resolve({
stdout: '',
stderr: error.message,
exitCode: 1,
timedOut: false,
});
});
});
}
/**
* Execute Obsidian CLI command with vault context
*/
export async function executeObsidianCommand(
subcommand: string,
args: string[] = [],
options?: { timeout?: number }
): Promise<CLIResult> {
const vaultName = process.env.OBSIDIAN_VAULT;
if (!vaultName) {
throw new Error('OBSIDIAN_VAULT environment variable not set');
}
// Build full command: obsidian <subcommand> --vault <vault_name> <args>
const fullArgs = [subcommand, '--vault', vaultName, ...args];
return executeCommand({
command: 'obsidian',
args: fullArgs,
timeout: options?.timeout,
});
}