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>
601 lines
22 KiB
TypeScript
601 lines
22 KiB
TypeScript
/**
|
||
* 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 });
|
||
}
|