/** * File Operations Tools * User Story 1 (P1 - MVP): Enable basic note-taking workflows */ import { ObsidianMCPServer, createToolHandler } from '../server.js'; import { executeObsidianCommand, executeObsidianCommandBinary } 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'; import { formatParam } from '../utils/cli-helpers.js'; /** * Detect binary content from a raw Buffer. * Checks for null bytes (definitive binary marker) or a high ratio of * non-printable characters in the first 8KB (catches ZIP, images, compiled files, etc.). */ function isBinaryContent(buf: Buffer): boolean { if (buf.length === 0) return false; const sample = buf.slice(0, 8192); // Null bytes are never present in valid UTF-8 text if (sample.includes(0x00)) return true; let nonPrintable = 0; for (let i = 0; i < sample.length; i++) { const byte = sample[i]; // Allow tab (9), newline (10), carriage return (13), and standard printable ASCII if (byte !== 9 && byte !== 10 && byte !== 13 && (byte < 32 || byte === 127)) { nonPrintable++; } } return nonPrintable / sample.length > 0.1; } /** Map common file extensions to MIME types */ const MIME_TYPES: Record = { pdf: 'application/pdf', zip: 'application/zip', gz: 'application/gzip', tar: 'application/x-tar', png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif', webp: 'image/webp', svg: 'image/svg+xml', mp3: 'audio/mpeg', mp4: 'video/mp4', docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation', }; function getMimeType(filePath: string): string { const ext = filePath.split('.').pop()?.toLowerCase() ?? ''; return MIME_TYPES[ext] ?? 'application/octet-stream'; } /** * Register all file operation tools */ export async function registerFileOperationTools(server: ObsidianMCPServer): Promise { logger.info('Registering file operation tools'); // T029: Create note tool server.registerTool( 'obsidian_create_note', 'Create a new note in the Obsidian vault. "name" is the filename only (e.g. "My Note.md"). "path" is the folder only (e.g. "Projects/Work") — do not include the filename in path. When overwrite is true the existing file is replaced.', { type: 'object', required: ['name'], properties: { name: { type: 'string', description: 'Filename for the new note (e.g. "My Note.md"). Do not include folder path here.', }, path: { type: 'string', description: 'Folder path where the note will be created (e.g. "Projects/Work"). Do not include the filename here. Omit for vault root.', }, content: { type: 'string', description: 'Initial content for the note (optional)', }, template: { type: 'string', description: 'Template name to use (optional)', }, overwrite: { type: 'boolean', description: 'Replace the file if it already exists (optional)', }, open: { type: 'boolean', description: 'Open file after creating (optional)', }, newtab: { type: 'boolean', description: 'Open in new tab (optional)', }, }, }, createToolHandler( 'Create a new note in the Obsidian vault', { type: 'object', required: ['name'], properties: { name: { type: 'string', description: 'Filename for the new note (e.g. "My Note.md"). Do not include folder path here.', }, path: { type: 'string', description: 'Folder path where the note will be created (e.g. "Projects/Work"). Do not include the filename here. Omit for vault root.', }, content: { type: 'string', description: 'Initial content for the note (optional)', }, template: { type: 'string', description: 'Template name to use (optional)', }, overwrite: { type: 'boolean', description: 'Replace the file if it already exists (optional)', }, open: { type: 'boolean', description: 'Open file after creating (optional)', }, newtab: { type: 'boolean', description: 'Open in new tab (optional)', }, }, }, async (args) => { const validated = createNoteSchema.parse(args); const sanitized = sanitizeParameters(validated) as any; const cmdArgs: string[] = []; if (sanitized.name) cmdArgs.push(formatParam('name', sanitized.name as string)); if (sanitized.path) cmdArgs.push(formatParam('path', sanitized.path as string)); if (sanitized.content) cmdArgs.push(formatParam('content', sanitized.content as string)); if (sanitized.template) cmdArgs.push(formatParam('template', sanitized.template as string)); if (sanitized.overwrite) cmdArgs.push('overwrite'); if (sanitized.open) cmdArgs.push('open'); if (sanitized.newtab) cmdArgs.push('newtab'); const result = await executeObsidianCommand('create', 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). For large files (e.g. PDFs), use max_chars and offset to read in chunks and avoid exceeding context limits. Binary files (ZIP, images, PDFs, etc.) are automatically detected and returned as an MCP embedded resource with a uri, mimeType, and base64-encoded blob field instead of plain text.', { type: 'object', properties: { file: { type: 'string', description: 'File name (resolves like wikilinks)', }, path: { type: 'string', description: 'Exact file path (folder/note.md)', }, max_chars: { type: 'number', description: 'Maximum characters to return (default: 50000, max: 500000). Use to avoid output-too-large errors on big files.', }, offset: { type: 'number', description: 'Character offset to start reading from (default: 0). Use with max_chars to page through large files.', }, }, }, createToolHandler( 'Read the content of a note. Binary files are returned as an MCP embedded resource (type: "resource") with uri, mimeType, and a base64-encoded blob field.', { type: 'object', properties: { file: { type: 'string', description: 'File name (resolves like wikilinks)', }, path: { type: 'string', description: 'Exact file path (folder/note.md)', }, max_chars: { type: 'number', description: 'Maximum characters to return (default: 50000, max: 500000)', }, offset: { type: 'number', description: 'Character offset to start reading from (default: 0)', }, }, }, async (args) => { const validated = readNoteSchema.parse(args) as any; const sanitized = sanitizeParameters(validated) as any; const cmdArgs: string[] = []; if (sanitized.file) cmdArgs.push(formatParam('file', sanitized.file as string)); if (sanitized.path) cmdArgs.push(formatParam('path', sanitized.path as string)); // Use binary-safe executor so stdout is collected as a raw Buffer, // preventing UTF-8 decoding from corrupting binary file content. const result = await executeObsidianCommandBinary('read', cmdArgs); handleCLIResult(result, { operation: 'read_note', identifier: sanitized.file || sanitized.path }); // Detect binary content from the raw buffer and return as MCP resource if (result.stdoutBuffer && isBinaryContent(result.stdoutBuffer)) { const identifier = sanitized.file || sanitized.path as string; const vaultName = process.env.OBSIDIAN_VAULT ?? 'vault'; const uri = `obsidian://${encodeURIComponent(vaultName)}/${encodeURIComponent(identifier)}`; const mimeType = getMimeType(identifier); return { content: [ { type: 'resource' as const, resource: { uri, mimeType, blob: result.stdoutBuffer.toString('base64'), }, }, ], }; } const offset: number = validated.offset ?? 0; const maxChars: number = validated.max_chars ?? 50000; const raw = result.stdout; const totalChars = raw.length; const chunk = raw.slice(offset, offset + maxChars); const isTruncated = offset + maxChars < totalChars; let text = chunk; if (isTruncated) { const nextOffset = offset + maxChars; text += `\n\n[Content truncated: showing characters ${offset}–${offset + chunk.length} of ${totalChars} total. To read the next chunk, call obsidian_read_note again with offset=${nextOffset}.]`; } return { content: [ { type: 'text', 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', required: ['content'], properties: { file: { type: 'string', description: 'File name (resolves like wikilinks)', }, path: { type: 'string', description: 'Exact file path (folder/note.md)', }, content: { type: 'string', description: 'Content to append (required)', }, inline: { type: 'boolean', description: 'Append without newline (optional)', }, }, }, createToolHandler( 'Append content to the end of a note', { type: 'object', required: ['content'], properties: { file: { type: 'string', description: 'File name (resolves like wikilinks)', }, path: { type: 'string', description: 'Exact file path (folder/note.md)', }, content: { type: 'string', description: 'Content to append (required)', }, inline: { type: 'boolean', description: 'Append without newline (optional)', }, }, }, async (args) => { const validated = appendPrependSchema.parse(args) as any; const sanitized = sanitizeParameters(validated) as any; const cmdArgs: string[] = []; if (sanitized.file) cmdArgs.push(formatParam('file', sanitized.file as string)); if (sanitized.path) cmdArgs.push(formatParam('path', sanitized.path as string)); cmdArgs.push(formatParam('content', sanitized.content as string)); if (sanitized.inline) cmdArgs.push('inline'); const result = await executeObsidianCommand('append', cmdArgs); handleCLIResult(result, { operation: 'append', identifier: sanitized.file || sanitized.path }); 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. Use inline flag to prepend without a new line.', { type: 'object', required: ['content'], properties: { file: { type: 'string', description: 'File name (resolves like wikilinks)' }, path: { type: 'string', description: 'Exact file path (folder/note.md)' }, content: { type: 'string', description: 'Content to prepend (required)' }, inline: { type: 'boolean', description: 'Prepend without newline (optional)' }, }, }, createToolHandler( 'Prepend content to the beginning of a note', { type: 'object', required: ['content'], properties: { file: { type: 'string', description: 'File name (resolves like wikilinks)' }, path: { type: 'string', description: 'Exact file path (folder/note.md)' }, content: { type: 'string', description: 'Content to prepend (required)' }, inline: { type: 'boolean', description: 'Prepend without newline (optional)' }, }, }, async (args) => { const validated = appendPrependSchema.parse(args) as any; const sanitized = sanitizeParameters(validated) as any; const cmdArgs: string[] = []; if (sanitized.file) cmdArgs.push(formatParam('file', sanitized.file as string)); if (sanitized.path) cmdArgs.push(formatParam('path', sanitized.path as string)); cmdArgs.push(formatParam('content', sanitized.content as string)); if (sanitized.inline) cmdArgs.push('inline'); const result = await executeObsidianCommand('prepend', cmdArgs); handleCLIResult(result, { operation: 'prepend', identifier: sanitized.file || sanitized.path }); 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: { file: { type: 'string', description: 'File name (resolves like wikilinks)' }, path: { type: 'string', description: 'Exact file path (folder/note.md)' }, permanent: { type: 'boolean', description: 'Skip trash, delete permanently (optional)' }, }, }, createToolHandler( 'Delete a note from the vault', { type: 'object', properties: { file: { type: 'string', description: 'File name (resolves like wikilinks)' }, path: { type: 'string', description: 'Exact file path (folder/note.md)' }, permanent: { type: 'boolean', description: 'Skip trash, delete permanently (optional)' }, }, }, async (args) => { const validated = deleteNoteSchema.parse(args) as any; const sanitized = sanitizeParameters(validated) as any; const cmdArgs: string[] = []; if (sanitized.file) cmdArgs.push(formatParam('file', sanitized.file as string)); if (sanitized.path) cmdArgs.push(formatParam('path', sanitized.path as string)); if (sanitized.permanent) cmdArgs.push('permanent'); const result = await executeObsidianCommand('delete', cmdArgs); handleCLIResult(result, { operation: 'delete', identifier: sanitized.file || sanitized.path }); 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 destination path (to).', { type: 'object', required: ['to'], properties: { file: { type: 'string', description: 'File name (resolves like wikilinks)' }, path: { type: 'string', description: 'Exact file path (folder/note.md)' }, to: { type: 'string', description: 'Destination folder or path (required)' }, }, }, createToolHandler( 'Move a note to a different location', { type: 'object', required: ['to'], properties: { file: { type: 'string', description: 'File name (resolves like wikilinks)' }, path: { type: 'string', description: 'Exact file path (folder/note.md)' }, to: { type: 'string', description: 'Destination folder or path (required)' }, }, }, async (args) => { const validated = moveRenameSchema.parse(args) as any; const sanitized = sanitizeParameters(validated) as any; const cmdArgs: string[] = []; if (sanitized.file) cmdArgs.push(formatParam('file', sanitized.file as string)); if (sanitized.path) cmdArgs.push(formatParam('path', sanitized.path as string)); cmdArgs.push(formatParam('to', (sanitized.to || sanitized.newPath) as string)); const result = await executeObsidianCommand('move', cmdArgs); handleCLIResult(result, { operation: 'move', identifier: sanitized.file || sanitized.path, to: sanitized.to }); 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.', { type: 'object', required: ['name'], properties: { file: { type: 'string', description: 'File name (resolves like wikilinks)' }, path: { type: 'string', description: 'Exact file path (folder/note.md)' }, name: { type: 'string', description: 'New file name (required)' }, }, }, createToolHandler( 'Rename a note', { type: 'object', required: ['name'], properties: { file: { type: 'string', description: 'File name (resolves like wikilinks)' }, path: { type: 'string', description: 'Exact file path (folder/note.md)' }, name: { type: 'string', description: 'New file name (required)' }, }, }, async (args) => { const validated = moveRenameSchema.parse(args) as any; const sanitized = sanitizeParameters(validated) as any; const cmdArgs: string[] = []; if (sanitized.file) cmdArgs.push(formatParam('file', sanitized.file as string)); if (sanitized.path) cmdArgs.push(formatParam('path', sanitized.path as string)); cmdArgs.push(formatParam('name', (sanitized.name || sanitized.newName) as string)); const result = await executeObsidianCommand('rename', cmdArgs); handleCLIResult(result, { operation: 'rename', identifier: sanitized.file || sanitized.path, name: sanitized.name }); 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: { file: { type: 'string', description: 'File name (resolves like wikilinks)' }, path: { type: 'string', description: 'Exact file path (folder/note.md)' }, newtab: { type: 'boolean', description: 'Open in new tab (optional)' }, }, }, createToolHandler( 'Open a note in Obsidian', { type: 'object', properties: { file: { type: 'string', description: 'File name (resolves like wikilinks)' }, path: { type: 'string', description: 'Exact file path (folder/note.md)' }, newtab: { type: 'boolean', description: 'Open in new tab (optional)' }, }, }, async (args) => { const validated = fileIdentifierSchema.parse(args) as any; const sanitized = sanitizeParameters(validated) as any; const cmdArgs: string[] = []; if (sanitized.file) cmdArgs.push(formatParam('file', sanitized.file as string)); if (sanitized.path) cmdArgs.push(formatParam('path', sanitized.path as string)); if ((args as any).newtab) cmdArgs.push('newtab'); const result = await executeObsidianCommand('open', cmdArgs); handleCLIResult(result, { operation: 'open', identifier: sanitized.file || sanitized.path }); return { content: [ { type: 'text', text: formatForMCP(result.stdout, 'text'), }, ], }; } ) ); // Note: obsidian_duplicate_note and obsidian_get_file_info were removed // as they are not in the actual Obsidian CLI specification logger.info('File operation tools registered', { count: 8 }); }