DEV Community

rps321321
rps321321

Posted on

I Built the Most Feature-Complete MCP Server for Obsidian — Here's How

If you use Obsidian for knowledge management and AI assistants for development, you have probably wondered: why can't Claude just read my notes? That is the question that led me to build obsidian-mcp-pro, a 23-tool MCP server that gives AI assistants deep, structured access to Obsidian vaults.

What Is MCP?

The Model Context Protocol (MCP) is an open standard from Anthropic that lets AI assistants call external tools and read external resources through a unified interface. Think of it as a USB-C port for AI: one protocol, any data source. An MCP server exposes tools (functions the AI can call) and resources (data the AI can read), and any MCP-compatible client — Claude Desktop, Claude Code, Cursor, Windsurf — can use them.

The Problem

When I started, four Obsidian MCP servers already existed on npm. All of them covered the basics: read a note, search, maybe create a file. None of them handled what makes Obsidian Obsidian:

  • Wikilinks with Obsidian's unique shortest-path resolution
  • Graph analysis — backlinks, orphans, broken links, neighbor traversal
  • Canvas files — Obsidian's visual thinking tool
  • Frontmatter-aware operations — search by YAML fields, update properties programmatically
  • Cross-platform vault detection — auto-find the vault on Windows, macOS, and Linux

I wanted to build something that treated Obsidian as a first-class knowledge graph, not just a folder of markdown files.

Architecture

The stack is intentionally minimal:

  • TypeScript with strict mode
  • @modelcontextprotocol/sdk for the MCP server scaffold
  • Zod for input validation on every tool
  • gray-matter for YAML frontmatter parsing

Three production dependencies. That is it.

File Structure

src/
├── index.ts          # Server bootstrap, MCP resources
├── config.ts         # Vault detection (cross-platform)
├── types.ts          # Shared type definitions
├── lib/
│   ├── vault.ts      # File I/O, search, path security
│   └── markdown.ts   # Frontmatter, wikilinks, tags, code block tracking
└── tools/
    ├── read.ts       # 5 read tools
    ├── write.ts      # 7 write tools
    ├── tags.ts       # 2 tag tools
    ├── links.ts      # 5 link/graph tools
    └── canvas.ts     # 4 canvas tools
Enter fullscreen mode Exit fullscreen mode

Vault Detection

The server auto-detects your vault by reading Obsidian's own config file. The detection chain checks OBSIDIAN_VAULT_PATH env var first, then falls back to parsing obsidian.json from the platform-specific config directory:

function getObsidianConfigPath(): string {
  const platform = os.platform();
  if (platform === "win32") {
    return path.join(process.env.APPDATA!, "obsidian", "obsidian.json");
  }
  if (platform === "darwin") {
    return path.join(os.homedir(), "Library", "Application Support",
      "obsidian", "obsidian.json");
  }
  // Linux: respect XDG_CONFIG_HOME
  const configDir = process.env.XDG_CONFIG_HOME
    || path.join(os.homedir(), ".config");
  return path.join(configDir, "obsidian", "obsidian.json");
}
Enter fullscreen mode Exit fullscreen mode

If multiple vaults exist and no name is specified via OBSIDIAN_VAULT_NAME, the server picks the first valid one and logs a warning.

Key Technical Challenges

1. Wikilink Resolution

Obsidian uses a shortest-path matching strategy for [[wikilinks]]. If you write [[Meeting Notes]], Obsidian does not require a full path — it finds the note by basename, preferring shorter paths when there are duplicates. Replicating this correctly was critical for backlink/outlink analysis:

