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 "fs/promises" import path from "path" import os from "os" import { z } from "zod" import { zodToJsonSchema } from "zod-to-json-schema" // Maximum number of search results to return const SEARCH_LIMIT = 200 // Command line argument parsing const args = process.argv.slice(2) if (args.length === 0) { console.error("Usage: mcp-obsidian <vault-directory>") process.exit(1) } // 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 } // Store allowed directories in normalized form const vaultDirectories = [normalizePath(path.resolve(expandHome(args[0])))] // Validate that all directories exist and are accessible await Promise.all( args.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 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 continue } } } await Promise.all(vaultDirectories.map((dir) => search(dir, dir))) return results } // Tool handlers server.setRequestHandler(ListToolsRequestSchema, async () => { return { tools: [ { name: "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: "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, }, ], } }) server.setRequestHandler(CallToolRequestSchema, async (request) => { try { const { name, arguments: args } = request.params switch (name) { case "read_notes": { const parsed = ReadNotesArgsSchema.safeParse(args) if (!parsed.success) { throw new Error(`Invalid arguments for 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 "search_notes": { const parsed = SearchNotesArgsSchema.safeParse(args) if (!parsed.success) { throw new Error(`Invalid arguments for 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.` : ""), }, ], } } 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) })