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

185
src/utils/error-handler.ts Normal file
View File

@@ -0,0 +1,185 @@
/**
* Error handler for Obsidian CLI operations
* Maps CLI errors to MCP error codes with actionable messages
* Constitutional Principle IV: Defensive Programming
*/
import {
CLIResult,
ErrorResponse,
MCPErrorCode,
ObsidianErrorType,
} from './types.js';
import { logger } from './logger.js';
/**
* Error class for Obsidian operations
*/
export class ObsidianError extends Error {
constructor(
public type: ObsidianErrorType,
message: string,
public details?: unknown
) {
super(message);
this.name = 'ObsidianError';
}
}
/**
* Map CLI result to error type
*/
export function detectErrorType(result: CLIResult): ObsidianErrorType | null {
const stderr = result.stderr.toLowerCase();
if (result.timedOut) {
return ObsidianErrorType.CLI_TIMEOUT;
}
if (stderr.includes('not found') || stderr.includes('does not exist')) {
if (stderr.includes('vault')) {
return ObsidianErrorType.VAULT_NOT_FOUND;
}
return ObsidianErrorType.FILE_NOT_FOUND;
}
if (
stderr.includes('obsidian is not running') ||
stderr.includes('not running') ||
stderr.includes('cannot connect')
) {
return ObsidianErrorType.OBSIDIAN_NOT_RUNNING;
}
if (
stderr.includes('multiple') &&
(stderr.includes('found') || stderr.includes('match'))
) {
return ObsidianErrorType.AMBIGUOUS_NAME;
}
if (result.exitCode !== 0) {
return ObsidianErrorType.CLI_ERROR;
}
return null;
}
/**
* Create actionable error message based on error type
*/
export function createErrorMessage(
type: ObsidianErrorType,
originalError: string,
context?: Record<string, unknown>
): string {
switch (type) {
case ObsidianErrorType.FILE_NOT_FOUND:
return `Note not found. ${originalError}\n\nTip: Use the exact note name or full path. Check for typos or case sensitivity.`;
case ObsidianErrorType.VAULT_NOT_FOUND:
return `Vault not found. ${originalError}\n\nTip: Ensure the vault name in your configuration matches exactly (case-sensitive). Use 'obsidian vault list' to see available vaults.`;
case ObsidianErrorType.OBSIDIAN_NOT_RUNNING:
return `Obsidian is not running. Please start the Obsidian application and try again.\n\nOriginal error: ${originalError}`;
case ObsidianErrorType.AMBIGUOUS_NAME:
return `Multiple notes found with the same name. ${originalError}\n\nTip: Specify the exact path to the note to avoid ambiguity.`;
case ObsidianErrorType.CLI_TIMEOUT:
return `Operation timed out after ${context?.timeout || 30} seconds.\n\nTip: The operation may still be running. Check Obsidian, or try again with a larger timeout if the vault is very large.`;
case ObsidianErrorType.CLI_ERROR:
return `Obsidian CLI error: ${originalError}\n\nTip: Check that the Obsidian CLI is properly installed and configured.`;
case ObsidianErrorType.VALIDATION_ERROR:
return `Invalid parameters: ${originalError}\n\nTip: Check that all required parameters are provided and in the correct format.`;
default:
return `An unexpected error occurred: ${originalError}`;
}
}
/**
* Map error type to MCP error code
*/
export function getMCPErrorCode(type: ObsidianErrorType): MCPErrorCode {
switch (type) {
case ObsidianErrorType.VALIDATION_ERROR:
return MCPErrorCode.InvalidParams;
case ObsidianErrorType.FILE_NOT_FOUND:
case ObsidianErrorType.VAULT_NOT_FOUND:
case ObsidianErrorType.OBSIDIAN_NOT_RUNNING:
case ObsidianErrorType.AMBIGUOUS_NAME:
return MCPErrorCode.InvalidParams;
case ObsidianErrorType.CLI_TIMEOUT:
case ObsidianErrorType.CLI_ERROR:
default:
return MCPErrorCode.InternalError;
}
}
/**
* Handle CLI result and throw error if failed
*/
export function handleCLIResult(result: CLIResult, context?: Record<string, unknown>): void {
const errorType = detectErrorType(result);
if (errorType) {
const errorMessage = createErrorMessage(errorType, result.stderr, context);
logger.error('CLI operation failed', {
type: errorType,
exitCode: result.exitCode,
timedOut: result.timedOut,
context,
});
throw new ObsidianError(errorType, errorMessage, { result, context });
}
}
/**
* Create MCP error response from ObsidianError
*/
export function createErrorResponse(error: unknown): ErrorResponse {
if (error instanceof ObsidianError) {
return {
content: [
{
type: 'text',
text: error.message,
},
],
isError: true,
};
}
// Handle validation errors from Zod
if (error instanceof Error && error.name === 'ZodError') {
return {
content: [
{
type: 'text',
text: createErrorMessage(
ObsidianErrorType.VALIDATION_ERROR,
error.message
),
},
],
isError: true,
};
}
// Generic error fallback
const message = error instanceof Error ? error.message : String(error);
logger.error('Unexpected error', { error });
return {
content: [
{
type: 'text',
text: `An unexpected error occurred: ${message}`,
},
],
isError: true,
};
}