← cd /blog

Article

Replacing the Obsidian MCP Server With a CLI

·
buildstoolsthoughts

MCP servers for Obsidian are broken in Claude Code CLI. The BrokenPipeError kills the server before it initialises. Zombie processes pile up across sessions. The 48-comment GitHub issue has no reliable fix. So, vaultctl.

The problem

Claude Code launches MCP servers, immediately closes the stdin/stdout pipes before initialisation completes, and the server crashes with BrokenPipeError. Every time. The same servers work fine in Claude Desktop; it's a Claude Code-specific lifecycle bug.

The community has tried uvx --quiet, PYTHONUNBUFFERED=1, global installs, direct binary paths. None work reliably because the root cause is in Claude Code's pipe handling, not the servers. The GitHub issue now has 48 comments and zero solutions, which is the MCP experience in miniature.

The solution

An Obsidian vault is a directory of markdown files: YAML frontmatter, [[wikilinks]], #tags, and folders. There's no reason to run a persistent server for that. Reader, I had been running a persistent server for that. vaultctl replaces MCP with stateless CLI commands. Every invocation reads the filesystem, does its work, and exits.

# search by content, type, status, or tag
vaultctl search "tempo plans" --type project --status active

# tag operations across the vault
vaultctl tags list
vaultctl tags rename status/active status/in-progress --yes

# vault health checks
vaultctl health --check broken-links

# create notes from templates
vaultctl create --template project "My New Project"

# vault statistics
vaultctl info

Output is JSON by default, designed for agents consuming via Bash. Add --format table for human-readable output. Exit code 2 means "no results" so agents can branch on it.

Architecture

Two packages in an npm workspaces monorepo:

@vaultctl/core: pure TypeScript library. Frontmatter parsing, wikilink resolution, tag engine, search, health checks, template rendering. Zero CLI dependencies. Importable by anything (a future HTTP API, an MCP server, an Obsidian plugin).

vaultctl: thin CLI wrapper using Commander. Argument parsing and output formatting. ~300 lines.

vaultctl/
├── packages/
│   ├── core/src/
│   │   ├── frontmatter.ts   # gray-matter wrapper
│   │   ├── wikilinks.ts     # [[link]] parsing + resolution
│   │   ├── vault.ts         # load vault, build file map
│   │   ├── search.ts        # content + metadata search
│   │   ├── tags.ts          # unified frontmatter + inline tags
│   │   ├── health.ts        # broken links, orphans, stale notes
│   │   └── templates.ts     # note creation from _templates/
│   └── cli/src/
│       ├── index.ts          # commander setup
│       └── commands/         # search, tags, health, create, read, info
└── test/                     # 58 tests across 8 files

Wikilink resolution

When loading a vault, vaultctl builds a case-insensitive filename map and resolves every [[wikilink]] to an actual file path, or marks it broken:

export function parseWikilinks(content: string): WikiLink[] {
  const regex = /\[\[([^\]|#]+)(?:#[^\]|]*)?\|?[^\]]*\]\]/g;
  const links: WikiLink[] = [];
  let match;
  while ((match = regex.exec(content)) !== null) {
    links.push({
      target: match[1].trim(),
      original: match[0],
      resolved: null  // populated during vault loading
    });
  }
  return links;
}

The health checker uses this to find broken links. It found 24 in my vault that I didn't know about.

Tag unification

Tags in Obsidian live in two places: the YAML frontmatter tags array and inline #hashtags in the body. vaultctl treats them as one unified tag space:

export function collectTags(note: Note): string[] {
  const fmTags = note.frontmatter.tags || [];
  const inlineTags = parseInlineTags(note.content);
  return [...new Set([...fmTags, ...inlineTags])];
}

Search, filter, and rename operations work across both. The rename command does a dry-run first:

$ vaultctl tags rename status/active status/in-progress
Dry run, 8 notes would be modified:
  01_Projects/vaultctl.md (frontmatter + inline)
  01_Projects/personal-site.md (frontmatter)
  ...
Run with --yes to apply.

Removing MCP completely

If you're migrating from an existing Obsidian MCP server, clearing ~/.mcp.json isn't enough. Claude Code caches tool permissions in ~/.claude/settings.local.json, so the mcp__obsidian__* tools keep appearing in new sessions and will hang when called.

# Remove the server registration
claude mcp remove obsidian -s user

# Then clean ~/.claude/settings.local.json:
# - Delete all "mcp__obsidian__*" entries from permissions.allow
# - Remove "obsidian" from enabledMcpjsonServers
# - Remove any Bash permission for the obsidian-mcp binary

Without this, Claude Code will still try to call the dead MCP server. Ask me how I know.

Running it

Against my real vault: 53 notes, 70 unique tags, 13 templates. 24 broken links. 31 orphaned notes. 6 frontmatter issues. Under a second, zero configuration beyond an env var.

The tool is open source at github.com/testing-in-production/vaultctl. 58 tests. Works against any Obsidian vault.