#!/usr/bin/env node import { Server } from "@modelcontextprotocol/sdk/server/index.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextprotocol/sdk/types.js"; import fs from "fs/promises"; import path from "path"; import { glob } from "glob"; // Configuration const VAULT_PATH = process.env.OBSIDIAN_VAULT_PATH || "/path/to/your/vault"; class ObsidianMCPServer { server; constructor() { this.server = new Server({ name: "obsidian-mcp-server", version: "1.0.0", }, { capabilities: { tools: {}, }, }); this.setupHandlers(); } setupHandlers() { // List available tools this.server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: [ { name: "search_notes", description: "Search for notes in the Obsidian vault by content or filename", inputSchema: { type: "object", properties: { query: { type: "string", description: "Search query to find in note content or filename", }, search_type: { type: "string", enum: ["content", "filename", "both"], description: "Type of search to perform", default: "both", }, }, required: ["query"], }, }, { name: "read_note", description: "Read the content of a specific note", inputSchema: { type: "object", properties: { note_path: { type: "string", description: "Relative path to the note within the vault (e.g., 'folder/note.md')", }, }, required: ["note_path"], }, }, { name: "create_note", description: "Create a new note in the Obsidian vault", inputSchema: { type: "object", properties: { note_path: { type: "string", description: "Relative path for the new note (e.g., 'folder/note.md')", }, content: { type: "string", description: "Content of the note in Markdown format", }, }, required: ["note_path", "content"], }, }, { name: "update_note", description: "Update an existing note's content", inputSchema: { type: "object", properties: { note_path: { type: "string", description: "Relative path to the note (e.g., 'folder/note.md')", }, content: { type: "string", description: "New content for the note", }, }, required: ["note_path", "content"], }, }, { name: "delete_note", description: "Delete a note from the vault", inputSchema: { type: "object", properties: { note_path: { type: "string", description: "Relative path to the note to delete", }, }, required: ["note_path"], }, }, { name: "list_notes", description: "List all notes in the vault or in a specific folder", inputSchema: { type: "object", properties: { folder: { type: "string", description: "Optional folder path to list notes from (relative to vault root)", }, }, }, }, { name: "get_tags", description: "Extract all tags from notes in the vault", inputSchema: { type: "object", properties: { note_path: { type: "string", description: "Optional: specific note to get tags from. If not provided, gets all tags from vault", }, }, }, }, { name: "append_to_note", description: "Append content to an existing note", inputSchema: { type: "object", properties: { note_path: { type: "string", description: "Relative path to the note", }, content: { type: "string", description: "Content to append", }, }, required: ["note_path", "content"], }, }, ], })); // Handle tool calls this.server.setRequestHandler(CallToolRequestSchema, async (request) => { const { name, arguments: args } = request.params; try { if (!args) { throw new Error("Missing arguments"); } switch (name) { case "search_notes": return await this.searchNotes(args.query, args.search_type || "both"); case "read_note": return await this.readNote(args.note_path); case "create_note": return await this.createNote(args.note_path, args.content); case "update_note": return await this.updateNote(args.note_path, args.content); case "delete_note": return await this.deleteNote(args.note_path); case "list_notes": return await this.listNotes(args.folder); case "get_tags": return await this.getTags(args.note_path); case "append_to_note": return await this.appendToNote(args.note_path, args.content); default: throw new Error(`Unknown tool: ${name}`); } } catch (error) { return { content: [ { type: "text", text: `Error: ${error instanceof Error ? error.message : String(error)}`, }, ], }; } }); } async searchNotes(query, searchType) { const pattern = path.join(VAULT_PATH, "**/*.md"); const files = await glob(pattern, { nodir: true }); const results = []; for (const file of files) { const relativePath = path.relative(VAULT_PATH, file); const content = await fs.readFile(file, "utf-8"); const matches = []; if (searchType === "filename" || searchType === "both") { if (relativePath.toLowerCase().includes(query.toLowerCase())) { matches.push(`Filename match: ${relativePath}`); } } if (searchType === "content" || searchType === "both") { const lines = content.split("\n"); lines.forEach((line, index) => { if (line.toLowerCase().includes(query.toLowerCase())) { matches.push(`Line ${index + 1}: ${line.trim()}`); } }); } if (matches.length > 0) { results.push({ path: relativePath, matches }); } } return { content: [ { type: "text", text: JSON.stringify({ query, results, count: results.length }, null, 2), }, ], }; } async readNote(notePath) { const fullPath = path.join(VAULT_PATH, notePath); // Security check: ensure path is within vault if (!fullPath.startsWith(VAULT_PATH)) { throw new Error("Invalid path: outside vault"); } const content = await fs.readFile(fullPath, "utf-8"); return { content: [ { type: "text", text: content, }, ], }; } async createNote(notePath, content) { const fullPath = path.join(VAULT_PATH, notePath); if (!fullPath.startsWith(VAULT_PATH)) { throw new Error("Invalid path: outside vault"); } // Create directory if it doesn't exist await fs.mkdir(path.dirname(fullPath), { recursive: true }); // Check if file already exists try { await fs.access(fullPath); throw new Error(`Note already exists: ${notePath}`); } catch (error) { if (error.code !== "ENOENT") { throw error; } } await fs.writeFile(fullPath, content, "utf-8"); return { content: [ { type: "text", text: `Note created successfully: ${notePath}`, }, ], }; } async updateNote(notePath, content) { const fullPath = path.join(VAULT_PATH, notePath); if (!fullPath.startsWith(VAULT_PATH)) { throw new Error("Invalid path: outside vault"); } await fs.writeFile(fullPath, content, "utf-8"); return { content: [ { type: "text", text: `Note updated successfully: ${notePath}`, }, ], }; } async deleteNote(notePath) { const fullPath = path.join(VAULT_PATH, notePath); if (!fullPath.startsWith(VAULT_PATH)) { throw new Error("Invalid path: outside vault"); } await fs.unlink(fullPath); return { content: [ { type: "text", text: `Note deleted successfully: ${notePath}`, }, ], }; } async listNotes(folder) { const searchPath = folder ? path.join(VAULT_PATH, folder) : VAULT_PATH; const pattern = path.join(searchPath, "**/*.md"); const files = await glob(pattern, { nodir: true }); const notes = files.map((file) => { const relativePath = path.relative(VAULT_PATH, file); return { path: relativePath, name: path.basename(file, ".md"), }; }); return { content: [ { type: "text", text: JSON.stringify({ notes, count: notes.length }, null, 2), }, ], }; } async getTags(notePath) { const allTags = new Set(); if (notePath) { const fullPath = path.join(VAULT_PATH, notePath); const content = await fs.readFile(fullPath, "utf-8"); const tags = this.extractTags(content); tags.forEach((tag) => allTags.add(tag)); } else { const pattern = path.join(VAULT_PATH, "**/*.md"); const files = await glob(pattern, { nodir: true }); for (const file of files) { const content = await fs.readFile(file, "utf-8"); const tags = this.extractTags(content); tags.forEach((tag) => allTags.add(tag)); } } return { content: [ { type: "text", text: JSON.stringify({ tags: Array.from(allTags).sort() }, null, 2), }, ], }; } extractTags(content) { const tags = []; // Extract inline tags (#tag) const inlineTagRegex = /#([a-zA-Z0-9_/-]+)/g; let match; while ((match = inlineTagRegex.exec(content)) !== null) { tags.push(match[1]); } // Extract frontmatter tags const frontmatterRegex = /^---\n([\s\S]*?)\n---/; const frontmatterMatch = content.match(frontmatterRegex); if (frontmatterMatch) { const frontmatter = frontmatterMatch[1]; const tagsMatch = frontmatter.match(/tags:\s*\[(.*?)\]/); if (tagsMatch) { const fmTags = tagsMatch[1].split(",").map((t) => t.trim().replace(/['"]/g, "")); tags.push(...fmTags); } } return [...new Set(tags)]; } async appendToNote(notePath, content) { const fullPath = path.join(VAULT_PATH, notePath); if (!fullPath.startsWith(VAULT_PATH)) { throw new Error("Invalid path: outside vault"); } await fs.appendFile(fullPath, "\n" + content, "utf-8"); return { content: [ { type: "text", text: `Content appended successfully to: ${notePath}`, }, ], }; } async run() { const transport = new StdioServerTransport(); await this.server.connect(transport); console.error("Obsidian MCP server running on stdio"); } } const server = new ObsidianMCPServer(); server.run().catch(console.error);