Files
obsidian-mcp/src/tools/file-operations.ts
Peter.Morton a0801a82fd fix: chunk large note reads to prevent output-too-large errors (fixes #5)
Add offset and max_chars parameters to obsidian_read_note:
- max_chars (default 50000, max 500000): caps characters returned per call
- offset (default 0): start position for reading, enabling pagination

When content is truncated a trailer message is appended telling the
caller the total size and the exact offset to pass on the next call.

This prevents the 26MB+ responses that caused Claude to reject output
when reading large PDFs stored in an Obsidian vault.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-17 17:36:33 -05:00

534 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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';
import { formatParam } from '../utils/cli-helpers.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: {
name: {
type: 'string',
description: 'File name for the new note',
},
path: {
type: 'string',
description: 'Full file path (alternative to name)',
},
content: {
type: 'string',
description: 'Initial content for the note (optional)',
},
template: {
type: 'string',
description: 'Template name to use (optional)',
},
overwrite: {
type: 'boolean',
description: 'Overwrite if file 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',
properties: {
name: {
type: 'string',
description: 'File name for the new note',
},
path: {
type: 'string',
description: 'Full file path (alternative to name)',
},
content: {
type: 'string',
description: 'Initial content for the note (optional)',
},
template: {
type: 'string',
description: 'Template name to use (optional)',
},
overwrite: {
type: 'boolean',
description: 'Overwrite if file 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[] = [];
// Add name or path parameter
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.',
{
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',
{
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));
const result = await executeObsidianCommand('read', cmdArgs);
handleCLIResult(result, { operation: 'read_note', identifier: sanitized.file || sanitized.path });
const offset: number = validated.offset ?? 0;
const maxChars: number = validated.max_chars ?? 50000;
const fullContent = result.stdout;
const totalChars = fullContent.length;
const chunk = fullContent.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 });
}