23 Commits
v1.0.0 ... main

Author SHA1 Message Date
3d91406c2c Merge pull request 'node-server' (#10) from node-server into main
Reviewed-on: #10
2026-04-30 20:46:49 -05:00
253c3a327b chore: bump version to 1.2.0
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-30 20:12:09 -05:00
76b4aed508 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>
2026-04-30 20:08:35 -05:00
8b651d379a feat: add HTTP server transport for MCP
Adds src/http-server.ts - a Node.js HTTP entry point that exposes the
MCP implementation via Streamable HTTP transport (POST/GET /mcp) per
the MCP 2025-11-25 specification.

- Uses StreamableHTTPServerTransport (stateless mode) from the MCP SDK
- Port configurable via MCP_PORT env var (default: 3000)
- ObsidianMCPServer.connect() now accepts an optional Transport param
  so both stdio (existing) and HTTP (new) modes reuse the same server
- Added 'npm run server' script to start the HTTP server

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-30 19:41:51 -05:00
3948cd6966 chore: bump version to 1.1.9
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-30 19:32:52 -05:00
cef658ce19 fix: return images as MCP image content type per spec
Images (PNG, JPG, GIF, WEBP, SVG) now returned as { type: 'image', data, mimeType }
per https://modelcontextprotocol.io/specification/2025-11-25/server/tools#image-content
Other binary files continue using embedded resource format { type: 'resource', ... }
Added audio content type to ToolOutput union for future use

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-30 19:31:47 -05:00
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
8bc0094604 chore: bump version to 1.1.6 and update CHANGELOG
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-30 18:24:08 -05:00
d27abcfca4 fix: clarify name vs path semantics in obsidian_create_note (fixes #8)
The tool descriptions were ambiguous — 'path' was described as a
'full file path (alternative to name)' which led callers to pass the
full path including filename in path, confusing the CLI.

Clarified semantics:
  name = filename only  (e.g. "My Note.md")
  path = folder only    (e.g. "Projects/Work") — never include filename

Added required: ['name'] to both input schemas.
Updated tool and handler descriptions accordingly.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-30 18:20:49 -05:00
ec507531ce chore: bump version to 1.1.5 and update CHANGELOG
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-28 12:22:38 -05:00
96b44ac97f fix: preserve < and > in note content so Mermaid arrows and HTML are not stripped (fixes #7)
< and > were in DANGEROUS_CHARS on the assumption they could trigger
shell redirection. However, shell redirection only applies at the
command level — inside double-quoted strings (which is how all values
are passed via formatParam) they are completely inert.

Removing them from DANGEROUS_CHARS and sanitizePath preserves:
- Mermaid diagram connectors: ->>, -->, <|, >>, etc.
- HTML tags in note content
- Any other angle-bracket syntax

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-28 12:21:24 -05:00
fe12e00e03 chore: bump version to 1.1.4 and update CHANGELOG
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-28 12:16:57 -05:00
3922056b25 fix: preserve Markdown code fences by escaping backticks instead of removing them (fixes #6)
Backticks were being stripped entirely by the sanitizer, destroying
Markdown code fences (```) in note content.

The real injection risk is backtick command substitution inside
double-quoted shell strings (e.g. content=`rm -rf /`). The fix is to
escape backticks as \` in formatParam — exactly as we already do for
double quotes — so the shell never interprets them while the content
is preserved intact.

Changes:
- sanitizer.ts: remove ` from DANGEROUS_CHARS and the backtick command
  substitution pattern from COMMAND_INJECTION_PATTERNS (now handled at
  the quoting layer, not the stripping layer)
- cli-helpers.ts: escape backticks as \` in formatParam alongside the
  existing double-quote escaping

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-28 12:15:28 -05:00
edd445f8ae docs: update CHANGELOG for v1.1.3
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-17 17:55:16 -05:00
c8abde8a88 chore: bump version to 1.1.3
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-17 17:38:55 -05:00
a0801a82fd fix: chunk large note reads to prevent output-too-large errors (fixes #5)
Add offset and max_chars parameters to obsidian_read_note:
- max_chars (default 50000, max 500000): caps characters returned per call
- offset (default 0): start position for reading, enabling pagination

When content is truncated a trailer message is appended telling the
caller the total size and the exact offset to pass on the next call.

This prevents the 26MB+ responses that caused Claude to reject output
when reading large PDFs stored in an Obsidian vault.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-17 17:36:33 -05:00
82d2409fe3 docs: clarify Obsidian must be running before using MCP tools (fixes #4)
- Add prominent note in Prerequisites that Obsidian must be open and running
- Expand Troubleshooting section with explanation of why the error occurs and how to fix it

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-16 09:22:57 -05:00
57b58a0d22 fix: allow ampersands in filenames while blocking command injection (v1.1.2)
Fixes #2 - Files with & in their names (e.g., 'Research & Development.md')
were being incorrectly sanitized, causing search and file-not-found errors.

Changes:
- Removed & from DANGEROUS_CHARS regex
- Single & is safe in quoted arguments passed to CLI
- Dangerous && patterns still blocked by COMMAND_INJECTION_PATTERNS
- Also allows (), [], {} which are safe in quoted args

Version: 1.1.2

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-14 17:05:18 -05:00
466587d1c5 fix: preserve square brackets and escape quotes in note content (v1.1.1)
- Fix square bracket removal: Remove [] from DANGEROUS_CHARS regex
  * Wikilinks ([[link]]) now work correctly
  * Task checkboxes (- [ ] Task) are properly preserved
  * Brackets are safe because values are quoted and passed as array args

- Fix quote truncation: Escape double quotes in formatParam
  * Content like "Bot QM" no longer truncates
  * Internal quotes escaped as \" before wrapping in parameter quotes
  * Prevents shell from misinterpreting quote boundaries

Bump version: 1.0.0 -> 1.1.1

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-10 00:07:13 -05:00
12 changed files with 551 additions and 37 deletions

View File

@@ -73,6 +73,105 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Comprehensive input schema definitions
- Security audit of parameter handling
## [1.2.0] - 2026-04-30
### Added
- **Binary file support in `obsidian_read_note`**: Binary files are now read directly from the filesystem, bypassing the Obsidian CLI which does not support binary output
- Uses `obsidian vault info=path` to resolve the vault root filesystem path
- Uses `obsidian file file=<name>` to resolve wikilink-style names to vault-relative paths
- Images (PNG, JPG, JPEG, GIF, WEBP, SVG) returned as MCP image content `{ type: "image", data, mimeType }`
- Other binary files (PDF, ZIP, DOCX, XLSX, PPTX, etc.) returned as MCP embedded resource `{ type: "resource", resource: { uri, mimeType, blob } }`
- Unknown extensions fall through to the CLI with a runtime binary detection fallback
## [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
### Fixed
- **Large File Chunking**: Fixed issue #5 where reading large files (e.g. PDFs) caused a "Tool result is too large" error in Claude Desktop
- `obsidian_read_note` now returns at most 50,000 characters by default (configurable up to 500,000)
- New `max_chars` parameter caps the number of characters returned per call (default: 50000, max: 500000)
- New `offset` parameter enables pagination — pass the offset from the truncation message to read the next chunk
- When truncated, the response includes a message stating the total size and the exact `offset` to use for the next call
### Documentation
- **Obsidian Must Be Running**: Clarified in README (issue #4) that the Obsidian application must be open and running before any MCP tools are used
- Added prominent callout in Prerequisites section
- Expanded Troubleshooting entry with explanation of root cause and fix
## [1.1.2] - 2026-04-14
### Fixed
- **Ampersand in Filenames**: Fixed issue #2 where files with `&` in their names (e.g., "Research & Development.md") were causing search and file-not-found errors
- Single ampersands are now preserved in filenames and paths
- Security maintained: Dangerous `&&` command operators are still blocked by injection pattern detection
- Also preserves parentheses `()`, brackets `[]`, and braces `{}` which are safe in quoted CLI arguments
- Affects all file operations and search tools
## [1.1.1] - 2026-04-10
### Fixed
- **Quote Escaping**: Fixed critical bug where note content was being truncated when containing double quotes
- Content like `"Bot QM"` is now properly escaped and passed to the CLI without truncation
- Internal double quotes are escaped as `\"` before being wrapped in parameter quotes
- Prevents shell from misinterpreting quote boundaries in parameter values
- Affects all tools that pass content: create, append, prepend, search queries, etc.
## [1.1.0] - 2026-04-10
### Fixed
- **Square Brackets Preservation**: Fixed critical bug where square brackets `[` and `]` were being removed from note content during sanitization
- Wikilinks (`[[link]]`) now work correctly when creating or modifying notes
- Task checkboxes (`- [ ] Task` and `- [x] Done`) are properly preserved
- Array notation and date formats with brackets are no longer corrupted
- Security: Square brackets are safe because parameter values are quoted and passed as array arguments to the CLI
- All dangerous shell metacharacters (`;`, `|`, `$()`, backticks, etc.) are still properly blocked
## [Unreleased]
### Planned
@@ -86,9 +185,27 @@ 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)
- **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.1** - Bug fix release: Quote escaping in note content
- **1.1.0** - Bug fix release: Square brackets preservation in note content
- **1.0.0** - Initial release with 28 MCP tools across 3 user stories
- File Operations (8 tools)
- Search & Discovery (12 tools)
- Task & Property Management (8 tools)
[1.0.0]: https://github.com/yourusername/obsidian-mcp/releases/tag/v1.0.0
[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.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.0]: https://git.mortons.site/Peter.Morton/obsidian-mcp/releases/tag/v1.1.0
[1.0.0]: https://git.mortons.site/Peter.Morton/obsidian-mcp/releases/tag/v1.0.0

View File

@@ -19,7 +19,9 @@ An MCP (Model Context Protocol) Bundle that exposes Obsidian CLI capabilities to
- Node.js >= 18.0.0
- Obsidian CLI installed and configured
- Obsidian application installed
- Obsidian application installed and **running**
> **Important:** The Obsidian application must be open and running before using any MCP tools. The CLI used by this bundle communicates with the running Obsidian instance. If Obsidian is not running, commands will launch the GUI instead of executing — start Obsidian first, then use the MCP tools.
### Install via Claude Desktop
@@ -194,7 +196,9 @@ Contributions are welcome! Please ensure:
### "Obsidian not running" error
Start the Obsidian application before using MCP tools.
The Obsidian application must be running before using any MCP tools. The CLI communicates with the running Obsidian instance — if Obsidian is closed, commands will launch the GUI app instead of executing.
**Fix:** Open the Obsidian application and wait for it to fully load, then retry the operation.
### "Vault not found" error

View File

@@ -1,7 +1,7 @@
{
"manifest_version": "0.3",
"name": "obsidian-mcp",
"version": "1.0.0",
"version": "1.2.0",
"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,13 +1,15 @@
{
"name": "obsidian-mcp",
"version": "1.0.0",
"version": "1.2.0",
"description": "MCP Bundle for Obsidian CLI - Enable AI assistants to manage Obsidian vaults through Model Context Protocol",
"type": "module",
"main": "dist/index.js",
"scripts": {
"build": "tsc",
"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"

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[] = [],

114
src/http-server.ts Normal file
View File

@@ -0,0 +1,114 @@
/**
* HTTP Entry Point for Obsidian MCP Server
* Exposes the MCP implementation over Streamable HTTP transport
* per the MCP 2025-11-25 specification.
*
* Endpoint: POST/GET /mcp
* Port: MCP_PORT env var (default: 3000)
*/
import http, { IncomingMessage } from 'node:http';
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
import { ObsidianMCPServer } from './server.js';
import { registerAllTools } from './tools/index.js';
import { logger } from './utils/logger.js';
const PORT = parseInt(process.env.MCP_PORT ?? '3000', 10);
/**
* Read and parse the JSON body from an incoming HTTP request.
*/
function readBody(req: IncomingMessage): Promise<unknown> {
return new Promise((resolve, reject) => {
const chunks: Buffer[] = [];
req.on('data', (chunk: Buffer) => chunks.push(chunk));
req.on('end', () => {
const raw = Buffer.concat(chunks).toString('utf8');
if (!raw) {
resolve(undefined);
return;
}
try {
resolve(JSON.parse(raw));
} catch {
reject(new Error('Invalid JSON body'));
}
});
req.on('error', reject);
});
}
async function main(): Promise<void> {
const vaultName = process.env.OBSIDIAN_VAULT;
if (!vaultName) {
logger.error('OBSIDIAN_VAULT environment variable not set');
process.exit(1);
}
logger.info('Starting Obsidian MCP HTTP server', { vault: vaultName, port: PORT });
// Create and configure the MCP server
const mcpServer = new ObsidianMCPServer();
await registerAllTools(mcpServer);
// Stateless transport: no session management, each request is self-contained.
// Use sessionIdGenerator: () => randomUUID() for stateful/multi-client mode.
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: undefined,
});
await mcpServer.connect(transport);
const httpServer = http.createServer(async (req, res) => {
// Only serve the /mcp endpoint
if (req.url !== '/mcp') {
res.writeHead(404, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Not Found. Use POST /mcp' }));
return;
}
try {
let body: unknown;
if (req.method === 'POST') {
body = await readBody(req);
}
await transport.handleRequest(req, res, body);
} catch (error) {
logger.error('Error handling MCP request', {
error: error instanceof Error ? error.message : String(error),
});
if (!res.headersSent) {
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Internal server error' }));
}
}
});
httpServer.listen(PORT, () => {
logger.info(`MCP HTTP server ready`, { url: `http://localhost:${PORT}/mcp` });
});
// Graceful shutdown
const shutdown = async (signal: string) => {
logger.info(`Received ${signal}, shutting down`);
httpServer.close(async () => {
try {
await mcpServer.close();
logger.info('Server closed');
process.exit(0);
} catch {
process.exit(1);
}
});
};
process.on('SIGINT', () => shutdown('SIGINT'));
process.on('SIGTERM', () => shutdown('SIGTERM'));
}
main().catch((error) => {
logger.error('Fatal error', {
error: error instanceof Error ? error.message : String(error),
});
process.exit(1);
});

View File

@@ -6,6 +6,7 @@
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
import {
CallToolRequestSchema,
ListToolsRequestSchema,
@@ -119,12 +120,12 @@ export class ObsidianMCPServer {
}
/**
* Connect to stdio transport
* Connect using the provided transport, or stdio if none is given
*/
async connect(): Promise<void> {
const transport = new StdioServerTransport();
await this.server.connect(transport);
logger.info('MCP server connected to stdio transport');
async connect(transport?: Transport): Promise<void> {
const t = transport ?? new StdioServerTransport();
await this.server.connect(t);
logger.info('MCP server connected', { transport: transport ? 'custom' : 'stdio' });
}
/**

View File

@@ -3,6 +3,8 @@
* 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 } from '../cli/executor.js';
import { formatForMCP } from '../cli/parser.js';
@@ -19,6 +21,101 @@ 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';
}
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
*/
@@ -28,17 +125,18 @@ export async function registerFileOperationTools(server: ObsidianMCPServer): Pro
// T029: Create note tool
server.registerTool(
'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',
required: ['name'],
properties: {
name: {
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: {
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: {
type: 'string',
@@ -50,7 +148,7 @@ export async function registerFileOperationTools(server: ObsidianMCPServer): Pro
},
overwrite: {
type: 'boolean',
description: 'Overwrite if file exists (optional)',
description: 'Replace the file if it already exists (optional)',
},
open: {
type: 'boolean',
@@ -66,14 +164,15 @@ export async function registerFileOperationTools(server: ObsidianMCPServer): Pro
'Create a new note in the Obsidian vault',
{
type: 'object',
required: ['name'],
properties: {
name: {
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: {
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: {
type: 'string',
@@ -85,7 +184,7 @@ export async function registerFileOperationTools(server: ObsidianMCPServer): Pro
},
overwrite: {
type: 'boolean',
description: 'Overwrite if file exists (optional)',
description: 'Replace the file if it already exists (optional)',
},
open: {
type: 'boolean',
@@ -102,8 +201,6 @@ export async function registerFileOperationTools(server: ObsidianMCPServer): Pro
const sanitized = sanitizeParameters(validated) as any;
const cmdArgs: string[] = [];
// Add name or path parameter
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));
@@ -130,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).',
'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: {
@@ -142,10 +239,18 @@ export async function registerFileOperationTools(server: ObsidianMCPServer): Pro
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',
'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',
properties: {
@@ -157,24 +262,99 @@ export async function registerFileOperationTools(server: ObsidianMCPServer): Pro
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 identifier = (sanitized.file || sanitized.path) as string;
const mimeType = getMimeType(identifier);
// 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';
if (isImageMimeType(mimeType)) {
return {
content: [{ type: 'image' as const, data: base64, mimeType }],
};
}
const uri = `obsidian://${encodeURIComponent(vaultName)}/${encodeURIComponent(relPath)}`;
return {
content: [
{
type: 'resource' as const,
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: sanitized.file || sanitized.path });
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 },
},
],
};
}
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: formatForMCP(result.stdout, 'text'),
text,
},
],
};

View File

@@ -13,7 +13,15 @@
export function formatParam(key: string, value: string | number): string {
// Always quote string values to handle spaces and special characters safely
// Note: Obsidian CLI docs say: "Quote values with spaces: name="My Note""
return `${key}="${value}"`;
// Escape double quotes and backticks to prevent shell interpretation inside double-quoted strings.
// In bash double-quoted strings: \" prevents quote termination, \` prevents command substitution.
// This preserves Markdown code fences (``` ` ```) while blocking injection via backticks.
const escapedValue = String(value)
.replace(/"/g, '\\"')
.replace(/`/g, '\\`');
return `${key}="${escapedValue}"`;
}
/**

View File

@@ -16,10 +16,12 @@ export interface ToolInput {
* Successful tool output
*/
export interface ToolOutput {
content: Array<{
type: 'text';
text: string;
}>;
content: Array<
| { type: 'text'; 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;
}
@@ -63,6 +65,7 @@ export enum ObsidianErrorType {
*/
export interface CLIResult {
stdout: string;
stdoutBuffer?: Buffer;
stderr: string;
exitCode: number;
timedOut?: boolean;

View File

@@ -8,11 +8,19 @@ import { logger } from '../utils/logger.js';
/**
* Characters that should be removed or escaped for security
* 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.)
* Note: Single & is safe in quoted args (filenames like "Research & Development.md")
* 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
*/
const DANGEROUS_CHARS = /[;&|`$(){}[\]<>]/g;
const DANGEROUS_CHARS = /[;|$]/g;
const COMMAND_INJECTION_PATTERNS = [
/\$\(/g, // Command substitution $(...)
/`[^`]*`/g, // Command substitution `...`
/\|\|/g, // OR operator
/&&/g, // AND operator
/;/g, // Command separator
@@ -67,7 +75,9 @@ export function sanitizePath(path: string): string {
sanitized = sanitized.replace(/^\/+|\/+$/g, '');
// Remove dangerous characters but allow path separators
sanitized = sanitized.replace(/[;&|`$(){}[\]<>]/g, '');
// Note: Brackets, parentheses, braces, and single & are safe in paths (quoted args)
// Note: < and > are safe inside double-quoted strings (not shell redirects)
sanitized = sanitized.replace(/[;|`$]/g, '');
return sanitized;
}

View File

@@ -104,11 +104,13 @@ export const createNoteSchema = z.object({
});
// Read note parameters
export const readNoteSchema = z.union([
z.object({ file: noteNameSchema }),
z.object({ path: filePathSchema }),
]).refine(
(data) => ('file' in data && data.file) || ('path' in data && data.path),
export const readNoteSchema = z.object({
file: noteNameSchema.optional(),
path: filePathSchema.optional(),
offset: z.number().int().nonnegative().optional().default(0),
max_chars: z.number().int().positive().max(500000).optional().default(50000),
}).refine(
(data) => data.file || data.path,
{ message: 'Either file or path must be provided' }
);