@kazuph/mcp-obsidian

#!/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); });