Skip to content

How to Build an MCP Server for Claude with Obsidian Integration

Problem

I wanted Claude to remember things across sessions. Every time I started a new conversation, Claude had no knowledge of my previous work, my coding preferences, or the architecture decisions I had documented in my Obsidian vault.

I thought about using RAG (Retrieval-Augmented Generation), but that felt like overkill. Then I discovered someone on Reddit had built exactly what I needed:

“I built an MCP server that sits on top of Obsidian (I call it the librarian). Then I built semantic search through it. So now Claude shows up, reads the .md for behavioral instructions, then gets directions from me.”

This was the solution: an MCP (Model Context Protocol) server that turns my Obsidian vault into Claude’s persistent memory.

Environment

  • Claude Desktop (or Claude Code CLI)
  • Node.js 18+
  • Obsidian vault with markdown notes
  • TypeScript

What is MCP?

MCP is a protocol that lets AI models connect to external data sources. Think of it as a universal adapter between Claude and your tools.

The architecture looks like this:

MCP Architecture Overview
+------------------+ MCP Protocol +------------------+
| Claude AI | <-------------------> | MCP Server |
| (Client) | JSON-RPC over | (Librarian) |
+------------------+ stdio/SSE +------------------+
|
| File System API
v
+------------------+
| Obsidian Vault |
| (.md files) |
+------------------+

The server exposes resources (your notes) and tools (search, update) that Claude can call during conversations.

What I Built

I built a minimal MCP server called “librarian” that does two things:

  1. Reads notes - Claude can access any markdown file in my vault
  2. Semantic search - Claude can find related concepts, not just keywords

Let me show you how I built it.

Step 1: Set Up the Project

I started by creating a new Node.js project:

Project setup
mkdir obsidian-mcp-server
cd obsidian-mcp-server
npm init -y
npm install @modelcontextprotocol/sdk

The MCP SDK provides the server infrastructure - I just need to plug in my logic.

Step 2: Create the Server

Here’s the basic server structure:

src/index.ts
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import {
ListResourcesRequestSchema,
ReadResourceRequestSchema,
CallToolRequestSchema,
ListToolsRequestSchema,
} from '@modelcontextprotocol/sdk/types.js';
import * as fs from 'fs/promises';
import * as path from 'path';
const vaultPath = process.env.OBSIDIAN_VAULT_PATH || '';
const server = new Server(
{ name: 'obsidian-librarian', version: '1.0.0' },
{ capabilities: { resources: {}, tools: {} } }
);
// List all markdown files as resources
server.setRequestHandler(ListResourcesRequestSchema, async () => {
const notes = await listMarkdownFiles(vaultPath);
return {
resources: notes.map(note => ({
uri: `obsidian://${note.path}`,
name: note.title,
mimeType: 'text/markdown'
}))
};
});
// Read a specific note
server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
const filePath = request.params.uri.replace('obsidian://', '');
const content = await fs.readFile(filePath, 'utf-8');
return {
contents: [{
uri: request.params.uri,
text: content
}]
};
});
// Start the server
const transport = new StdioServerTransport();
await server.connect(transport);

This gives Claude the ability to list and read any note in my vault.

Step 3: Add Search Tools

Reading files is nice, but I wanted Claude to search my notes. I added two search tools:

src/tools.ts
// Define available tools
server.setRequestHandler(ListToolsRequestSchema, async () => {
return {
tools: [
{
name: 'search_notes',
description: 'Search for notes by keyword',
inputSchema: {
type: 'object',
properties: {
query: { type: 'string', description: 'Search query' }
},
required: ['query']
}
},
{
name: 'semantic_search',
description: 'Find semantically similar notes using embeddings',
inputSchema: {
type: 'object',
properties: {
query: { type: 'string', description: 'Concept to search for' }
},
required: ['query']
}
}
]
};
});
// Handle tool calls
server.setRequestHandler(CallToolRequestSchema, async (request) => {
if (request.params.name === 'search_notes') {
const query = request.params.arguments.query;
const results = await searchMarkdownFiles(vaultPath, query);
return {
content: [{
type: 'text',
text: JSON.stringify(results, null, 2)
}]
};
}
if (request.params.name === 'semantic_search') {
const query = request.params.arguments.query;
const results = await semanticSearch(query, vaultPath);
return {
content: [{
type: 'text',
text: JSON.stringify(results, null, 2)
}]
};
}
});

The keyword search is straightforward - just grep through files. Semantic search is more interesting.

Step 4: Semantic Search with Embeddings

For semantic search, I needed to convert notes into embeddings and compare them with the query:

