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:
307
src/tools/file-operations.ts
Normal file
307
src/tools/file-operations.ts
Normal file
@@ -0,0 +1,307 @@
|
||||
/**
|
||||
* File Operations Tools
|
||||
* User Story 1 (P1 - MVP): Enable basic note-taking workflows
|
||||
*/
|
||||
|
||||
import { ObsidianMCPServer, createToolHandler } from '../server.js';
|
||||
import { executeObsidianCommand } from '../cli/executor.js';
|
||||
import { formatForMCP } from '../cli/parser.js';
|
||||
import { handleCLIResult } from '../utils/error-handler.js';
|
||||
import { logger } from '../utils/logger.js';
|
||||
import {
|
||||
createNoteSchema,
|
||||
readNoteSchema,
|
||||
appendPrependSchema,
|
||||
deleteNoteSchema,
|
||||
moveRenameSchema,
|
||||
fileIdentifierSchema,
|
||||
} from '../validation/schemas.js';
|
||||
import { sanitizeParameters } from '../validation/sanitizer.js';
|
||||
|
||||
/**
|
||||
* Register all file operation tools
|
||||
*/
|
||||
export async function registerFileOperationTools(server: ObsidianMCPServer): Promise<void> {
|
||||
logger.info('Registering file operation tools');
|
||||
|
||||
// T029: Create note tool
|
||||
server.registerTool(
|
||||
'obsidian_create_note',
|
||||
'Create a new note in the Obsidian vault. Optionally specify path, content, template, and whether to overwrite existing notes or open after creation.',
|
||||
{ type: 'object', properties: {} },
|
||||
createToolHandler(
|
||||
'Create a new note in the Obsidian vault',
|
||||
{ type: 'object', properties: {} },
|
||||
async (args) => {
|
||||
const validated = createNoteSchema.parse(args);
|
||||
const sanitized = sanitizeParameters(validated) as any;
|
||||
|
||||
const cmdArgs: string[] = ['create-note', sanitized.name as string];
|
||||
if (sanitized.path) cmdArgs.push('--path', sanitized.path as string);
|
||||
if (sanitized.content) cmdArgs.push('--content', sanitized.content as string);
|
||||
if (sanitized.template) cmdArgs.push('--template', sanitized.template as string);
|
||||
if (sanitized.overwrite) cmdArgs.push('--overwrite');
|
||||
if (sanitized.open) cmdArgs.push('--open');
|
||||
|
||||
const result = await executeObsidianCommand('note', cmdArgs);
|
||||
handleCLIResult(result, { operation: 'create_note', name: sanitized.name });
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: formatForMCP(result.stdout, 'text'),
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
// T031: Read note tool
|
||||
server.registerTool(
|
||||
'obsidian_read_note',
|
||||
'Read the content of a note from the Obsidian vault. Specify either the note name (file) or full path (path).',
|
||||
{ type: 'object', properties: {} },
|
||||
createToolHandler(
|
||||
'Read the content of a note',
|
||||
{ type: 'object', properties: {} },
|
||||
async (args) => {
|
||||
const validated = readNoteSchema.parse(args) as any;
|
||||
const sanitized = sanitizeParameters(validated) as any;
|
||||
|
||||
const identifier = sanitized.file || sanitized.path;
|
||||
const cmdArgs: string[] = ['read-note', identifier as string];
|
||||
|
||||
const result = await executeObsidianCommand('note', cmdArgs);
|
||||
handleCLIResult(result, { operation: 'read_note', identifier });
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: formatForMCP(result.stdout, 'text'),
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
// T033: Append to note tool
|
||||
server.registerTool(
|
||||
'obsidian_append_to_note',
|
||||
'Append content to the end of an existing note. Specify either file name or path, and the content to append. Use inline flag to append without a new line.',
|
||||
{ type: 'object', properties: {} },
|
||||
createToolHandler(
|
||||
'Append content to the end of a note',
|
||||
{ type: 'object', properties: {} },
|
||||
async (args) => {
|
||||
const validated = appendPrependSchema.parse(args) as any;
|
||||
const sanitized = sanitizeParameters(validated) as any;
|
||||
|
||||
const identifier = sanitized.file || sanitized.path;
|
||||
const cmdArgs: string[] = ['append', identifier as string, '--content', sanitized.content as string];
|
||||
if (sanitized.inline) cmdArgs.push('--inline');
|
||||
|
||||
const result = await executeObsidianCommand('note', cmdArgs);
|
||||
handleCLIResult(result, { operation: 'append', identifier });
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: formatForMCP(result.stdout, 'text'),
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
// T035: Prepend to note tool
|
||||
server.registerTool(
|
||||
'obsidian_prepend_to_note',
|
||||
'Prepend content to the beginning of an existing note. Specify either file name or path, and the content to prepend.',
|
||||
{ type: 'object', properties: {} },
|
||||
createToolHandler(
|
||||
'Prepend content to the beginning of a note',
|
||||
{ type: 'object', properties: {} },
|
||||
async (args) => {
|
||||
const validated = appendPrependSchema.parse(args) as any;
|
||||
const sanitized = sanitizeParameters(validated) as any;
|
||||
|
||||
const identifier = sanitized.file || sanitized.path;
|
||||
const cmdArgs: string[] = ['prepend', identifier as string, '--content', sanitized.content as string];
|
||||
|
||||
const result = await executeObsidianCommand('note', cmdArgs);
|
||||
handleCLIResult(result, { operation: 'prepend', identifier });
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: formatForMCP(result.stdout, 'text'),
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
// T036: Delete note tool
|
||||
server.registerTool(
|
||||
'obsidian_delete_note',
|
||||
'Delete a note from the Obsidian vault. By default moves to trash; use permanent flag for permanent deletion. Specify either file name or path.',
|
||||
{ type: 'object', properties: {} },
|
||||
createToolHandler(
|
||||
'Delete a note from the vault',
|
||||
{ type: 'object', properties: {} },
|
||||
async (args) => {
|
||||
const validated = deleteNoteSchema.parse(args) as any;
|
||||
const sanitized = sanitizeParameters(validated) as any;
|
||||
|
||||
const identifier = sanitized.file || sanitized.path;
|
||||
const cmdArgs: string[] = ['delete', identifier as string];
|
||||
if (sanitized.permanent) cmdArgs.push('--permanent');
|
||||
|
||||
const result = await executeObsidianCommand('note', cmdArgs);
|
||||
handleCLIResult(result, { operation: 'delete', identifier });
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: formatForMCP(result.stdout, 'text'),
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
// T037: Move note tool
|
||||
server.registerTool(
|
||||
'obsidian_move_note',
|
||||
'Move a note to a different location in the vault. Specify the current note (file or path) and the new path (newPath).',
|
||||
{ type: 'object', properties: {} },
|
||||
createToolHandler(
|
||||
'Move a note to a different location',
|
||||
{ type: 'object', properties: {} },
|
||||
async (args) => {
|
||||
const validated = moveRenameSchema.parse(args) as any;
|
||||
const sanitized = sanitizeParameters(validated) as any;
|
||||
|
||||
const identifier = sanitized.file || sanitized.path;
|
||||
const newLocation = sanitized.newPath || sanitized.newName;
|
||||
const cmdArgs: string[] = ['move', identifier as string, newLocation as string];
|
||||
|
||||
const result = await executeObsidianCommand('note', cmdArgs);
|
||||
handleCLIResult(result, { operation: 'move', identifier, newLocation });
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: formatForMCP(result.stdout, 'text'),
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
// T038: Rename note tool
|
||||
server.registerTool(
|
||||
'obsidian_rename_note',
|
||||
'Rename a note in the vault. Specify the current note (file or path) and the new name (newName).',
|
||||
{ type: 'object', properties: {} },
|
||||
createToolHandler(
|
||||
'Rename a note',
|
||||
{ type: 'object', properties: {} },
|
||||
async (args) => {
|
||||
const validated = moveRenameSchema.parse(args) as any;
|
||||
const sanitized = sanitizeParameters(validated) as any;
|
||||
|
||||
const identifier = sanitized.file || sanitized.path;
|
||||
const newName = sanitized.newName || sanitized.newPath;
|
||||
const cmdArgs: string[] = ['rename', identifier as string, newName as string];
|
||||
|
||||
const result = await executeObsidianCommand('note', cmdArgs);
|
||||
handleCLIResult(result, { operation: 'rename', identifier, newName });
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: formatForMCP(result.stdout, 'text'),
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
// T039: Open note tool
|
||||
server.registerTool(
|
||||
'obsidian_open_note',
|
||||
'Open a note in the Obsidian application. Specify either file name or path. Use newtab flag to open in a new tab.',
|
||||
{ type: 'object', properties: {} },
|
||||
createToolHandler(
|
||||
'Open a note in Obsidian',
|
||||
{ type: 'object', properties: {} },
|
||||
async (args) => {
|
||||
const validated = fileIdentifierSchema.parse(args) as any;
|
||||
const sanitized = sanitizeParameters(validated) as any;
|
||||
|
||||
const identifier = sanitized.file || sanitized.path;
|
||||
const cmdArgs: string[] = ['open', identifier as string];
|
||||
if ((args as any).newtab) cmdArgs.push('--newtab');
|
||||
|
||||
const result = await executeObsidianCommand('note', cmdArgs);
|
||||
handleCLIResult(result, { operation: 'open', identifier });
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: formatForMCP(result.stdout, 'text'),
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
// T040: Get file info tool
|
||||
server.registerTool(
|
||||
'obsidian_get_file_info',
|
||||
'Get metadata and information about a note file. Specify either file name or path. Returns file size, dates, path, and other metadata.',
|
||||
{ type: 'object', properties: {} },
|
||||
createToolHandler(
|
||||
'Get information about a note file',
|
||||
{ type: 'object', properties: {} },
|
||||
async (args) => {
|
||||
const validated = fileIdentifierSchema.parse(args) as any;
|
||||
const sanitized = sanitizeParameters(validated) as any;
|
||||
|
||||
const identifier = sanitized.file || sanitized.path;
|
||||
const cmdArgs: string[] = ['info', identifier as string];
|
||||
|
||||
const result = await executeObsidianCommand('note', cmdArgs);
|
||||
handleCLIResult(result, { operation: 'file_info', identifier });
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: formatForMCP(result.stdout, 'text'),
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
logger.info('File operation tools registered', { count: 9 });
|
||||
}
|
||||
25
src/tools/index.ts
Normal file
25
src/tools/index.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* Tool Registry
|
||||
* Centralized registration of all MCP tools
|
||||
*/
|
||||
|
||||
import { ObsidianMCPServer } from '../server.js';
|
||||
import { logger } from '../utils/logger.js';
|
||||
import { registerFileOperationTools } from './file-operations.js';
|
||||
|
||||
/**
|
||||
* Register all tools with the MCP server
|
||||
*/
|
||||
export async function registerAllTools(server: ObsidianMCPServer): Promise<void> {
|
||||
logger.info('Registering MCP tools');
|
||||
|
||||
// Phase 3: User Story 1 - File Operations (MVP)
|
||||
await registerFileOperationTools(server);
|
||||
|
||||
// TODO: Phase 4: User Story 2 - Search & Discovery
|
||||
// TODO: Phase 5: User Story 3 - Task & Property Management
|
||||
// TODO: Phase 6: User Story 4 - Vault Navigation
|
||||
// TODO: Phase 7: User Story 5 - Advanced Features
|
||||
|
||||
logger.info('All tools registered successfully');
|
||||
}
|
||||
Reference in New Issue
Block a user