Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3948cd6966 | |||
| cef658ce19 | |||
| 31744d01a1 | |||
| 6ee26f1ad8 | |||
| 825ad133a0 | |||
| ef02d14f18 | |||
| 4067520cd8 | |||
| 8bc0094604 | |||
| d27abcfca4 | |||
| ec507531ce | |||
| 96b44ac97f | |||
| fe12e00e03 | |||
| 3922056b25 |
57
CHANGELOG.md
57
CHANGELOG.md
@@ -73,6 +73,53 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
- Comprehensive input schema definitions
|
- Comprehensive input schema definitions
|
||||||
- Security audit of parameter handling
|
- Security audit of parameter handling
|
||||||
|
|
||||||
|
## [1.1.9] - 2026-04-30
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **Images returned as MCP image content type**: Updated binary file handling so image files (PNG, JPG, JPEG, GIF, WEBP, SVG) are returned as `{ type: "image", data: "<base64>", mimeType: "..." }` per the MCP 2025-11-25 spec for image content in tool results
|
||||||
|
- Non-image binary files (PDF, ZIP, DOCX, etc.) continue to use the embedded resource format `{ type: "resource", resource: { uri, mimeType, blob } }`
|
||||||
|
|
||||||
|
## [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
|
||||||
|
- **`obsidian_create_note` parameter semantics**: Fixed issue #8 where ambiguous `path` description led callers to pass the full file path (including filename) in `path`, causing the CLI to misbehave and the `overwrite` flag to auto-suffix filenames instead of replacing them
|
||||||
|
- `name` is now clearly documented as the **filename only** (e.g. `"My Note.md"`)
|
||||||
|
- `path` is now clearly documented as the **folder only** (e.g. `"Projects/Work"`) — never include the filename
|
||||||
|
- Added `required: ['name']` to the input schema
|
||||||
|
|
||||||
|
## [1.1.5] - 2026-04-28
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **Mermaid Arrows and HTML Preserved**: Fixed issue #7 where `<` and `>` were being stripped from note content, breaking Mermaid diagram connectors (`->>`, `-->`, `<|`, `>>`) and HTML tags
|
||||||
|
- `<` and `>` are only meaningful as shell redirects at the command level — inside double-quoted strings (how all values are passed) they are completely inert
|
||||||
|
- Removed from `DANGEROUS_CHARS` in both `sanitizeString` and `sanitizePath`
|
||||||
|
|
||||||
|
## [1.1.4] - 2026-04-28
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **Markdown Code Fence Preservation**: Fixed issue #6 where backticks were being stripped from note content, destroying Markdown code fences (` ``` `)
|
||||||
|
- Backticks are now escaped as `` \` `` inside double-quoted CLI parameter strings instead of being removed
|
||||||
|
- This preserves code fences and inline code in note content while still preventing shell command substitution via backticks
|
||||||
|
- Affects all tools that pass content: create, append, prepend, etc.
|
||||||
|
|
||||||
## [1.1.3] - 2026-04-17
|
## [1.1.3] - 2026-04-17
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
@@ -128,6 +175,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
## Version History
|
## 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)
|
||||||
- **1.1.3** - Bug fix release: Large file chunking for obsidian_read_note; docs clarification for Obsidian must be running (fixes #4, #5)
|
- **1.1.3** - Bug fix release: Large file chunking for obsidian_read_note; docs clarification for Obsidian must be running (fixes #4, #5)
|
||||||
- **1.1.2** - Bug fix release: Ampersand support in filenames (fixes #2)
|
- **1.1.2** - Bug fix release: Ampersand support in filenames (fixes #2)
|
||||||
- **1.1.1** - Bug fix release: Quote escaping in note content
|
- **1.1.1** - Bug fix release: Quote escaping in note content
|
||||||
@@ -137,6 +189,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
- Search & Discovery (12 tools)
|
- Search & Discovery (12 tools)
|
||||||
- Task & Property Management (8 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
|
||||||
[1.1.3]: https://git.mortons.site/Peter.Morton/obsidian-mcp/releases/tag/v1.1.3
|
[1.1.3]: https://git.mortons.site/Peter.Morton/obsidian-mcp/releases/tag/v1.1.3
|
||||||
[1.1.2]: https://git.mortons.site/Peter.Morton/obsidian-mcp/releases/tag/v1.1.2
|
[1.1.2]: https://git.mortons.site/Peter.Morton/obsidian-mcp/releases/tag/v1.1.2
|
||||||
[1.1.1]: https://git.mortons.site/Peter.Morton/obsidian-mcp/releases/tag/v1.1.1
|
[1.1.1]: https://git.mortons.site/Peter.Morton/obsidian-mcp/releases/tag/v1.1.1
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"manifest_version": "0.3",
|
"manifest_version": "0.3",
|
||||||
"name": "obsidian-mcp",
|
"name": "obsidian-mcp",
|
||||||
"version": "1.1.3",
|
"version": "1.1.9",
|
||||||
"display_name": "Obsidian CLI Bundle",
|
"display_name": "Obsidian CLI Bundle",
|
||||||
"description": "MCP Bundle for Obsidian CLI - Enable AI assistants to manage Obsidian vaults through conversational interface",
|
"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.",
|
"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.",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "obsidian-mcp",
|
"name": "obsidian-mcp",
|
||||||
"version": "1.1.3",
|
"version": "1.1.9",
|
||||||
"description": "MCP Bundle for Obsidian CLI - Enable AI assistants to manage Obsidian vaults through Model Context Protocol",
|
"description": "MCP Bundle for Obsidian CLI - Enable AI assistants to manage Obsidian vaults through Model Context Protocol",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
|
|||||||
@@ -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(
|
export async function executeObsidianCommand(
|
||||||
subcommand: string,
|
subcommand: string,
|
||||||
args: string[] = [],
|
args: string[] = [],
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { ObsidianMCPServer, createToolHandler } from '../server.js';
|
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 { formatForMCP } from '../cli/parser.js';
|
||||||
import { handleCLIResult } from '../utils/error-handler.js';
|
import { handleCLIResult } from '../utils/error-handler.js';
|
||||||
import { logger } from '../utils/logger.js';
|
import { logger } from '../utils/logger.js';
|
||||||
@@ -19,6 +19,55 @@ import {
|
|||||||
import { sanitizeParameters } from '../validation/sanitizer.js';
|
import { sanitizeParameters } from '../validation/sanitizer.js';
|
||||||
import { formatParam } from '../utils/cli-helpers.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';
|
||||||
|
}
|
||||||
|
|
||||||
|
function isImageMimeType(mimeType: string): boolean {
|
||||||
|
return mimeType.startsWith('image/');
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Register all file operation tools
|
* Register all file operation tools
|
||||||
*/
|
*/
|
||||||
@@ -28,17 +77,18 @@ export async function registerFileOperationTools(server: ObsidianMCPServer): Pro
|
|||||||
// T029: Create note tool
|
// T029: Create note tool
|
||||||
server.registerTool(
|
server.registerTool(
|
||||||
'obsidian_create_note',
|
'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.',
|
'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',
|
type: 'object',
|
||||||
|
required: ['name'],
|
||||||
properties: {
|
properties: {
|
||||||
name: {
|
name: {
|
||||||
type: 'string',
|
type: 'string',
|
||||||
description: 'File name for the new note',
|
description: 'Filename for the new note (e.g. "My Note.md"). Do not include folder path here.',
|
||||||
},
|
},
|
||||||
path: {
|
path: {
|
||||||
type: 'string',
|
type: 'string',
|
||||||
description: 'Full file path (alternative to name)',
|
description: 'Folder path where the note will be created (e.g. "Projects/Work"). Do not include the filename here. Omit for vault root.',
|
||||||
},
|
},
|
||||||
content: {
|
content: {
|
||||||
type: 'string',
|
type: 'string',
|
||||||
@@ -50,7 +100,7 @@ export async function registerFileOperationTools(server: ObsidianMCPServer): Pro
|
|||||||
},
|
},
|
||||||
overwrite: {
|
overwrite: {
|
||||||
type: 'boolean',
|
type: 'boolean',
|
||||||
description: 'Overwrite if file exists (optional)',
|
description: 'Replace the file if it already exists (optional)',
|
||||||
},
|
},
|
||||||
open: {
|
open: {
|
||||||
type: 'boolean',
|
type: 'boolean',
|
||||||
@@ -66,14 +116,15 @@ export async function registerFileOperationTools(server: ObsidianMCPServer): Pro
|
|||||||
'Create a new note in the Obsidian vault',
|
'Create a new note in the Obsidian vault',
|
||||||
{
|
{
|
||||||
type: 'object',
|
type: 'object',
|
||||||
|
required: ['name'],
|
||||||
properties: {
|
properties: {
|
||||||
name: {
|
name: {
|
||||||
type: 'string',
|
type: 'string',
|
||||||
description: 'File name for the new note',
|
description: 'Filename for the new note (e.g. "My Note.md"). Do not include folder path here.',
|
||||||
},
|
},
|
||||||
path: {
|
path: {
|
||||||
type: 'string',
|
type: 'string',
|
||||||
description: 'Full file path (alternative to name)',
|
description: 'Folder path where the note will be created (e.g. "Projects/Work"). Do not include the filename here. Omit for vault root.',
|
||||||
},
|
},
|
||||||
content: {
|
content: {
|
||||||
type: 'string',
|
type: 'string',
|
||||||
@@ -85,7 +136,7 @@ export async function registerFileOperationTools(server: ObsidianMCPServer): Pro
|
|||||||
},
|
},
|
||||||
overwrite: {
|
overwrite: {
|
||||||
type: 'boolean',
|
type: 'boolean',
|
||||||
description: 'Overwrite if file exists (optional)',
|
description: 'Replace the file if it already exists (optional)',
|
||||||
},
|
},
|
||||||
open: {
|
open: {
|
||||||
type: 'boolean',
|
type: 'boolean',
|
||||||
@@ -102,8 +153,6 @@ export async function registerFileOperationTools(server: ObsidianMCPServer): Pro
|
|||||||
const sanitized = sanitizeParameters(validated) as any;
|
const sanitized = sanitizeParameters(validated) as any;
|
||||||
|
|
||||||
const cmdArgs: string[] = [];
|
const cmdArgs: string[] = [];
|
||||||
|
|
||||||
// Add name or path parameter
|
|
||||||
if (sanitized.name) cmdArgs.push(formatParam('name', sanitized.name as 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.path) cmdArgs.push(formatParam('path', sanitized.path as string));
|
||||||
if (sanitized.content) cmdArgs.push(formatParam('content', sanitized.content as string));
|
if (sanitized.content) cmdArgs.push(formatParam('content', sanitized.content as string));
|
||||||
@@ -130,7 +179,7 @@ export async function registerFileOperationTools(server: ObsidianMCPServer): Pro
|
|||||||
// T031: Read note tool
|
// T031: Read note tool
|
||||||
server.registerTool(
|
server.registerTool(
|
||||||
'obsidian_read_note',
|
'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 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.',
|
||||||
{
|
{
|
||||||
type: 'object',
|
type: 'object',
|
||||||
properties: {
|
properties: {
|
||||||
@@ -153,7 +202,7 @@ export async function registerFileOperationTools(server: ObsidianMCPServer): Pro
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
createToolHandler(
|
createToolHandler(
|
||||||
'Read the content of a note',
|
'Read the content of a note. Images (PNG, JPG, GIF, WEBP, SVG) are returned as MCP image content (type: "image") with base64-encoded data. Other binary files are returned as MCP embedded resource content (type: "resource") with uri, mimeType, and base64-encoded blob.',
|
||||||
{
|
{
|
||||||
type: 'object',
|
type: 'object',
|
||||||
properties: {
|
properties: {
|
||||||
@@ -183,14 +232,52 @@ export async function registerFileOperationTools(server: ObsidianMCPServer): Pro
|
|||||||
if (sanitized.file) cmdArgs.push(formatParam('file', sanitized.file as 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.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 });
|
handleCLIResult(result, { operation: 'read_note', identifier: sanitized.file || sanitized.path });
|
||||||
|
|
||||||
|
// 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,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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)}`;
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'resource' as const,
|
||||||
|
resource: {
|
||||||
|
uri,
|
||||||
|
mimeType,
|
||||||
|
blob: base64,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const offset: number = validated.offset ?? 0;
|
const offset: number = validated.offset ?? 0;
|
||||||
const maxChars: number = validated.max_chars ?? 50000;
|
const maxChars: number = validated.max_chars ?? 50000;
|
||||||
const fullContent = result.stdout;
|
const raw = result.stdout;
|
||||||
const totalChars = fullContent.length;
|
const totalChars = raw.length;
|
||||||
const chunk = fullContent.slice(offset, offset + maxChars);
|
const chunk = raw.slice(offset, offset + maxChars);
|
||||||
const isTruncated = offset + maxChars < totalChars;
|
const isTruncated = offset + maxChars < totalChars;
|
||||||
|
|
||||||
let text = chunk;
|
let text = chunk;
|
||||||
|
|||||||
@@ -14,9 +14,12 @@ export function formatParam(key: string, value: string | number): string {
|
|||||||
// Always quote string values to handle spaces and special characters safely
|
// Always quote string values to handle spaces and special characters safely
|
||||||
// Note: Obsidian CLI docs say: "Quote values with spaces: name="My Note""
|
// Note: Obsidian CLI docs say: "Quote values with spaces: name="My Note""
|
||||||
|
|
||||||
// Escape any double quotes in the value to prevent shell interpretation issues
|
// Escape double quotes and backticks to prevent shell interpretation inside double-quoted strings.
|
||||||
// This prevents truncation when content contains quotes like "Bot QM"
|
// In bash double-quoted strings: \" prevents quote termination, \` prevents command substitution.
|
||||||
const escapedValue = String(value).replace(/"/g, '\\"');
|
// This preserves Markdown code fences (``` ` ```) while blocking injection via backticks.
|
||||||
|
const escapedValue = String(value)
|
||||||
|
.replace(/"/g, '\\"')
|
||||||
|
.replace(/`/g, '\\`');
|
||||||
|
|
||||||
return `${key}="${escapedValue}"`;
|
return `${key}="${escapedValue}"`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,10 +16,12 @@ export interface ToolInput {
|
|||||||
* Successful tool output
|
* Successful tool output
|
||||||
*/
|
*/
|
||||||
export interface ToolOutput {
|
export interface ToolOutput {
|
||||||
content: Array<{
|
content: Array<
|
||||||
type: 'text';
|
| { type: 'text'; text: string }
|
||||||
text: string;
|
| { type: 'image'; data: string; mimeType: string }
|
||||||
}>;
|
| { type: 'audio'; data: string; mimeType: string }
|
||||||
|
| { type: 'resource'; resource: { uri: string; mimeType?: string; blob: string } }
|
||||||
|
>;
|
||||||
isError?: false;
|
isError?: false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,6 +65,7 @@ export enum ObsidianErrorType {
|
|||||||
*/
|
*/
|
||||||
export interface CLIResult {
|
export interface CLIResult {
|
||||||
stdout: string;
|
stdout: string;
|
||||||
|
stdoutBuffer?: Buffer;
|
||||||
stderr: string;
|
stderr: string;
|
||||||
exitCode: number;
|
exitCode: number;
|
||||||
timedOut?: boolean;
|
timedOut?: boolean;
|
||||||
|
|||||||
@@ -11,13 +11,16 @@ import { logger } from '../utils/logger.js';
|
|||||||
* Note: Brackets [], parentheses (), and braces {} are safe because values are quoted and passed as array args
|
* Note: Brackets [], parentheses (), and braces {} are safe because values are quoted and passed as array args
|
||||||
* They're essential for Obsidian markdown (wikilinks [[link]], tasks - [ ] Task, templates {{...}}, etc.)
|
* They're essential for Obsidian markdown (wikilinks [[link]], tasks - [ ] Task, templates {{...}}, etc.)
|
||||||
* Note: Single & is safe in quoted args (filenames like "Research & Development.md")
|
* Note: Single & is safe in quoted args (filenames like "Research & Development.md")
|
||||||
* We only block: ; | ` $ < > (command separators, pipes, substitution, redirects)
|
* Note: Backticks are safe because formatParam escapes them as \` inside double-quoted strings,
|
||||||
|
* preventing shell command substitution while preserving Markdown code fences (``` ```)
|
||||||
|
* Note: < and > are safe inside double-quoted strings — shell redirection only applies at the
|
||||||
|
* command level, not inside quotes. Stripping them breaks Mermaid arrows (->>, -->) and HTML.
|
||||||
|
* We only block: ; | $ (command separators, pipes, variable substitution)
|
||||||
* Command injection patterns (&&, ||, etc.) are handled separately
|
* Command injection patterns (&&, ||, etc.) are handled separately
|
||||||
*/
|
*/
|
||||||
const DANGEROUS_CHARS = /[;|`$<>]/g;
|
const DANGEROUS_CHARS = /[;|$]/g;
|
||||||
const COMMAND_INJECTION_PATTERNS = [
|
const COMMAND_INJECTION_PATTERNS = [
|
||||||
/\$\(/g, // Command substitution $(...)
|
/\$\(/g, // Command substitution $(...)
|
||||||
/`[^`]*`/g, // Command substitution `...`
|
|
||||||
/\|\|/g, // OR operator
|
/\|\|/g, // OR operator
|
||||||
/&&/g, // AND operator
|
/&&/g, // AND operator
|
||||||
/;/g, // Command separator
|
/;/g, // Command separator
|
||||||
@@ -73,7 +76,8 @@ export function sanitizePath(path: string): string {
|
|||||||
|
|
||||||
// Remove dangerous characters but allow path separators
|
// Remove dangerous characters but allow path separators
|
||||||
// Note: Brackets, parentheses, braces, and single & are safe in paths (quoted args)
|
// Note: Brackets, parentheses, braces, and single & are safe in paths (quoted args)
|
||||||
sanitized = sanitized.replace(/[;|`$<>]/g, '');
|
// Note: < and > are safe inside double-quoted strings (not shell redirects)
|
||||||
|
sanitized = sanitized.replace(/[;|`$]/g, '');
|
||||||
|
|
||||||
return sanitized;
|
return sanitized;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user