# Directory Structure ``` ├── .gitignore ├── index.ts ├── LICENSE ├── package.json ├── pnpm-lock.yaml ├── README.md └── tsconfig.json ``` # Files -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- ``` 1 | dist 2 | node_modules ``` -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- ```markdown 1 | # MCP Obsidian 2 | 3 | Model Context Protocol server for Obsidian vault integration. This allows Claude Desktop (or any MCP client) to search and read your Obsidian notes. 4 | 5 | ## Quick Start (For Users) 6 | 7 | ### Prerequisites 8 | - Node.js 18+ (install via `brew install node`) 9 | - Obsidian vault 10 | - Claude Desktop (install from https://claude.ai/desktop) 11 | 12 | ### Configuration 13 | 14 | 1. Open your Claude Desktop configuration file at: 15 | `~/Library/Application Support/Claude/claude_desktop_config.json` 16 | 17 | You can find this through the Claude Desktop menu: 18 | 1. Open Claude Desktop 19 | 2. Click Claude on the Mac menu bar 20 | 3. Click "Settings" 21 | 4. Click "Developer" 22 | 23 | 2. Add the following to your configuration: 24 | 25 | ```json 26 | { 27 | "tools": { 28 | "obsidian": { 29 | "command": "npx", 30 | "args": ["-y", "@kazuph/mcp-obsidian"], 31 | "env": { 32 | "OBSIDIAN_VAULT_PATH": "/path/to/your/obsidian/vault" 33 | } 34 | } 35 | } 36 | } 37 | ``` 38 | 39 | Note: Replace `/path/to/your/obsidian/vault` with your actual Obsidian vault path. 40 | 41 | ## For Developers 42 | 43 | ### Prerequisites 44 | - Node.js 18+ (install via `brew install node`) 45 | - Obsidian vault 46 | - Claude Desktop (install from https://claude.ai/desktop) 47 | - tsx (install via `npm install -g tsx`) 48 | 49 | ## Installation 50 | 51 | ```bash 52 | git clone https://github.com/kazuph/mcp-obsidian.git 53 | cd mcp-obsidian 54 | npm install 55 | npm run build 56 | ``` 57 | 58 | ## Configuration 59 | 60 | 1. Make sure Claude Desktop is installed and running. 61 | 62 | 2. Install tsx globally if you haven't: 63 | ```bash 64 | npm install -g tsx 65 | # or 66 | pnpm add -g tsx 67 | ``` 68 | 69 | 3. Modify your Claude Desktop config located at: 70 | `~/Library/Application Support/Claude/claude_desktop_config.json` 71 | 72 | You can easily find this through the Claude Desktop menu: 73 | 1. Open Claude Desktop 74 | 2. Click Claude on the Mac menu bar 75 | 3. Click "Settings" 76 | 4. Click "Developer" 77 | 78 | Add the following to your MCP client's configuration: 79 | 80 | ```json 81 | { 82 | "tools": { 83 | "obsidian": { 84 | "args": ["tsx", "/path/to/mcp-obsidian/index.ts"], 85 | "env": { 86 | "OBSIDIAN_VAULT_PATH": "/path/to/your/obsidian/vault" 87 | } 88 | } 89 | } 90 | } 91 | ``` 92 | 93 | ## Available Tools 94 | 95 | - `obsidian_read_notes`: Read the contents of multiple notes. Each note's content is returned with its path as a reference. 96 | - `obsidian_search_notes`: Search for notes by name (case-insensitive, supports partial matches and regex). 97 | - `obsidian_read_notes_dir`: List the directory structure under a specified path. 98 | - `obsidian_write_note`: Create a new note at the specified path. 99 | ``` -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- ```json 1 | { 2 | "compilerOptions": { 3 | "target": "ES2022", 4 | "strict": true, 5 | "esModuleInterop": true, 6 | "skipLibCheck": true, 7 | "forceConsistentCasingInFileNames": true, 8 | "resolveJsonModule": true, 9 | "outDir": "./dist", 10 | "rootDir": ".", 11 | "moduleResolution": "NodeNext", 12 | "module": "NodeNext" 13 | }, 14 | "exclude": ["node_modules"], 15 | "include": ["./**/*.ts"] 16 | } 17 | ``` -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- ```json 1 | { 2 | "name": "@kazuph/mcp-obsidian", 3 | "version": "1.0.2", 4 | "description": "Model Context Protocol server for Obsidian Vaults", 5 | "author": "kazuph (https://x.com/kazuph)", 6 | "main": "dist/index.js", 7 | "type": "module", 8 | "bin": { 9 | "mcp-obsidian": "dist/index.js" 10 | }, 11 | "files": [ 12 | "dist" 13 | ], 14 | "scripts": { 15 | "build": "tsc && shx chmod +x dist/*.js", 16 | "prepare": "npm run build", 17 | "watch": "tsc --watch" 18 | }, 19 | "repository": { 20 | "type": "git", 21 | "url": "git+https://github.com/kazuph/mcp-obsidian.git" 22 | }, 23 | "keywords": [ 24 | "obsidian", 25 | "mcp", 26 | "claude" 27 | ], 28 | "license": "MIT", 29 | "publishConfig": { 30 | "access": "public" 31 | }, 32 | "dependencies": { 33 | "@modelcontextprotocol/sdk": "0.5.0", 34 | "glob": "^10.3.10", 35 | "zod": "^3.23.8", 36 | "zod-to-json-schema": "^3.23.5" 37 | }, 38 | "devDependencies": { 39 | "@types/node": "^20.11.0", 40 | "shx": "^0.3.4", 41 | "typescript": "^5.3.3" 42 | } 43 | } 44 | ``` -------------------------------------------------------------------------------- /index.ts: -------------------------------------------------------------------------------- ```typescript 1 | #!/usr/bin/env node 2 | 3 | import { Server } from "@modelcontextprotocol/sdk/server/index.js"; 4 | import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; 5 | import { 6 | CallToolRequestSchema, 7 | ListToolsRequestSchema, 8 | ToolSchema, 9 | } from "@modelcontextprotocol/sdk/types.js"; 10 | import fs from "node:fs/promises"; 11 | import path from "node:path"; 12 | import os from "node:os"; 13 | import { z } from "zod"; 14 | import { zodToJsonSchema } from "zod-to-json-schema"; 15 | 16 | // Maximum number of search results to return 17 | const SEARCH_LIMIT = 200; 18 | 19 | interface Config { 20 | obsidianVaultPath: string; 21 | } 22 | 23 | // Configuration from environment variables 24 | const config: Config = { 25 | obsidianVaultPath: process.env.OBSIDIAN_VAULT_PATH || "", 26 | }; 27 | 28 | if (!config.obsidianVaultPath) { 29 | console.error("Error: OBSIDIAN_VAULT_PATH environment variable is required"); 30 | process.exit(1); 31 | } 32 | 33 | // Store allowed directories in normalized form 34 | const vaultDirectories = [ 35 | normalizePath(path.resolve(expandHome(config.obsidianVaultPath))), 36 | ]; 37 | 38 | // Normalize all paths consistently 39 | function normalizePath(p: string): string { 40 | return path.normalize(p).toLowerCase(); 41 | } 42 | 43 | function expandHome(filepath: string): string { 44 | if (filepath.startsWith("~/") || filepath === "~") { 45 | return path.join(os.homedir(), filepath.slice(1)); 46 | } 47 | return filepath; 48 | } 49 | 50 | // Validate that all directories exist and are accessible 51 | await Promise.all( 52 | vaultDirectories.map(async (dir) => { 53 | try { 54 | const stats = await fs.stat(dir); 55 | if (!stats.isDirectory()) { 56 | console.error(`Error: ${dir} is not a directory`); 57 | process.exit(1); 58 | } 59 | } catch (error) { 60 | console.error(`Error accessing directory ${dir}:`, error); 61 | process.exit(1); 62 | } 63 | }), 64 | ); 65 | 66 | // Security utilities 67 | async function validatePath(requestedPath: string): Promise<string> { 68 | // Ignore hidden files/directories starting with "." 69 | const pathParts = requestedPath.split(path.sep); 70 | if (pathParts.some((part) => part.startsWith("."))) { 71 | throw new Error("Access denied - hidden files/directories not allowed"); 72 | } 73 | 74 | const expandedPath = expandHome(requestedPath); 75 | const absolute = path.isAbsolute(expandedPath) 76 | ? path.resolve(expandedPath) 77 | : path.resolve(process.cwd(), expandedPath); 78 | 79 | const normalizedRequested = normalizePath(absolute); 80 | 81 | // Check if path is within allowed directories 82 | const isAllowed = vaultDirectories.some((dir) => 83 | normalizedRequested.startsWith(dir), 84 | ); 85 | if (!isAllowed) { 86 | throw new Error( 87 | `Access denied - path outside allowed directories: ${absolute} not in ${vaultDirectories.join( 88 | ", ", 89 | )}`, 90 | ); 91 | } 92 | 93 | // Handle symlinks by checking their real path 94 | try { 95 | const realPath = await fs.realpath(absolute); 96 | const normalizedReal = normalizePath(realPath); 97 | const isRealPathAllowed = vaultDirectories.some((dir) => 98 | normalizedReal.startsWith(dir), 99 | ); 100 | if (!isRealPathAllowed) { 101 | throw new Error( 102 | "Access denied - symlink target outside allowed directories", 103 | ); 104 | } 105 | return realPath; 106 | } catch (error) { 107 | // For new files that don't exist yet, verify parent directory 108 | const parentDir = path.dirname(absolute); 109 | try { 110 | const realParentPath = await fs.realpath(parentDir); 111 | const normalizedParent = normalizePath(realParentPath); 112 | const isParentAllowed = vaultDirectories.some((dir) => 113 | normalizedParent.startsWith(dir), 114 | ); 115 | if (!isParentAllowed) { 116 | throw new Error( 117 | "Access denied - parent directory outside allowed directories", 118 | ); 119 | } 120 | return absolute; 121 | } catch { 122 | throw new Error(`Parent directory does not exist: ${parentDir}`); 123 | } 124 | } 125 | } 126 | 127 | // Schema definitions 128 | const ReadNotesArgsSchema = z.object({ 129 | paths: z.array(z.string()), 130 | }); 131 | 132 | const SearchNotesArgsSchema = z.object({ 133 | query: z.string(), 134 | }); 135 | 136 | const ReadNotesDirArgsSchema = z.object({ 137 | path: z.string(), 138 | }); 139 | 140 | const WriteNoteArgsSchema = z.object({ 141 | path: z.string(), 142 | content: z.string(), 143 | }); 144 | 145 | const ToolInputSchema = ToolSchema.shape.inputSchema; 146 | type ToolInput = z.infer<typeof ToolInputSchema>; 147 | 148 | // Server setup 149 | const server = new Server( 150 | { 151 | name: "mcp-obsidian", 152 | version: "1.0.0", 153 | }, 154 | { 155 | capabilities: { 156 | tools: {}, 157 | }, 158 | }, 159 | ); 160 | 161 | /** 162 | * Search for notes in the allowed directories that match the query. 163 | * @param query - The query to search for. 164 | * @returns An array of relative paths to the notes (from root) that match the query. 165 | */ 166 | async function searchNotes(query: string): Promise<string[]> { 167 | const results: string[] = []; 168 | 169 | async function search(basePath: string, currentPath: string) { 170 | const entries = await fs.readdir(currentPath, { withFileTypes: true }); 171 | 172 | for (const entry of entries) { 173 | const fullPath = path.join(currentPath, entry.name); 174 | 175 | try { 176 | // Validate each path before processing 177 | await validatePath(fullPath); 178 | 179 | let matches = entry.name.toLowerCase().includes(query.toLowerCase()); 180 | try { 181 | matches = 182 | matches || 183 | new RegExp(query.replace(/[*]/g, ".*"), "i").test(entry.name); 184 | } catch { 185 | // Ignore invalid regex 186 | } 187 | 188 | if (entry.name.endsWith(".md") && matches) { 189 | // Turn into relative path 190 | results.push(fullPath.replace(basePath, "")); 191 | } 192 | 193 | if (entry.isDirectory()) { 194 | await search(basePath, fullPath); 195 | } 196 | } catch (error) { 197 | // Skip invalid paths during search 198 | console.error(`Error searching ${fullPath}:`, error); 199 | } 200 | } 201 | } 202 | 203 | await Promise.all(vaultDirectories.map((dir) => search(dir, dir))); 204 | return results; 205 | } 206 | 207 | // Tool handlers 208 | server.setRequestHandler(ListToolsRequestSchema, async () => { 209 | const tools = [ 210 | { 211 | name: "obsidian_read_notes", 212 | description: 213 | "Read the contents of multiple notes. Each note's content is returned with its " + 214 | "path as a reference. Failed reads for individual notes won't stop " + 215 | "the entire operation. Reading too many at once may result in an error.", 216 | inputSchema: zodToJsonSchema(ReadNotesArgsSchema) as ToolInput, 217 | }, 218 | { 219 | name: "obsidian_search_notes", 220 | description: 221 | "Searches for a note by its name. The search " + 222 | "is case-insensitive and matches partial names. " + 223 | "Queries can also be a valid regex. Returns paths of the notes " + 224 | "that match the query.", 225 | inputSchema: zodToJsonSchema(SearchNotesArgsSchema) as ToolInput, 226 | }, 227 | { 228 | name: "obsidian_read_notes_dir", 229 | description: 230 | "Lists only the directory structure under the specified path. " + 231 | "Returns the relative paths of all directories without file contents.", 232 | inputSchema: zodToJsonSchema(ReadNotesDirArgsSchema) as ToolInput, 233 | }, 234 | { 235 | name: "obsidian_write_note", 236 | description: 237 | "Creates a new note at the specified path. Before writing, " + 238 | "check the directory structure using obsidian_read_notes_dir. " + 239 | "If the target directory is unclear, the operation will be paused " + 240 | "and you will be prompted to specify the correct directory.", 241 | inputSchema: zodToJsonSchema(WriteNoteArgsSchema) as ToolInput, 242 | }, 243 | ]; 244 | 245 | return { tools }; 246 | }); 247 | 248 | server.setRequestHandler(CallToolRequestSchema, async (request) => { 249 | try { 250 | const { name, arguments: args } = request.params; 251 | 252 | switch (name) { 253 | case "obsidian_read_notes": { 254 | const parsed = ReadNotesArgsSchema.safeParse(args); 255 | if (!parsed.success) { 256 | throw new Error( 257 | `Invalid arguments for obsidian_read_notes: ${parsed.error}`, 258 | ); 259 | } 260 | const results = await Promise.all( 261 | parsed.data.paths.map(async (filePath: string) => { 262 | try { 263 | const validPath = await validatePath( 264 | path.join(vaultDirectories[0], filePath), 265 | ); 266 | const content = await fs.readFile(validPath, "utf-8"); 267 | return `${filePath}:\n${content}\n`; 268 | } catch (error) { 269 | const errorMessage = 270 | error instanceof Error ? error.message : String(error); 271 | return `${filePath}: Error - ${errorMessage}`; 272 | } 273 | }), 274 | ); 275 | return { 276 | content: [{ type: "text", text: results.join("\n---\n") }], 277 | }; 278 | } 279 | case "obsidian_search_notes": { 280 | const parsed = SearchNotesArgsSchema.safeParse(args); 281 | if (!parsed.success) { 282 | throw new Error( 283 | `Invalid arguments for obsidian_search_notes: ${parsed.error}`, 284 | ); 285 | } 286 | const results = await searchNotes(parsed.data.query); 287 | 288 | const limitedResults = results.slice(0, SEARCH_LIMIT); 289 | return { 290 | content: [ 291 | { 292 | type: "text", 293 | text: 294 | (limitedResults.length > 0 295 | ? limitedResults.join("\n") 296 | : "No matches found") + 297 | (results.length > SEARCH_LIMIT 298 | ? `\n\n... ${ 299 | results.length - SEARCH_LIMIT 300 | } more results not shown.` 301 | : ""), 302 | }, 303 | ], 304 | }; 305 | } 306 | case "obsidian_read_notes_dir": { 307 | const parsed = ReadNotesDirArgsSchema.safeParse(args); 308 | if (!parsed.success) { 309 | throw new Error( 310 | `Invalid arguments for obsidian_read_notes_dir: ${parsed.error}`, 311 | ); 312 | } 313 | 314 | const validPath = await validatePath( 315 | path.join(vaultDirectories[0], parsed.data.path), 316 | ); 317 | 318 | const dirs: string[] = []; 319 | 320 | async function listDirs(currentPath: string) { 321 | const entries = await fs.readdir(currentPath, { 322 | withFileTypes: true, 323 | }); 324 | for (const entry of entries) { 325 | if (entry.isDirectory()) { 326 | const fullPath = path.join(currentPath, entry.name); 327 | try { 328 | await validatePath(fullPath); 329 | dirs.push(fullPath.replace(vaultDirectories[0], "")); 330 | await listDirs(fullPath); 331 | } catch (error) { 332 | console.error(`Error listing ${fullPath}:`, error); 333 | } 334 | } 335 | } 336 | } 337 | 338 | await listDirs(validPath); 339 | return { 340 | content: [{ type: "text", text: dirs.join("\n") }], 341 | }; 342 | } 343 | case "obsidian_write_note": { 344 | const parsed = WriteNoteArgsSchema.safeParse(args); 345 | if (!parsed.success) { 346 | throw new Error( 347 | `Invalid arguments for obsidian_write_note: ${parsed.error}`, 348 | ); 349 | } 350 | 351 | try { 352 | const validPath = await validatePath( 353 | path.join(vaultDirectories[0], parsed.data.path), 354 | ); 355 | await fs.writeFile(validPath, parsed.data.content, "utf-8"); 356 | return { 357 | content: [ 358 | { 359 | type: "text", 360 | text: `Note successfully written to ${parsed.data.path}`, 361 | }, 362 | ], 363 | }; 364 | } catch (error) { 365 | return { 366 | content: [ 367 | { 368 | type: "text", 369 | text: `Please specify the target directory. Available directories:\n${vaultDirectories.join( 370 | "\n", 371 | )}`, 372 | }, 373 | ], 374 | isError: true, 375 | }; 376 | } 377 | } 378 | default: 379 | throw new Error(`Unknown tool: ${name}`); 380 | } 381 | } catch (error) { 382 | const errorMessage = error instanceof Error ? error.message : String(error); 383 | return { 384 | content: [{ type: "text", text: `Error: ${errorMessage}` }], 385 | isError: true, 386 | }; 387 | } 388 | }); 389 | 390 | // Start server 391 | async function runServer() { 392 | const transport = new StdioServerTransport(); 393 | await server.connect(transport); 394 | console.error("MCP Obsidian Server running on stdio"); 395 | console.error("Allowed directories:", vaultDirectories); 396 | } 397 | 398 | runServer().catch((error) => { 399 | console.error("Fatal error running server:", error); 400 | process.exit(1); 401 | }); 402 | ```