Files
obsidian-mcp/src/tools/file-operations.ts
Peter.Morton 6ee26f1ad8 fix: return binary files as MCP embedded resource instead of BASE64 prefix fixes #9
The previous implementation prefixed base64 with "BASE64:" in a text
response. This updates the response to use the proper MCP embedded
resource format:

  { type: "resource", resource: { uri, mimeType, blob } }

Changes:
- types.ts: extend ToolOutput content union to allow resource items
- file-operations.ts:
  - getMimeType() maps common extensions to MIME types, falling back
    to application/octet-stream
  - MIME_TYPES table covers PDF, ZIP, images, Office formats, audio/video
  - Binary files are now returned as an EmbeddedResource with:
      uri:      obsidian://<vault>/<path>
      mimeType: detected from file extension
      blob:     base64-encoded raw bytes from the Buffer
  - Tool descriptions updated to document the resource response shape

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-30 18:52:57 -05:00

601 lines
22 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, 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<string, string> = {
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<void> {
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 });
}