wk_

Obsidian Brain

Apr 2, 2026 · 10 min read
obsidian mcp embedding ollama

Building a Digital Brain to Replace my Faulty Real One

I spend a lot of my time working with LLMs. I spend pretty much all of my 9-5 interacting with these models, and as my girlfriend knows (sorry Maddie <3) the same is true for my 5-9. I have my own memory problems, and get frustrated when my AI friend follows suit.

Loosely inspired by some recent trends and my own desire for a personalized issue tracker, I created Obsidian Brain. The concept is simple, most of us use Obsidian for note taking and Claude already uses .md files for pretty much everything.

Taking this into account I setup an MCP server complete with a full suite of tools to handle documentation of every conversation you have with your AI of choice. This content is automatically embedded to allow for vector semantic search and LESS TOKEN USAGE (more on this later).

This post walks through how I built it, the technical decisions involved, and the key systems that make it work.

Open source btw, would love to hear how you all like it.


What Is MCP?

Lets back up a second, if you arent already familiar:

The Model Context Protocol is an open standard that lets AI assistants call external tools.

An MCP server exposes tools (functions the model can call) and communicates over stdio. Your platform of choice (Claude Code, in my case) discovers available tools and lets the model invoke them with structured parameters.

Here’s roughly what a registered tool looks like:

server.tool(
  "search_vault",
  "Hybrid full-text + semantic search across vault notes",
  {
    query: z.string().describe("Search query"),
    limit: z.number().optional().default(10),
  },
  async ({ query, limit }) => {
    const results = await hybridSearch(db, vaultPath, query, limit);
    return { content: [{ type: "text", text: results }] };
  }
);

Zod schemas define the parameters. The MCP SDK handles serialization, validation, and transport. You just write the logic.


Architecture Overview

The server is a Node.js process that sits between Claude and the filesystem:

Claude Code ←→ stdio ←→ MCP Server ←→ Obsidian Vault

                        SQLite DB
                     (FTS + vectors)

It exposes 11 tools, categorized for your viewing pleasure:

CategoryTools
Notesread_note, write_note, update_note, move_note, list_notes
Searchsearch_vault, reindex_vault
Issuescreate_issue, update_issue, list_issues
Projectscreate_project

Everything is backed by a single SQLite database (.vault-index.db) stored in the vault’s meta/ folder.


Big Idea: Only Read Whats Relevant

I mentioned briefly that the fundamental problem is memory and token efficiency. My vault has hundreds of notes. Loading them all into context would be, like, expensive. The vast majority of them aren’t relevant either.

Sounds awful, but as you probably know context is king when working with LLMs. We solve this through semantic search.

The workflow is simple:

  1. Claude receives a task
  2. It searches the vault for related context
  3. It reads only the notes that matter
  4. It works on the task with full context
  5. It writes back any new knowledge

This keeps token usage tight while giving the model access to everything I know.


Hybrid Search: FTS + Vectors

The search system is the heart of the project. It combines two approaches that complement each other and when used properly completely eliminates the problems described above.

Full-Text Search (FTS5)

SQLite’s FTS5 extension provides fast keyword matching with Porter stemming, i.e. searching “running” also matches “runs” and “ran.”

CREATE VIRTUAL TABLE notes_fts USING fts5(
  path,
  title,
  content,
  tokenize='porter'
);

Queries return ranked results with context snippets:

function queryFTS(db: Database, query: string, limit: number) {
  const terms = query.split(/\s+/).filter(Boolean);
  const safeQuery = terms.map(t => `"${t.replace(/"/g, '""')}"`).join(" ");

  return db.prepare(`
    SELECT path, snippet(notes_fts, 2, '<mark>', '</mark>', '...', 40) as snippet
    FROM notes_fts
    WHERE notes_fts MATCH ?
    ORDER BY rank
    LIMIT ?
  `).all(safeQuery, limit);
}

Note the query sanitization, each term is wrapped in double quotes to prevent FTS operator injection. Without this, a query like "rust OR python" would be interpreted as a boolean expression instead of a literal search.

Keyword search is great, I mean talk about a classic, but it can’t understand meaning. Searching “Why is production down?!” won’t match a note titled “Vibe Coding: an Introduction.” That’s where embeddings come in.

I use a local Ollama instance with the nomic-embed-text model to generate 768-dimensional vectors for each note:

async function embed(text: string): Promise<number[] | null> {
  if (!ollamaAvailable) return null;

  const res = await fetch("http://localhost:11434/api/embed", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ model: "nomic-embed-text", input: text }),
  });

  const data = await res.json();
  return data.embeddings[0];
}

These vectors are stored in SQLite using the sqlite-vec extension:

