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:
185
src/utils/error-handler.ts
Normal file
185
src/utils/error-handler.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user