Files
obsidian-mcp/src/tools/file-operations.ts
Peter.Morton 8e2e8f858c fix: properly quote CLI parameters to handle filenames with spaces
Fixed parameter quoting in file operations and search tools to handle
filenames and values containing spaces correctly.

Root Cause:
- Obsidian CLI requires quoting values with spaces: name="My Note"
- Previous implementation used unquoted format: name=My Note
- Shell would split on spaces, breaking multi-word filenames

Solution:
1. Created formatParam() helper in src/utils/cli-helpers.ts
   - Always quotes parameter values: param="value"
   - Handles spaces and special characters safely

2. Updated file-operations.ts (8 tools):
   - All file/path/content/name parameters now quoted
   - create, read, append, prepend, delete, move, rename, open

3. Updated search.ts (1 tool):
   - query, path, format, limit parameters now quoted
   - Fixes searches with multi-word queries

Changes:
- Before: cmdArgs.push(\`file=${name}\`)
- After: cmdArgs.push(formatParam('file', name))

Files changed:
- src/utils/cli-helpers.ts (new): formatParam() and buildCmdArgs() helpers
- src/tools/file-operations.ts: Use formatParam() for all parameters
- src/tools/search.ts: Use formatParam() for all parameters

Impact:
- File operations now work with multi-word filenames
- Search queries with spaces now work correctly
- Content parameters with newlines/special chars handled safely

Known Issue:
- links.ts, tags-aliases.ts, properties.ts still need similar fixes
- These tools have additional structural issues (wrong command names)
- Will be addressed in follow-up commit

Build: 0 errors

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-22 13:09:08 -05:00

505 lines
17 KiB
TypeScript

/**
* 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).',
{
type: 'object',
properties: {
file: {
type: 'string',
description: 'File name (resolves like wikilinks)',
},
path: {
type: 'string',
description: 'Exact file path (folder/note.md)',
},
},
},
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)',
},
},
},
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 });
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',
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 });
}