CREATE VIRTUAL TABLE notes_vec USING vec0(
  path TEXT PRIMARY KEY,
  embedding FLOAT[768]
);

Semantic search finds notes by conceptual similarity using cosine distance, even when the exact words don’t match.

Ruler (king) and Ruler (measurement) have completely different meanings. Keyword search can’t differentiate this, semantic search can.

Merging Results

Both systems run in parallel, then results are merged with a scoring algorithm:

async function hybridSearch(db, vaultPath, query, limit) {
  const ftsResults = queryFTS(db, query, limit * 2);
  const embedding = await embed(query);
  const vecResults = embedding ? queryVector(db, embedding, limit * 2) : [];

  const scores = new Map();

  ftsResults.forEach((r, i) => {
    scores.set(r.path, { score: 1 - i / ftsResults.length, snippet: r.snippet });
  });

  vecResults.forEach((r, i) => {
    const vectorScore = 1 - i / vecResults.length;
    const existing = scores.get(r.path);
    if (existing) {
      // Both methods found this note — boost it
      existing.score = (existing.score + vectorScore) * 1.2;
    } else {
      scores.set(r.path, { score: vectorScore });
    }
  });

  // Sort by score, return top results
}

Notes found by both methods get a 1.2x boost, surfacing the most relevant content. Each method fetches limit * 2 candidates to ensure enough overlap for meaningful ranking.

(The limit parameter exists so we don’t query the whole vault, would be a bit counterintuitive don’t you think?)

Graceful Degradation

Not everyone has Ollama running, and in my book you don’t need to. The system handles this appropriately:

let ollamaAvailable = true;

async function embed(text: string): Promise<number[] | null> {
  if (!ollamaAvailable) return null;
  try {
    // ... fetch embedding
  } catch {
    ollamaAvailable = false;  // Don't retry on failure
    return null;
  }
}

If Ollama is down or sqlite-vec isn’t available, search falls back to FTS-only. The server never crashes, it just gets worse. lol.


Incremental Indexing

I acknowledge some notes will be hand-written for some reason. To account for this there is a reindexing tool to ensure the embeddings are accurate.

Re-embedding every note on every startup would be slow and wasteful. The indexer tracks file modification times to skip unchanged files:

async function reindexVault(db, vaultPath) {
  const files = walkMarkdownFiles(vaultPath);
  let indexed = 0, skipped = 0;

  for (const filePath of files) {
    const stat = fs.statSync(filePath);
    const relativePath = path.relative(vaultPath, filePath);
    const storedMtime = getMtime(db, relativePath);

    if (storedMtime && storedMtime >= stat.mtimeMs) {
      skipped++;
      continue;  // File hasn't changed
    }

    const content = fs.readFileSync(filePath, "utf-8");
    const clean = stripFrontmatter(content);
    const title = extractTitle(relativePath, clean);

    await indexNote(db, relativePath, title, clean, stat.mtimeMs);
    indexed++;
  }

  return `Indexed ${indexed}, skipped ${skipped} unchanged`;
}

A notes_meta table stores the last-indexed mtime for each path. Only files with newer timestamps get re-processed. This means reindexing a 500-note vault after editing 3 notes only costs 3 embedding calls. Thank god.


Note Management

Notes are the primary data type. The server handles CRUD operations with some useful features beyond basic file I/O.

Section-Level Updates

The update_note tool supports three modes: append, prepend, and replace-section. The last one is the most interesting — it can target a specific markdown section by heading:

if (mode === "replace-section") {
  const headingMatch = existing.match(new RegExp(`^(#{1,6})\\s+${section}`, "m"));
  if (!headingMatch) return `Section "${section}" not found`;

  const level = headingMatch[1].length;
  const pattern = new RegExp(
    `(^#{${level}}\\s+${section}.*$)([\\s\\S]*?)(?=^#{1,${level}}\\s|\\Z)`,
    "m"
  );

  updated = existing.replace(pattern, `${headingMatch[0]}\n\n${cleanContent}`);
}

