← cd /blog

Article

Replacing an Obsidian MCP Server With a Stateless CLI

·
buildstoolsthoughts

Issue #9176 reports the Obsidian MCP server mcp-obsidian dying with BrokenPipeError in Claude Code CLI before it finishes initialising, and it was closed as a duplicate without a fix. A vault is only markdown files, and a CLI that reads them, prints JSON and exits does the same job through Bash. That is what replaced my own Obsidian MCP server, the Node package obsidian-mcp.

Install vaultctl and save the vault path

vaultctl is that CLI, on npm. Install it, save the vault path, and let the agent call it through Bash:

npm install -g vaultctl
vaultctl config set vault "$HOME/path/to/your/vault"

vaultctl search "quarterly" --type project --status active
vaultctl health --check broken-links

config set writes the path to ~/.vaultctlrc and arrived in v0.4.0. Before that you exported VAULTCTL_PATH or wrote the rc file by hand. Setting the vault path once has the lookup order, and why an exported variable may never reach an agent. The source and the other commands below are from v0.1.0, committed on 14 February 2026. npm has 0.5.0 and marks it deprecated, so the install prints npm warn deprecated vaultctl@0.5.0: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. The installed 0.5.0 reports 0.4.0 from vaultctl --version.

Issue #9176 was closed as a duplicate, not fixed

The issue reports mcp-obsidian v0.2.2 on Claude Code 2.0.9 and macOS 15.7. The server starts, then dies when it flushes stdout:

BrokenPipeError: [Errno 32] Broken pipe

It lists four failed fixes: uvx --quiet, PYTHONUNBUFFERED=1, a direct path to a uv tool install binary, and removing environment variables the server didn't need. Its author suspects Claude Code closes stdin and stdout before the server finishes initialising. The config and logs quoted in the issue are Claude Desktop's, so they don't show the CLI failure.

A bot closed #9176 four days after it was opened, as a duplicate of #3071. #3071 was closed as not planned on 5 January 2026.

Remove the old server completely

Clearing ~/.mcp.json isn't enough. Claude Code keeps offering the mcp__obsidian__* tools, and calls to them hang or crash. Remove the registration, then clean ~/.claude/settings.local.json by hand:

# Remove the server registration
claude mcp remove obsidian -s user
# If that says "not found", it was registered at project scope:
claude mcp remove obsidian -s project

# 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

Output is JSON, and exit code 2 means nothing matched

The rest of the commands:

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

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

# vault statistics
vaultctl info

Output is JSON unless you pass --format table. search, tags find and tags rename exit 2 when nothing matches. Errors exit 1. The search command, from packages/cli/src/commands/search.ts:

        if (results.length === 0) {
          console.log(formatOutput([], format));
          process.exit(2);
        }

        const output = results.map(n => formatNoteRow(n, format));
        console.log(formatOutput(output, format));
      } catch (err) {
        console.error(String(err));
        process.exit(1);
      }

A miss still prints [] to stdout. An error, such as a vault path that doesn't resolve, goes to stderr. An agent can branch on the exit code before it parses anything: 0 means results, 2 means none, 1 means read stderr.

create turns the title into the filename and won't overwrite an existing file. The command above writes 01_Projects/my-new-project.md.

Note: Two features hard-code a vault layout. The frontmatter check wants type, created and tags on every note. create reads templates from _templates/tpl-<name>.md and fails if that file is missing. Unless you pass --folder, it picks the folder by template name: project goes to 01_Projects, daily to 05_Journal, anything unrecognised to 00_Inbox.

Frontmatter tags and inline tags are one set

Obsidian keeps tags in two places: the tags array in the YAML frontmatter and #hashtags in the body. Listing, finding, searching and renaming merge them first:

export function findByTag(notes: Note[], tag: string): Note[] {
  return notes.filter(note => {
    const allTags = [...(note.frontmatter.tags ?? []), ...note.inlineTags];
    return allTags.includes(tag);
  });
}

The inline parser skips Markdown heading lines. A # Title is never a tag, and neither is a #tag written on a heading line.

tags rename rewrites both places. tags add and tags remove edit only the frontmatter, so tags remove leaves a #tag in the body where it is.

Without --yes, tags rename only reports what would change. The report has this shape (the path is an example):

$ vaultctl tags rename status/active status/in-progress
{
  "dryRun": true,
  "affected": [
    {
      "path": "01_Projects/vaultctl.md",
      "action": "status/active → status/in-progress"
    },
    ...
  ],
  "message": "Pass --yes to execute"
}

Keep the core free of the CLI

Two packages in an npm workspaces monorepo. @vaultctl/core is a pure TypeScript library: frontmatter, wikilinks, tags, search, health checks and templates, with no CLI dependencies. The vaultctl package is a thin Commander wrapper for argument parsing and output formatting, about 400 lines. On 21 February the same core got two more wrappers: an HTTP API and an MCP server.

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

Wikilinks resolve by filename, ignoring case

Parsing strips the heading anchor (#Section) and the alias (|text) and keeps the target:

const WIKILINK_RE = /\[\[([^\]]+)\]\]/g;
// ...

export function parseWikilinks(body: string): WikiLink[] {
  const links: WikiLink[] = [];
  let match: RegExpExecArray | null;

  while ((match = WIKILINK_RE.exec(body)) !== null) {
    const inner = match[1];
    const withoutAnchor = inner.split('#')[0];
    const parts = withoutAnchor.split('|');
    const target = parts[0].trim();
    const alias = parts.length > 1 ? inner.split('|')[1]?.trim() : undefined;

    links.push({
      raw: match[0],
      target,
      alias,
      resolved: null,
    });
  }

  return links;
}

Loading the vault maps every lowercased filename to its path and looks each target up. A target with no match stays null, and the health check reports it as a broken link. Two notes with the same filename in different folders share one key, and the map keeps whichever loaded last.