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 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:
- Reads notes - Claude can access any markdown file in my vault
- 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:
mkdir obsidian-mcp-servercd obsidian-mcp-servernpm init -ynpm install @modelcontextprotocol/sdkThe 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:
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 resourcesserver.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 noteserver.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 serverconst 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:
// Define available toolsserver.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 callsserver.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:
import { OpenAI } from 'openai';
const openai = new OpenAI();
// Pre-compute embeddings for all notesasync 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 notesasync 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 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 notesNow 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:
{ "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:
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:
| Approach | Pros | Cons |
|---|---|---|
| RAG | Full control, custom indexing | Complex, needs vector DB |
| MCP Server | Simple protocol, native Claude support | Limited 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:
- Resource handlers for reading notes
- Tool handlers for search operations
- Semantic search via embeddings
- 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:
- 👨💻 Model Context Protocol Documentation
- 👨💻 MCP TypeScript SDK
- 👨💻 Claude Desktop MCP Configuration
- 👨💻 Reddit Discussion: 1M Context is So Good
Oh, and if you found these resources useful, don’t forget to support me by starring the repo on GitHub!
Comments