This detects the heading level from the actual note (so ## Implementation and ### Implementation are handled differently), then replaces everything between that heading and the next heading of equal or higher level.

I will admit this feature is a bit sketchy still. for non-template notes I usually opt for append or prepend.

Automatic Indexing

Every write or update triggers re-indexing:

async function writeNote(db, vaultPath, notePath, content) {
  // ... validate path, write file ...

  const clean = stripFrontmatter(content);
  const title = extractTitle(notePath, clean);
  await indexNote(db, notePath, title, clean, Date.now());

  return `Wrote ${notePath}`;
}

Fully automatic assault indexing, the search index stays current as notes change.


Issue Tracking

I previously used Steve Yegge’s beads (https://github.com/gastownhall/beads) for issue tracking. Great tool btw.

I wanted this context inside the vault, not in a separate tool. Beads allowed for powerful, instant issue querying. Something markdown files just don’t support.

Solution: dual storage.

Database for Queries

CREATE TABLE issues (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  title TEXT NOT NULL,
  type TEXT CHECK(type IN ('bug', 'feature', 'task')),
  status TEXT DEFAULT 'not_started'
    CHECK(status IN ('backlog','not_started','in_progress','code_review','done','blocked')),
  priority INTEGER CHECK(priority BETWEEN 1 AND 5),
  project TEXT,
  note_path TEXT NOT NULL,
  created_at TEXT DEFAULT (datetime('now')),
  updated_at TEXT DEFAULT (datetime('now'))
);

This lets Claude query issues efficiently: “show me all in-progress bugs for project X, sorted by priority” is a single indexed query.

Markdown for Humans

Each issue also gets a markdown file in the vault:

---
id: 42
type: bug
status: in_progress
priority: 2
project: obsidian-brain
created: 2024-01-15T10:30:00
updated: 2024-01-20T14:22:00
---

# Fix sidebar layout on mobile

**Status:** In Progress | **Priority:** P2 (High) | **Type:** bug

## Description

The sidebar overflows on viewports under 768px.

## Notes

- 2024-01-15: Initial investigation
- 2024-01-20: Found CSS issue in grid container

I can browse and edit issues directly in Obsidian. The database and markdown stay in sync. Updating an issue rewrites the frontmatter and appends timestamped notes.

Automatic Archival

When an issue is marked done, it automatically moves from issues/active/ to issues/done/:

if (updates.status === "done") {
  const newPath = issue.note_path.replace("/active/", "/done/");
  fs.renameSync(
    path.join(vaultPath, issue.note_path),
    path.join(vaultPath, newPath)
  );
}

Simple, but it keeps the active folder clean without manual housekeeping. If I liked housekeeping I wouldn’t be 500 words in on an AI tooling blog.


Project Scaffolding

New projects get a consistent structure:

async function createProject(vaultPath, name) {
  const base = path.join(vaultPath, "02-projects", name);

  fs.mkdirSync(path.join(base, "notes"), { recursive: true });
  fs.mkdirSync(path.join(base, "issues", "active"), { recursive: true });
  fs.mkdirSync(path.join(base, "issues", "done"), { recursive: true });

  fs.writeFileSync(path.join(base, "README.md"), `# ${name}\n\n## Overview\n\n## Architecture\n`);
}

This creates:

02-projects/my-project/
├── README.md
├── notes/
└── issues/
    ├── active/
    └── done/

Every project gets the same shape, so Claude always knows where to look.


Security

BORING, but unfortunately necessary.

Two concerns stood out during development.

Path Traversal

The server must never read or write files outside the vault. Every path operation goes through validation:

function validatePath(vaultPath: string, notePath: string): string | null {
  const resolved = path.resolve(vaultPath, notePath);
  if (!resolved.startsWith(path.resolve(vaultPath) + path.sep) &&
      resolved !== path.resolve(vaultPath)) {
    return `Error: Path "${notePath}" escapes vault boundary`;
  }
  return null;
}

A request for ../../etc/passwd gets caught before any filesystem call.

FTS Injection

FTS5 has its own query syntax: operators like OR, NOT, and NEAR can change search behavior. User input is sanitized by quoting each term:

const safeQuery = terms.map(t => `"${t.replace(/"/g, '""')}"`).join(" ");

We touched on this earlier, but again this ensures the query is always treated as literal text, not as FTS expressions.


Stack

DependencyPurpose
@modelcontextprotocol/sdkMCP server framework
better-sqlite3SQLite driver
sqlite-vecVector similarity extension
zodParameter validation
tsxTypeScript execution (dev)

No frameworks, no ORMs, no build complexity. SQLite handles all persistence. For how powerful it is the package is pretty lightweight.


Running It

The server registers in Claude Code’s MCP config:

{
  "mcpServers": {
    "obsidian": {
      "command": "npx",
      "args": ["tsx", "/path/to/obsidian-brain/src/server.ts"],
      "env": {
        "VAULT_PATH": "/path/to/your/vault"
      }
    }
  }
}

Claude Code launches the server on startup. From that point, every conversation has access to the vault through the registered tools.

I recommend setting up your global CLAUDE.md with your intended workflow. For this tool to work at its maximum potential it should be used for EVERYTHING.


Thanks for reading this far, context is king friends.

The project is open source at obsidian-brain.