export function resolveWikilink(
  link: string,
  _currentNotePath: string,
  allNotePaths: string[],
): string | null {
  const cleanLink = link.split("#")[0].split("^")[0].trim();
  if (!cleanLink) return null;
  const normalizedLink = cleanLink.replace(/\.md$/i, "");

  // 1. Exact relative path match
  for (const notePath of allNotePaths) {
    const withoutExt = notePath.replace(/\.md$/i, "").toLowerCase();
    if (withoutExt === normalizedLink.toLowerCase()) return notePath;
  }

  // 2. Path suffix match (for links like "folder/note")
  if (normalizedLink.includes("/")) {
    for (const notePath of allNotePaths) {
      const withoutExt = notePath.replace(/\.md$/i, "").toLowerCase();
      if (withoutExt.endsWith(normalizedLink.toLowerCase())) {
        const prefix = withoutExt.slice(
          0, withoutExt.length - normalizedLink.length
        );
        if (prefix === "" || prefix.endsWith("/")) return notePath;
      }
    }
  }

  // 3. Shortest-path: basename match, prefer shortest vault path
  const linkBasename = path.basename(normalizedLink).toLowerCase();
  const candidates = allNotePaths.filter((p) =>
    path.basename(p, ".md").toLowerCase() === linkBasename
  );
  candidates.sort((a, b) => a.length - b.length);
  return candidates[0] ?? null;
}
Enter fullscreen mode Exit fullscreen mode

