fix: read binary files via native FS instead of Obsidian CLI
The Obsidian CLI does not support binary file output, causing corrupted
or empty results for images, PDFs, ZIPs, etc.
New approach for binary files (known MIME type from extension):
- Run 'obsidian vault info=path' to get the vault root filesystem path
- If 'file' param: run 'obsidian file file=<name>' and parse the 'path'
field from its output to get the vault-relative path
- If 'path' param: use it directly as the vault-relative path
- Read the file with Node fs.readFile() and return as MCP content
Images -> { type: 'image', data, mimeType }
Other binary -> { type: 'resource', resource: { uri, mimeType, blob } }
Unknown extensions fall through to the CLI with a runtime binary
detection fallback that also uses native FS if triggered.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
1
banner.base64
Normal file
1
banner.base64
Normal file
File diff suppressed because one or more lines are too long
BIN
banner.png
Normal file
BIN
banner.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 115 KiB |
1
banner0.base64
Normal file
1
banner0.base64
Normal file
File diff suppressed because one or more lines are too long
@@ -9,6 +9,7 @@
|
||||
"server": "node dist/http-server.js",
|
||||
"validate-manifest": "mcpb validate manifest.json",
|
||||
"pack": "npm run build && mcpb pack",
|
||||
"inspect": "npx @modelcontextprotocol/inspector node dist/index.js",
|
||||
"test": "jest",
|
||||
"dev": "tsc --watch",
|
||||
"validate-tools": "node scripts/validate-tools.js"
|
||||
|
||||
@@ -3,8 +3,10 @@
|
||||
* User Story 1 (P1 - MVP): Enable basic note-taking workflows
|
||||
*/
|
||||
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { ObsidianMCPServer, createToolHandler } from '../server.js';
|
||||
import { executeObsidianCommand, executeObsidianCommandBinary } from '../cli/executor.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';
|
||||
@@ -68,6 +70,52 @@ function isImageMimeType(mimeType: string): boolean {
|
||||
return mimeType.startsWith('image/');
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true when the MIME type is a known binary format.
|
||||
* application/octet-stream (unknown extension) returns false so we fall
|
||||
* through to the CLI and let isBinaryContent() decide at runtime.
|
||||
*/
|
||||
function isBinaryMimeType(mimeType: string): boolean {
|
||||
return !mimeType.startsWith('text/') && mimeType !== 'application/octet-stream';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the vault's root filesystem path via `obsidian vault info=path`.
|
||||
*/
|
||||
async function getVaultRootPath(): Promise<string> {
|
||||
const result = await executeObsidianCommand('vault', ['info=path']);
|
||||
if (result.exitCode !== 0 || !result.stdout) {
|
||||
throw new Error(`Failed to get vault filesystem path: ${result.stderr || 'no output'}`);
|
||||
}
|
||||
return result.stdout.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the vault-relative path for a note.
|
||||
* - When `path` is provided it is already vault-relative, so return it directly.
|
||||
* - When `file` (wikilink-style name) is provided, run `obsidian file file=<name>`
|
||||
* and parse the `path` field from its tab-separated output.
|
||||
*/
|
||||
async function resolveVaultRelativePath(file?: string, path?: string): Promise<string> {
|
||||
if (path) {
|
||||
return path;
|
||||
}
|
||||
if (file) {
|
||||
const result = await executeObsidianCommand('file', [formatParam('file', file)]);
|
||||
if (result.exitCode !== 0) {
|
||||
throw new Error(`File "${file}" not found in vault: ${result.stderr}`);
|
||||
}
|
||||
for (const line of result.stdout.split('\n')) {
|
||||
const [key, value] = line.split('\t');
|
||||
if (key?.trim() === 'path' && value?.trim()) {
|
||||
return value.trim();
|
||||
}
|
||||
}
|
||||
throw new Error(`Could not resolve vault path for file "${file}"`);
|
||||
}
|
||||
throw new Error('Either file or path must be provided');
|
||||
}
|
||||
|
||||
/**
|
||||
* Register all file operation tools
|
||||
*/
|
||||
@@ -179,7 +227,7 @@ export async function registerFileOperationTools(server: ObsidianMCPServer): Pro
|
||||
// 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 are automatically detected: images (PNG, JPG, GIF, WEBP, SVG) are returned as MCP image content (type: "image") with base64-encoded data and mimeType; all other binary files (ZIP, PDF, DOCX, etc.) are returned as MCP embedded resource content (type: "resource") with uri, mimeType, and base64-encoded blob.',
|
||||
'Read the content of a note from the Obsidian vault. Specify either the note name (file) or full path (path). For large text files, use max_chars and offset to read in chunks and avoid exceeding context limits. Binary files are read natively from the filesystem (bypassing the CLI): images (PNG, JPG, GIF, WEBP, SVG) are returned as MCP image content (type: "image") with base64-encoded data and mimeType; all other binary files (ZIP, PDF, DOCX, etc.) are returned as MCP embedded resource content (type: "resource") with uri, mimeType, and base64-encoded blob.',
|
||||
{
|
||||
type: 'object',
|
||||
properties: {
|
||||
@@ -228,46 +276,62 @@ export async function registerFileOperationTools(server: ObsidianMCPServer): Pro
|
||||
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 identifier = (sanitized.file || sanitized.path) as string;
|
||||
const mimeType = getMimeType(identifier);
|
||||
|
||||
// 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 });
|
||||
// For known binary MIME types, bypass the CLI entirely and read via native FS.
|
||||
// The Obsidian CLI does not support binary file output.
|
||||
if (isBinaryMimeType(mimeType)) {
|
||||
logger.debug('Binary file detected by extension, using native FS', { identifier, mimeType });
|
||||
const vaultRoot = await getVaultRootPath();
|
||||
const relPath = await resolveVaultRelativePath(sanitized.file, sanitized.path);
|
||||
const fullPath = join(vaultRoot, relPath);
|
||||
const buf = await readFile(fullPath);
|
||||
const base64 = buf.toString('base64');
|
||||
const vaultName = process.env.OBSIDIAN_VAULT ?? 'vault';
|
||||
|
||||
// Detect binary content from the raw buffer and return as spec-appropriate MCP content
|
||||
if (result.stdoutBuffer && isBinaryContent(result.stdoutBuffer)) {
|
||||
const identifier = sanitized.file || sanitized.path as string;
|
||||
const mimeType = getMimeType(identifier);
|
||||
const base64 = result.stdoutBuffer.toString('base64');
|
||||
|
||||
// Images use the MCP image content type per spec
|
||||
if (isImageMimeType(mimeType)) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'image' as const,
|
||||
data: base64,
|
||||
mimeType,
|
||||
},
|
||||
],
|
||||
content: [{ type: 'image' as const, data: base64, mimeType }],
|
||||
};
|
||||
}
|
||||
|
||||
// All other binary files use the embedded resource content type per spec
|
||||
const vaultName = process.env.OBSIDIAN_VAULT ?? 'vault';
|
||||
const uri = `obsidian://${encodeURIComponent(vaultName)}/${encodeURIComponent(identifier)}`;
|
||||
const uri = `obsidian://${encodeURIComponent(vaultName)}/${encodeURIComponent(relPath)}`;
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'resource' as const,
|
||||
resource: {
|
||||
uri,
|
||||
mimeType,
|
||||
blob: base64,
|
||||
},
|
||||
resource: { uri, mimeType, blob: base64 },
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
// Text file — use the CLI
|
||||
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 });
|
||||
|
||||
// Fallback: CLI returned binary bytes for an unknown-extension file
|
||||
const rawBuf = Buffer.from(result.stdout, 'utf8');
|
||||
if (isBinaryContent(rawBuf)) {
|
||||
logger.debug('Binary content detected at runtime, using native FS fallback', { identifier });
|
||||
const vaultRoot = await getVaultRootPath();
|
||||
const relPath = await resolveVaultRelativePath(sanitized.file, sanitized.path);
|
||||
const fullPath = join(vaultRoot, relPath);
|
||||
const buf = await readFile(fullPath);
|
||||
const base64 = buf.toString('base64');
|
||||
const vaultName = process.env.OBSIDIAN_VAULT ?? 'vault';
|
||||
const fallbackMime = getMimeType(relPath);
|
||||
const uri = `obsidian://${encodeURIComponent(vaultName)}/${encodeURIComponent(relPath)}`;
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'resource' as const,
|
||||
resource: { uri, mimeType: fallbackMime, blob: base64 },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user