# Directory Structure ``` ├── .gitignore ├── index.ts ├── LICENSE ├── package.json ├── pnpm-lock.yaml ├── README.md └── tsconfig.json ``` # Files -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- ``` dist node_modules ``` -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- ```markdown # MCP Obsidian Model Context Protocol server for Obsidian vault integration. This allows Claude Desktop (or any MCP client) to search and read your Obsidian notes. ## Quick Start (For Users) ### Prerequisites - Node.js 18+ (install via `brew install node`) - Obsidian vault - Claude Desktop (install from https://claude.ai/desktop) ### Configuration 1. Open your Claude Desktop configuration file at: `~/Library/Application Support/Claude/claude_desktop_config.json` You can find this through the Claude Desktop menu: 1. Open Claude Desktop 2. Click Claude on the Mac menu bar 3. Click "Settings" 4. Click "Developer" 2. Add the following to your configuration: ```json { "tools": { "obsidian": { "command": "npx", "args": ["-y", "@kazuph/mcp-obsidian"], "env": { "OBSIDIAN_VAULT_PATH": "/path/to/your/obsidian/vault" } } } } ``` Note: Replace `/path/to/your/obsidian/vault` with your actual Obsidian vault path. ## For Developers ### Prerequisites - Node.js 18+ (install via `brew install node`) - Obsidian vault - Claude Desktop (install from https://claude.ai/desktop) - tsx (install via `npm install -g tsx`) ## Installation ```bash git clone https://github.com/kazuph/mcp-obsidian.git cd mcp-obsidian npm install npm run build ``` ## Configuration 1. Make sure Claude Desktop is installed and running. 2. Install tsx globally if you haven't: ```bash npm install -g tsx # or pnpm add -g tsx ``` 3. Modify your Claude Desktop config located at: `~/Library/Application Support/Claude/claude_desktop_config.json` You can easily find this through the Claude Desktop menu: 1. Open Claude Desktop 2. Click Claude on the Mac menu bar 3. Click "Settings" 4. Click "Developer" Add the following to your MCP client's configuration: ```json { "tools": { "obsidian": { "args": ["tsx", "/path/to/mcp-obsidian/index.ts"], "env": { "OBSIDIAN_VAULT_PATH": "/path/to/your/obsidian/vault" } } } } ``` ## Available Tools - `obsidian_read_notes`: Read the contents of multiple notes. Each note's content is returned with its path as a reference. - `obsidian_search_notes`: Search for notes by name (case-insensitive, supports partial matches and regex). - `obsidian_read_notes_dir`: List the directory structure under a specified path. - `obsidian_write_note`: Create a new note at the specified path. ``` -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- ```json { "compilerOptions": { "target": "ES2022", "strict": true, "esModuleInterop": true, "skipLibCheck": true, "forceConsistentCasingInFileNames": true, "resolveJsonModule": true, "outDir": "./dist", "rootDir": ".", "moduleResolution": "NodeNext", "module": "NodeNext" }, "exclude": ["node_modules"], "include": ["./**/*.ts"] } ``` -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- ```json { "name": "@kazuph/mcp-obsidian", "version": "1.0.2", "description": "Model Context Protocol server for Obsidian Vaults", "author": "kazuph (https://x.com/kazuph)", "main": "dist/index.js", "type": "module", "bin": { "mcp-obsidian": "dist/index.js" }, "files": [ "dist" ], "scripts": { "build": "tsc && shx chmod +x dist/*.js", "prepare": "npm run build", "watch": "tsc --watch" }, "repository": { "type": "git", "url": "git+https://github.com/kazuph/mcp-obsidian.git" }, "keywords": [ "obsidian", "mcp", "claude" ], "license": "MIT", "publishConfig": { "access": "public" }, "dependencies": { "@modelcontextprotocol/sdk": "0.5.0", "glob": "^10.3.10", "zod": "^3.23.8", "zod-to-json-schema": "^3.23.5" }, "devDependencies": { "@types/node": "^20.11.0", "shx": "^0.3.4", "typescript": "^5.3.3" } } ``` -------------------------------------------------------------------------------- /index.ts: -------------------------------------------------------------------------------- ```typescript #!/usr/bin/env node import { Server } from "@modelcontextprotocol/sdk/server/index.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { CallToolRequestSchema, ListToolsRequestSchema, ToolSchema, } from "@modelcontextprotocol/sdk/types.js"; import fs from "node:fs/promises"; import path from "node:path"; import os from "node:os"; import { z } from "zod"; import { zodToJsonSchema } from "zod-to-json-schema"; // Maximum number of search results to return const SEARCH_LIMIT = 200; interface Config { obsidianVaultPath: string; } // Configuration from environment variables const config: Config = { obsidianVaultPath: process.env.OBSIDIAN_VAULT_PATH || "", }; if (!config.obsidianVaultPath) { console.error("Error: OBSIDIAN_VAULT_PATH environment variable is required"); process.exit(1); } // Store allowed directories in normalized form const vaultDirectories = [ normalizePath(path.resolve(expandHome(config.obsidianVaultPath))), ]; // Normalize all paths consistently function normalizePath(p: string): string { return path.normalize(p).toLowerCase(); } function expandHome(filepath: string): string { if (filepath.startsWith("~/") || filepath === "~") { return path.join(os.homedir(), filepath.slice(1)); } return filepath; } // Validate that all directories exist and are accessible await Promise.all( vaultDirectories.map(async (dir) => { try { const stats = await fs.stat(dir); if (!stats.isDirectory()) { console.error(`Error: ${dir} is not a directory`); process.exit(1); } } catch (error) { console.error(`Error accessing directory ${dir}:`, error); process.exit(1); } }), ); // Security utilities async function validatePath(requestedPath: string): Promise<string> { // Ignore hidden files/directories starting with "." const pathParts = requestedPath.split(path.sep); if (pathParts.some((part) => part.startsWith("."))) { throw new Error("Access denied - hidden files/directories not allowed"); } const expandedPath = expandHome(requestedPath); const absolute = path.isAbsolute(expandedPath) ? path.resolve(expandedPath) : path.resolve(process.cwd(), expandedPath); const normalizedRequested = normalizePath(absolute); // Check if path is within allowed directories const isAllowed = vaultDirectories.some((dir) => normalizedRequested.startsWith(dir), ); if (!isAllowed) { throw new Error( `Access denied - path outside allowed directories: ${absolute} not in ${vaultDirectories.join( ", ", )}`, ); } // Handle symlinks by checking their real path try { const realPath = await fs.realpath(absolute); const normalizedReal = normalizePath(realPath); const isRealPathAllowed = vaultDirectories.some((dir) => normalizedReal.startsWith(dir), ); if (!isRealPathAllowed) { throw new Error( "Access denied - symlink target outside allowed directories", ); } return realPath; } catch (error) { // For new files that don't exist yet, verify parent directory const parentDir = path.dirname(absolute); try { const realParentPath = await fs.realpath(parentDir); const normalizedParent = normalizePath(realParentPath); const isParentAllowed = vaultDirectories.some((dir) => normalizedParent.startsWith(dir), ); if (!isParentAllowed) { throw new Error( "Access denied - parent directory outside allowed directories", ); } return absolute; } catch { throw new Error(`Parent directory does not exist: ${parentDir}`); } } } // Schema definitions const ReadNotesArgsSchema = z.object({ paths: z.array(z.string()), }); const SearchNotesArgsSchema = z.object({ query: z.string(), }); const ReadNotesDirArgsSchema = z.object({ path: z.string(), }); const WriteNoteArgsSchema = z.object({ path: z.string(), content: z.string(), }); const ToolInputSchema = ToolSchema.shape.inputSchema; type ToolInput = z.infer<typeof ToolInputSchema>; // Server setup const server = new Server( { name: "mcp-obsidian", version: "1.0.0", }, { capabilities: { tools: {}, }, }, ); /** * Search for notes in the allowed directories that match the query. * @param query - The query to search for. * @returns An array of relative paths to the notes (from root) that match the query. */ async function searchNotes(query: string): Promise<string[]> { const results: string[] = []; async function search(basePath: string, currentPath: string) { const entries = await fs.readdir(currentPath, { withFileTypes: true }); for (const entry of entries) { const fullPath = path.join(currentPath, entry.name); try { // Validate each path before processing await validatePath(fullPath); let matches = entry.name.toLowerCase().includes(query.toLowerCase()); try { matches = matches || new RegExp(query.replace(/[*]/g, ".*"), "i").test(entry.name); } catch { // Ignore invalid regex } if (entry.name.endsWith(".md") && matches) { // Turn into relative path results.push(fullPath.replace(basePath, "")); } if (entry.isDirectory()) { await search(basePath, fullPath); } } catch (error) { // Skip invalid paths during search console.error(`Error searching ${fullPath}:`, error); } } } await Promise.all(vaultDirectories.map((dir) => search(dir, dir))); return results; } // Tool handlers server.setRequestHandler(ListToolsRequestSchema, async () => { const tools = [ { name: "obsidian_read_notes", description: "Read the contents of multiple notes. Each note's content is returned with its " + "path as a reference. Failed reads for individual notes won't stop " + "the entire operation. Reading too many at once may result in an error.", inputSchema: zodToJsonSchema(ReadNotesArgsSchema) as ToolInput, }, { name: "obsidian_search_notes", description: "Searches for a note by its name. The search " + "is case-insensitive and matches partial names. " + "Queries can also be a valid regex. Returns paths of the notes " + "that match the query.", inputSchema: zodToJsonSchema(SearchNotesArgsSchema) as ToolInput, }, { name: "obsidian_read_notes_dir", description: "Lists only the directory structure under the specified path. " + "Returns the relative paths of all directories without file contents.", inputSchema: zodToJsonSchema(ReadNotesDirArgsSchema) as ToolInput, }, { name: "obsidian_write_note", description: "Creates a new note at the specified path. Before writing, " + "check the directory structure using obsidian_read_notes_dir. " + "If the target directory is unclear, the operation will be paused " + "and you will be prompted to specify the correct directory.", inputSchema: zodToJsonSchema(WriteNoteArgsSchema) as ToolInput, }, ]; return { tools }; }); server.setRequestHandler(CallToolRequestSchema, async (request) => { try { const { name, arguments: args } = request.params; switch (name) { case "obsidian_read_notes": { const parsed = ReadNotesArgsSchema.safeParse(args); if (!parsed.success) { throw new Error( `Invalid arguments for obsidian_read_notes: ${parsed.error}`, ); } const results = await Promise.all( parsed.data.paths.map(async (filePath: string) => { try { const validPath = await validatePath( path.join(vaultDirectories[0], filePath), ); const content = await fs.readFile(validPath, "utf-8"); return `${filePath}:\n${content}\n`; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); return `${filePath}: Error - ${errorMessage}`; } }), ); return { content: [{ type: "text", text: results.join("\n---\n") }], }; } case "obsidian_search_notes": { const parsed = SearchNotesArgsSchema.safeParse(args); if (!parsed.success) { throw new Error( `Invalid arguments for obsidian_search_notes: ${parsed.error}`, ); } const results = await searchNotes(parsed.data.query); const limitedResults = results.slice(0, SEARCH_LIMIT); return { content: [ { type: "text", text: (limitedResults.length > 0 ? limitedResults.join("\n") : "No matches found") + (results.length > SEARCH_LIMIT ? `\n\n... ${ results.length - SEARCH_LIMIT } more results not shown.` : ""), }, ], }; } case "obsidian_read_notes_dir": { const parsed = ReadNotesDirArgsSchema.safeParse(args); if (!parsed.success) { throw new Error( `Invalid arguments for obsidian_read_notes_dir: ${parsed.error}`, ); } const validPath = await validatePath( path.join(vaultDirectories[0], parsed.data.path), ); const dirs: string[] = []; async function listDirs(currentPath: string) { const entries = await fs.readdir(currentPath, { withFileTypes: true, }); for (const entry of entries) { if (entry.isDirectory()) { const fullPath = path.join(currentPath, entry.name); try { await validatePath(fullPath); dirs.push(fullPath.replace(vaultDirectories[0], "")); await listDirs(fullPath); } catch (error) { console.error(`Error listing ${fullPath}:`, error); } } } } await listDirs(validPath); return { content: [{ type: "text", text: dirs.join("\n") }], }; } case "obsidian_write_note": { const parsed = WriteNoteArgsSchema.safeParse(args); if (!parsed.success) { throw new Error( `Invalid arguments for obsidian_write_note: ${parsed.error}`, ); } try { const validPath = await validatePath( path.join(vaultDirectories[0], parsed.data.path), ); await fs.writeFile(validPath, parsed.data.content, "utf-8"); return { content: [ { type: "text", text: `Note successfully written to ${parsed.data.path}`, }, ], }; } catch (error) { return { content: [ { type: "text", text: `Please specify the target directory. Available directories:\n${vaultDirectories.join( "\n", )}`, }, ], isError: true, }; } } default: throw new Error(`Unknown tool: ${name}`); } } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); return { content: [{ type: "text", text: `Error: ${errorMessage}` }], isError: true, }; } }); // Start server async function runServer() { const transport = new StdioServerTransport(); await server.connect(transport); console.error("MCP Obsidian Server running on stdio"); console.error("Allowed directories:", vaultDirectories); } runServer().catch((error) => { console.error("Fatal error running server:", error); process.exit(1); }); ```