The three-phase strategy (exact match, suffix match, basename match) mirrors how Obsidian itself resolves links. Heading anchors (#heading) and block references (^blockid) are stripped before resolution.

2. Code Block-Aware Extraction

Tags and wikilinks inside code blocks should be ignored. A #typescript inside a fenced code block is syntax highlighting, not an Obsidian tag. The solution is a stateful tracker that handles both backtick and tilde fences of arbitrary length:

function createCodeBlockTracker(): (line: string) => boolean {
  let insideCodeBlock = false;
  let fenceChar = "";
  let fenceLength = 0;
  return (line: string): boolean => {
    const trimmed = line.trimStart();
    if (!insideCodeBlock) {
      const backtickMatch = trimmed.match(/^(`{3,})/);
      const tildeMatch = trimmed.match(/^(~{3,})/);
      if (backtickMatch) {
        insideCodeBlock = true;
        fenceChar = "`";
        fenceLength = backtickMatch[1].length;
        return true;
      }
      if (tildeMatch) {
        insideCodeBlock = true;
        fenceChar = "~";
        fenceLength = tildeMatch[1].length;
        return true;
      }
      return false;
    } else {
      const closePattern = new RegExp(
        `^${fenceChar.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}{${fenceLength},}\\s*$`
      );
      if (closePattern.test(trimmed)) {
        insideCodeBlock = false;
      }
      return true;
    }
  };
}
Enter fullscreen mode Exit fullscreen mode

This tracker is used in both extractTags and extractWikilinks. Inline code spans (`like this`) are stripped separately before regex matching.

3. Path Traversal Security

When an AI assistant can read and write files, path security is non-negotiable. A naive path.join(vaultPath, userInput) allows ../../etc/passwd to escape the vault. The fix:

export function resolveVaultPath(
  vaultPath: string, relativePath: string
): string {
  if (relativePath.includes('\0')) {
    throw new Error("Invalid path: contains null byte");
  }
  const resolved = path.resolve(vaultPath, relativePath);
  const resolvedVault = path.resolve(vaultPath);
  if (!resolved.startsWith(resolvedVault + path.sep)
      && resolved !== resolvedVault) {
    throw new Error(`Path traversal detected: ${relativePath}`);
  }
  return resolved;
}
Enter fullscreen mode Exit fullscreen mode

The null byte check blocks a classic bypass where \0 terminates strings in some filesystem APIs. The + path.sep suffix prevents a subtle bug where a vault at /home/user/notes would incorrectly allow access to /home/user/notes-private (since "notes-private".startsWith("notes") is true).

4. Graph Traversal with BFS

The get_graph_neighbors tool lets you explore the knowledge graph around any note using breadth-first search. Given a starting note and a depth (1-5 hops), it returns all connected notes with their distance and link direction:

// BFS traversal
const visited = new Map<string, GraphNeighbor>();
const queue: { path: string; currentDepth: number }[] = [
  { path: resolvedStart, currentDepth: 0 },
];

while (queue.length > 0) {
  const { path: currentPath, currentDepth } = queue.shift()!;
  if (currentDepth >= depth) continue;

  const neighbors: { path: string; dir: "inbound" | "outbound" }[] = [];
  if (direction === "outbound" || direction === "both") {
    for (const target of graph.outlinks.get(currentPath) ?? []) {
      neighbors.push({ path: target, dir: "outbound" });
    }
  }
  if (direction === "inbound" || direction === "both") {
    for (const source of graph.backlinks.get(currentPath) ?? []) {
      neighbors.push({ path: source, dir: "inbound" });
    }
  }
  for (const neighbor of neighbors) {
    if (!visited.has(neighbor.path)) {
      visited.set(neighbor.path, {
        path: neighbor.path,
        depth: currentDepth + 1,
        direction: neighbor.dir,
      });
      queue.push({ path: neighbor.path, currentDepth: currentDepth + 1 });
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

This gives the AI a way to reason about context: "Show me everything within 2 hops of my architecture decision records."

The 23 Tools

Category Tool Description
Read search_notes Full-text search across all vault notes
get_note Read a single note with frontmatter parsing
list_notes List notes by folder, date, or pattern
get_daily_note Retrieve today's daily note
search_by_frontmatter Query notes by YAML field values
Write create_note Create a note with frontmatter and content
append_to_note Append content to an existing note
prepend_to_note Prepend content (frontmatter-aware)
update_frontmatter Update YAML properties programmatically
create_daily_note Create today's daily note from template
move_note Move/rename with path validation
delete_note Delete to Obsidian trash (safe by default)
Tags get_tags Full tag index with usage counts
search_by_tag Find notes by one or more tags
Links get_backlinks What links to a note
get_outlinks What a note links to
find_orphans Notes with no connections
find_broken_links Wikilinks pointing to non-existent notes
get_graph_neighbors BFS traversal to configurable depth
Canvas list_canvases List all .canvas files
read_canvas Read canvas with full node/edge data
add_canvas_node Add text, file, link, or group nodes
add_canvas_edge Connect canvas nodes with edges

Plus 3 MCP resources: obsidian://note/{path}, obsidian://tags, and obsidian://daily.

Security Deep Dive

During a security audit before publishing, I found and fixed several issues:

  • Path traversal via prefix bypass: The initial startsWith(resolvedVault) check without + path.sep would allow access to sibling directories sharing a name prefix. A vault at /vault would mistakenly grant access to /vault-backup. Fixed by requiring the separator.
  • Null byte injection: Passing \0 in a path can truncate strings in certain filesystem operations. Added an explicit null byte check before any path resolution.
  • YAML injection via frontmatter: The update_frontmatter tool uses gray-matter for parsing and serialization, which handles YAML safely. But untrusted input in frontmatter values could still produce malformed YAML if concatenated as raw strings — the matter.stringify approach avoids this.
  • Trash path validation: The delete-to-trash operation gets its own separate path traversal check to prevent writing to arbitrary locations via crafted relative paths.

Results

  • Published on npm as obsidian-mcp-pro
  • Listed on Glama MCP server directory
  • PR submitted to awesome-mcp-servers
  • Works with Claude Desktop, Claude Code, Cursor, and any MCP-compatible client
  • Zero-config for single-vault setups: npx -y obsidian-mcp-pro just works
  • 122 automated tests covering vault operations, markdown parsing, and full integration testing

What I Learned

Building for npm is its own discipline. Getting the bin field, files array, prepublishOnly script, and shebang line all working correctly took more iterations than I expected. Testing with npm pack before every publish became a habit.

MCP protocol compliance matters. The SDK handles most of the protocol details, but tool responses have a specific shape ({ content: [{ type: "text", text }] }), error reporting has conventions (isError: true), and resource URIs need to follow the template spec. Getting these right meant reading the spec, not just the examples.

Security-first thinking pays off early. The path traversal fix was a two-line change, but catching it before publishing avoided a vulnerability that would have let any AI assistant read arbitrary files on the host machine. When your tool gives an AI filesystem access, every input is an attack surface.

Try It

npx -y obsidian-mcp-pro
Enter fullscreen mode Exit fullscreen mode

If you use Obsidian and AI assistants, give it a try. Feedback and contributions welcome.

Top comments (0)