src/semantic.ts
import { OpenAI } from 'openai';
const openai = new OpenAI();
// Pre-compute embeddings for all notes
async function indexVault(vaultPath: string): Promise<Map<string, number[]>> {
const embeddings = new Map();
const notes = await listMarkdownFiles(vaultPath);
for (const note of notes) {
const content = await fs.readFile(note.path, 'utf-8');
const response = await openai.embeddings.create({
model: 'text-embedding-3-small',
input: content.slice(0, 8000) // Token limit
});
embeddings.set(note.path, response.data[0].embedding);
}
return embeddings;
}
// Find similar notes
async function semanticSearch(
query: string,
vaultPath: string
): Promise<{ path: string; similarity: number }[]> {
// Get query embedding
const queryEmbedding = await openai.embeddings.create({
model: 'text-embedding-3-small',
input: query
});
const queryVector = queryEmbedding.data[0].embedding;
const noteEmbeddings = await getCachedEmbeddings(vaultPath);
// Calculate cosine similarity
const results = [];
for (const [path, embedding] of noteEmbeddings) {
const similarity = cosineSimilarity(queryVector, embedding);
results.push({ path, similarity });
}
return results.sort((a, b) => b.similarity - a.similarity).slice(0, 5);
}
function cosineSimilarity(a: number[], b: number[]): number {
let dotProduct = 0;
let normA = 0;
let normB = 0;
for (let i = 0; i < a.length; i++) {
dotProduct += a[i] * b[i];
normA += a[i] * a[i];
normB += b[i] * b[i];
}
return dotProduct / (Math.sqrt(normA) * Math.sqrt(normB));
}

This lets Claude find notes that are conceptually related, even if they don’t share keywords.

Step 5: Behavioral Instructions

The Reddit comment mentioned that Claude “reads the .md for behavioral instructions.” This was key - I could store my preferences in a markdown file:

.claude/instructions.md
# Claude Behavioral Instructions
## Response Style
- Be concise and direct
- Use code examples when explaining concepts
- Avoid unnecessary elaboration
## Project Context
- This is a TypeScript project
- We use functional programming patterns
- All functions should be pure
## Knowledge Focus
- Prioritize information from linked notes
- Cross-reference with graph connections
- Highlight contradictions between notes

Now when I start a session, Claude automatically reads this file and adjusts its behavior.

Step 6: Configure Claude Desktop

I needed to tell Claude Desktop about my server:

~/Library/Application Support/Claude/claude_desktop_config.json
{
"mcpServers": {
"obsidian-librarian": {
"command": "node",
"args": ["/path/to/obsidian-mcp-server/dist/index.js"],
"env": {
"OBSIDIAN_VAULT_PATH": "/path/to/your/vault"
}
}
}
}

After restarting Claude Desktop, my server appeared in the tools list.

What This Enables

Now when I work with Claude, it can:

  • Remember across sessions - My notes become persistent context
  • Find related concepts - Semantic search finds what I mean, not just what I type
  • Follow my preferences - Behavioral instructions shape responses
  • Track progress - Claude can update notes with new information

Here’s a simple tool I added for progress tracking:

src/progress.ts
server.setRequestHandler(CallToolRequestSchema, async (request) => {
if (request.params.name === 'update_progress') {
const { task, status, notes } = request.params.arguments;
const progressPath = path.join(vaultPath, 'Progress.md');
let content = await fs.readFile(progressPath, 'utf-8');
content += `\n## ${new Date().toISOString()}\n`;
content += `- Task: ${task}\n`;
content += `- Status: ${status}\n`;
content += `- Notes: ${notes}\n`;
await fs.writeFile(progressPath, content);
return {
content: [{ type: 'text', text: 'Progress updated!' }]
};
}
});

Why This Works Better Than RAG

I considered building a RAG system, but MCP is simpler:

ApproachProsCons
RAGFull control, custom indexingComplex, needs vector DB
MCP ServerSimple protocol, native Claude supportLimited to Claude

MCP gives me the benefits of RAG (persistent memory, semantic search) without managing infrastructure. The server runs locally, and Claude Desktop handles the integration.

Summary

In this post, I showed how to build an MCP server that connects Claude to an Obsidian vault. The key insight from the Reddit discussion was treating the server as a “librarian” - it organizes, retrieves, and maintains knowledge for AI-assisted workflows.

The main components are:

  1. Resource handlers for reading notes
  2. Tool handlers for search operations
  3. Semantic search via embeddings
  4. Behavioral instructions in markdown

With this setup, Claude now “remembers” my context across sessions, finds related concepts in my notes, and follows my documented preferences.

Final Words + More Resources

My intention with this article was to help others share my knowledge and experience. If you want to contact me, you can contact by email: Email me

Here are also the most important links from this article along with some further resources that will help you in this scope:

Oh, and if you found these resources useful, don’t forget to support me by starring the repo on GitHub!

Comments