5 Commits

Author SHA1 Message Date
31744d01a1 chore: bump version to 1.1.8 and update CHANGELOG
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-30 18:53:50 -05:00
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
825ad133a0 chore: bump version to 1.1.7 and update CHANGELOG
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-30 18:43:41 -05:00
ef02d14f18 fix: return binary vault files as base64 in obsidian_read_note fixes #9
Previously, binary files (ZIP, images, compiled files) were read via
data.toString() which corrupted the bytes through UTF-8 decoding,
making the content unrecoverable on the client side.

Changes:
- executor.ts: add executeCommandBinary / executeObsidianCommandBinary
  that collect stdout chunks as raw Buffers instead of strings
- types.ts: add optional stdoutBuffer field to CLIResult
- file-operations.ts:
  - obsidian_read_note now uses executeObsidianCommandBinary so the
    raw bytes are preserved before any decoding happens
  - isBinaryContent() now operates on the raw Buffer (null byte check
    + >10% non-printable byte ratio on first 8KB sample)
  - Binary files are returned as "BASE64:<base64string>" so the client
    can reliably decode back to the original binary
  - Tool descriptions updated to document the BASE64: prefix convention

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-30 18:43:00 -05:00
4067520cd8 fix: detect and reject binary files in obsidian_read_note fixes #9
When obsidian_read_note read a binary file (ZIP, image, compiled
binary, etc.) the CLI returned raw bytes that were corrupted by text
decoding, producing an unusable response.

Added isBinaryContent() helper that checks for null bytes (definitive
binary marker) and a >10% ratio of non-printable characters in the
first 8KB of content. When binary content is detected the tool returns
a clear error message instead of garbled bytes.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-30 18:39:32 -05:00
6 changed files with 177 additions and 14 deletions

View File

@@ -73,6 +73,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Comprehensive input schema definitions
- Security audit of parameter handling
## [1.1.8] - 2026-04-30
### Fixed
- **Binary files returned as MCP embedded resource**: Updated issue #9 fix to use the proper MCP `EmbeddedResource` format instead of a `BASE64:` text prefix
- Binary files are now returned as `{ type: "resource", resource: { uri, mimeType, blob } }`
- `uri` is constructed as `obsidian://<vault>/<path>`
- `mimeType` is detected from the file extension (PDF, ZIP, images, Office formats, audio/video; defaults to `application/octet-stream`)
- `blob` contains the base64-encoded raw bytes
## [1.1.7] - 2026-04-30
### Fixed
- **Binary file support in `obsidian_read_note`**: Fixed issue #9 where reading binary files (ZIP, images, compiled files, etc.) returned corrupted content due to UTF-8 decoding of raw bytes
- stdout is now collected as a raw `Buffer` to preserve bytes before any decoding
- Binary files are detected via null byte check and >10% non-printable byte ratio on first 8KB
- Binary content is returned as a `BASE64:<base64string>` — clients must base64-decode the value to recover the original binary file
- Tool description updated to document the `BASE64:` prefix convention
## [1.1.6] - 2026-04-30
### Fixed
@@ -151,6 +169,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## Version History
- **1.1.8** - Bug fix release: Binary files returned as MCP embedded resource (fixes #9)
- **1.1.7** - Bug fix release: Binary files returned as base64 in `obsidian_read_note` (fixes #9)
- **1.1.6** - Bug fix release: Clarify `name` vs `path` semantics in `obsidian_create_note` (fixes #8)
- **1.1.5** - Bug fix release: Preserve `<` and `>` in note content for Mermaid/HTML (fixes #7)
- **1.1.4** - Bug fix release: Preserve Markdown code fences (fixes #6)
@@ -163,6 +183,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Search & Discovery (12 tools)
- Task & Property Management (8 tools)
[1.1.8]: https://git.mortons.site/Peter.Morton/obsidian-mcp/releases/tag/v1.1.8
[1.1.7]: https://git.mortons.site/Peter.Morton/obsidian-mcp/releases/tag/v1.1.7
[1.1.6]: https://git.mortons.site/Peter.Morton/obsidian-mcp/releases/tag/v1.1.6
[1.1.5]: https://git.mortons.site/Peter.Morton/obsidian-mcp/releases/tag/v1.1.5
[1.1.4]: https://git.mortons.site/Peter.Morton/obsidian-mcp/releases/tag/v1.1.4

View File

@@ -1,7 +1,7 @@
{
"manifest_version": "0.3",
"name": "obsidian-mcp",
"version": "1.1.6",
"version": "1.1.8",
"display_name": "Obsidian CLI Bundle",
"description": "MCP Bundle for Obsidian CLI - Enable AI assistants to manage Obsidian vaults through conversational interface",
"long_description": "This MCP bundle provides a comprehensive set of tools for AI assistants to interact with and manage Obsidian vaults. It includes capabilities for creating, reading, updating, and deleting notes, managing links and tags, handling tasks, and more. With this bundle, AI assistants can seamlessly integrate with Obsidian to help users organize their knowledge and workflows.",

View File

@@ -1,6 +1,6 @@
{
"name": "obsidian-mcp",
"version": "1.1.6",
"version": "1.1.8",
"description": "MCP Bundle for Obsidian CLI - Enable AI assistants to manage Obsidian vaults through Model Context Protocol",
"type": "module",
"main": "dist/index.js",

View File

@@ -93,8 +93,81 @@ export async function executeCommand(cmd: CLICommand): Promise<CLIResult> {
}
/**
* Execute Obsidian CLI command with vault context
* Execute an Obsidian CLI command with timeout, collecting stdout as a raw Buffer.
* Use this when the output may be binary (e.g. reading non-text vault files).
*/
export async function executeCommandBinary(cmd: CLICommand): Promise<CLIResult> {
const timeout = cmd.timeout || getCommandTimeout(cmd.command);
return new Promise((resolve) => {
const child = spawn(cmd.command, cmd.args, {
cwd: cmd.cwd || process.cwd(),
shell: true,
});
const stdoutChunks: Buffer[] = [];
let stderr = '';
let timedOut = false;
const timeoutId = setTimeout(() => {
timedOut = true;
child.kill('SIGTERM');
logger.warn('CLI command timed out', { command: cmd.command, timeout });
}, timeout);
child.stdout?.on('data', (data: Buffer) => {
stdoutChunks.push(Buffer.isBuffer(data) ? data : Buffer.from(data));
});
child.stderr?.on('data', (data) => {
stderr += data.toString();
});
child.on('close', (code) => {
clearTimeout(timeoutId);
const stdoutBuffer = Buffer.concat(stdoutChunks);
resolve({
stdout: stdoutBuffer.toString('utf8').trim(),
stdoutBuffer,
stderr: stderr.trim(),
exitCode: code || 0,
timedOut,
});
});
child.on('error', (error) => {
clearTimeout(timeoutId);
logger.error('CLI command spawn error', { error: error.message });
resolve({
stdout: '',
stdoutBuffer: Buffer.alloc(0),
stderr: error.message,
exitCode: 1,
timedOut: false,
});
});
});
}
/**
* Execute Obsidian CLI command with vault context, collecting stdout as a raw Buffer.
*/
export async function executeObsidianCommandBinary(
subcommand: string,
args: string[] = [],
options?: { timeout?: number }
): Promise<CLIResult> {
const vaultName = process.env.OBSIDIAN_VAULT;
if (!vaultName) {
throw new Error('OBSIDIAN_VAULT environment variable not set');
}
const fullArgs = [subcommand, '--vault', vaultName, ...args];
return executeCommandBinary({
command: '/Applications/Obsidian.app/Contents/MacOS/obsidian',
args: fullArgs,
timeout: options?.timeout,
});
}
export async function executeObsidianCommand(
subcommand: string,
args: string[] = [],

View File

@@ -4,7 +4,7 @@
*/
import { ObsidianMCPServer, createToolHandler } from '../server.js';
import { executeObsidianCommand } from '../cli/executor.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';
@@ -19,6 +19,51 @@ import {
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
*/
@@ -130,7 +175,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.',
'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: {
@@ -153,7 +198,7 @@ export async function registerFileOperationTools(server: ObsidianMCPServer): Pro
},
},
createToolHandler(
'Read the content of a note',
'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: {
@@ -183,14 +228,36 @@ export async function registerFileOperationTools(server: ObsidianMCPServer): Pro
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);
// 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 fullContent = result.stdout;
const totalChars = fullContent.length;
const chunk = fullContent.slice(offset, offset + maxChars);
const raw = result.stdout;
const totalChars = raw.length;
const chunk = raw.slice(offset, offset + maxChars);
const isTruncated = offset + maxChars < totalChars;
let text = chunk;

View File

@@ -16,10 +16,10 @@ export interface ToolInput {
* Successful tool output
*/
export interface ToolOutput {
content: Array<{
type: 'text';
text: string;
}>;
content: Array<
| { type: 'text'; text: string }
| { type: 'resource'; resource: { uri: string; mimeType?: string; blob: string } }
>;
isError?: false;
}
@@ -63,6 +63,7 @@ export enum ObsidianErrorType {
*/
export interface CLIResult {
stdout: string;
stdoutBuffer?: Buffer;
stderr: string;
exitCode: number;
timedOut?: boolean;