Skip to main content
Glama
index.ts26.5 kB
#!/usr/bin/env node import { Server } from "@modelcontextprotocol/sdk/server/index.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { CallToolRequestSchema, ListToolsRequestSchema, Tool, } from "@modelcontextprotocol/sdk/types.js"; import * as fs from "fs/promises"; import * as path from "path"; // Get vault path from environment or command line const VAULT_PATH = process.env.OBSIDIAN_VAULT_PATH || process.argv[2] || ""; if (!VAULT_PATH) { console.error( "Error: OBSIDIAN_VAULT_PATH environment variable or command line argument required" ); process.exit(1); } // Tool definitions const tools: Tool[] = [ { name: "create_note", description: "Create a new note in the Obsidian vault. Supports nested folder creation.", inputSchema: { type: "object", properties: { path: { type: "string", description: "Path for the new note relative to vault root (e.g., 'folder/note.md')", }, content: { type: "string", description: "Content of the note in Markdown format", }, overwrite: { type: "boolean", description: "If true, overwrite existing note. Default: false", default: false, }, }, required: ["path", "content"], }, }, { name: "delete_note", description: "Delete a note from the Obsidian vault", inputSchema: { type: "object", properties: { path: { type: "string", description: "Path to the note to delete relative to vault root", }, }, required: ["path"], }, }, { name: "update_note", description: "Update/replace the entire content of an existing note", inputSchema: { type: "object", properties: { path: { type: "string", description: "Path to the note to update relative to vault root", }, content: { type: "string", description: "New content for the note", }, }, required: ["path", "content"], }, }, { name: "append_to_note", description: "Append content to the end of an existing note", inputSchema: { type: "object", properties: { path: { type: "string", description: "Path to the note relative to vault root", }, content: { type: "string", description: "Content to append", }, separator: { type: "string", description: "Separator to add before appended content. Default: '\\n\\n'", default: "\n\n", }, }, required: ["path", "content"], }, }, { name: "prepend_to_note", description: "Prepend content to the beginning of an existing note (after frontmatter if present)", inputSchema: { type: "object", properties: { path: { type: "string", description: "Path to the note relative to vault root", }, content: { type: "string", description: "Content to prepend", }, separator: { type: "string", description: "Separator to add after prepended content. Default: '\\n\\n'", default: "\n\n", }, }, required: ["path", "content"], }, }, { name: "rename_note", description: "Rename or move a note to a new location", inputSchema: { type: "object", properties: { oldPath: { type: "string", description: "Current path of the note", }, newPath: { type: "string", description: "New path for the note", }, }, required: ["oldPath", "newPath"], }, }, { name: "read_note", description: "Read the content of a single note", inputSchema: { type: "object", properties: { path: { type: "string", description: "Path to the note relative to vault root", }, }, required: ["path"], }, }, { name: "search_notes", description: "Search for notes by name pattern (case-insensitive, supports regex)", inputSchema: { type: "object", properties: { query: { type: "string", description: "Search query - can be a partial name or regex pattern", }, limit: { type: "number", description: "Maximum number of results to return. Default: 20", default: 20, }, }, required: ["query"], }, }, { name: "search_content", description: "Search for notes containing specific text in their content", inputSchema: { type: "object", properties: { query: { type: "string", description: "Text to search for in note contents", }, caseSensitive: { type: "boolean", description: "Whether search is case-sensitive. Default: false", default: false, }, limit: { type: "number", description: "Maximum number of results to return. Default: 20", default: 20, }, }, required: ["query"], }, }, { name: "list_folder", description: "List all notes and subfolders in a folder", inputSchema: { type: "object", properties: { path: { type: "string", description: "Folder path relative to vault root. Use empty string for vault root", default: "", }, recursive: { type: "boolean", description: "Whether to list recursively. Default: false", default: false, }, }, required: [], }, }, { name: "get_tags", description: "Get all tags used in a note", inputSchema: { type: "object", properties: { path: { type: "string", description: "Path to the note", }, }, required: ["path"], }, }, { name: "get_links", description: "Get all internal links (wikilinks and markdown links) from a note", inputSchema: { type: "object", properties: { path: { type: "string", description: "Path to the note", }, }, required: ["path"], }, }, { name: "get_backlinks", description: "Find all notes that link to a specific note", inputSchema: { type: "object", properties: { path: { type: "string", description: "Path to the note to find backlinks for", }, }, required: ["path"], }, }, { name: "insert_at_heading", description: "Insert content under a specific heading in a note", inputSchema: { type: "object", properties: { path: { type: "string", description: "Path to the note", }, heading: { type: "string", description: "The heading text to insert content under", }, content: { type: "string", description: "Content to insert", }, position: { type: "string", enum: ["start", "end"], description: "Insert at start or end of the heading section. Default: end", default: "end", }, }, required: ["path", "heading", "content"], }, }, { name: "update_frontmatter", description: "Update or add frontmatter (YAML) properties in a note", inputSchema: { type: "object", properties: { path: { type: "string", description: "Path to the note", }, properties: { type: "object", description: "Key-value pairs to set in frontmatter. Use null to delete a property.", }, }, required: ["path", "properties"], }, }, { name: "create_from_template", description: "Create a new note from an existing template note", inputSchema: { type: "object", properties: { templatePath: { type: "string", description: "Path to the template note", }, newPath: { type: "string", description: "Path for the new note", }, variables: { type: "object", description: "Variables to replace in template (e.g., {{title}} -> 'My Note')", }, }, required: ["templatePath", "newPath"], }, }, ]; // Helper functions function resolvePath(notePath: string): string { // Ensure .md extension const normalizedPath = notePath.endsWith(".md") ? notePath : `${notePath}.md`; return path.join(VAULT_PATH, normalizedPath); } async function ensureDir(filePath: string): Promise<void> { const dir = path.dirname(filePath); await fs.mkdir(dir, { recursive: true }); } async function fileExists(filePath: string): Promise<boolean> { try { await fs.access(filePath); return true; } catch { return false; } } async function getAllNotes(dir: string = VAULT_PATH): Promise<string[]> { const notes: string[] = []; const entries = await fs.readdir(dir, { withFileTypes: true }); for (const entry of entries) { const fullPath = path.join(dir, entry.name); if (entry.isDirectory() && !entry.name.startsWith(".")) { notes.push(...(await getAllNotes(fullPath))); } else if (entry.isFile() && entry.name.endsWith(".md")) { notes.push(path.relative(VAULT_PATH, fullPath)); } } return notes; } function parseFrontmatter(content: string): { frontmatter: Record<string, unknown> | null; body: string; raw: string; } { const match = content.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/); if (match) { try { // Simple YAML parsing for common cases const yaml: Record<string, unknown> = {}; const lines = match[1].split("\n"); for (const line of lines) { const colonIndex = line.indexOf(":"); if (colonIndex > 0) { const key = line.slice(0, colonIndex).trim(); let value: unknown = line.slice(colonIndex + 1).trim(); // Handle arrays if (value === "") { value = []; } else if ( typeof value === "string" && value.startsWith("[") && value.endsWith("]") ) { value = value .slice(1, -1) .split(",") .map((v) => v.trim()); } yaml[key] = value; } } return { frontmatter: yaml, body: match[2], raw: match[1] }; } catch { return { frontmatter: null, body: content, raw: "" }; } } return { frontmatter: null, body: content, raw: "" }; } function serializeFrontmatter(obj: Record<string, unknown>): string { const lines: string[] = []; for (const [key, value] of Object.entries(obj)) { if (value === null || value === undefined) continue; if (Array.isArray(value)) { lines.push(`${key}: [${value.join(", ")}]`); } else { lines.push(`${key}: ${value}`); } } return `---\n${lines.join("\n")}\n---\n`; } // Tool handlers async function handleCreateNote(args: { path: string; content: string; overwrite?: boolean; }): Promise<string> { const fullPath = resolvePath(args.path); if (!args.overwrite && (await fileExists(fullPath))) { throw new Error( `Note already exists at ${args.path}. Use overwrite: true to replace.` ); } await ensureDir(fullPath); await fs.writeFile(fullPath, args.content, "utf-8"); return `Successfully created note at ${args.path}`; } async function handleDeleteNote(args: { path: string }): Promise<string> { const fullPath = resolvePath(args.path); if (!(await fileExists(fullPath))) { throw new Error(`Note not found at ${args.path}`); } await fs.unlink(fullPath); return `Successfully deleted note at ${args.path}`; } async function handleUpdateNote(args: { path: string; content: string; }): Promise<string> { const fullPath = resolvePath(args.path); if (!(await fileExists(fullPath))) { throw new Error(`Note not found at ${args.path}`); } await fs.writeFile(fullPath, args.content, "utf-8"); return `Successfully updated note at ${args.path}`; } async function handleAppendToNote(args: { path: string; content: string; separator?: string; }): Promise<string> { const fullPath = resolvePath(args.path); const separator = args.separator ?? "\n\n"; if (!(await fileExists(fullPath))) { throw new Error(`Note not found at ${args.path}`); } const existingContent = await fs.readFile(fullPath, "utf-8"); const newContent = existingContent + separator + args.content; await fs.writeFile(fullPath, newContent, "utf-8"); return `Successfully appended content to ${args.path}`; } async function handlePrependToNote(args: { path: string; content: string; separator?: string; }): Promise<string> { const fullPath = resolvePath(args.path); const separator = args.separator ?? "\n\n"; if (!(await fileExists(fullPath))) { throw new Error(`Note not found at ${args.path}`); } const existingContent = await fs.readFile(fullPath, "utf-8"); const { frontmatter, body, raw } = parseFrontmatter(existingContent); let newContent: string; if (frontmatter) { newContent = `---\n${raw}\n---\n${args.content}${separator}${body}`; } else { newContent = args.content + separator + existingContent; } await fs.writeFile(fullPath, newContent, "utf-8"); return `Successfully prepended content to ${args.path}`; } async function handleRenameNote(args: { oldPath: string; newPath: string; }): Promise<string> { const oldFullPath = resolvePath(args.oldPath); const newFullPath = resolvePath(args.newPath); if (!(await fileExists(oldFullPath))) { throw new Error(`Note not found at ${args.oldPath}`); } if (await fileExists(newFullPath)) { throw new Error(`Note already exists at ${args.newPath}`); } await ensureDir(newFullPath); await fs.rename(oldFullPath, newFullPath); return `Successfully moved note from ${args.oldPath} to ${args.newPath}`; } async function handleReadNote(args: { path: string }): Promise<string> { const fullPath = resolvePath(args.path); if (!(await fileExists(fullPath))) { throw new Error(`Note not found at ${args.path}`); } return await fs.readFile(fullPath, "utf-8"); } async function handleSearchNotes(args: { query: string; limit?: number; }): Promise<string> { const limit = args.limit ?? 20; const allNotes = await getAllNotes(); let regex: RegExp; try { regex = new RegExp(args.query, "i"); } catch { regex = new RegExp(args.query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "i"); } const matches = allNotes.filter((note) => regex.test(note)).slice(0, limit); return JSON.stringify(matches, null, 2); } async function handleSearchContent(args: { query: string; caseSensitive?: boolean; limit?: number; }): Promise<string> { const limit = args.limit ?? 20; const caseSensitive = args.caseSensitive ?? false; const allNotes = await getAllNotes(); const results: { path: string; matches: string[] }[] = []; const regex = new RegExp( args.query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), caseSensitive ? "g" : "gi" ); for (const notePath of allNotes) { if (results.length >= limit) break; const fullPath = path.join(VAULT_PATH, notePath); const content = await fs.readFile(fullPath, "utf-8"); if (regex.test(content)) { // Find matching lines const lines = content.split("\n"); const matchingLines = lines .filter((line) => regex.test(line)) .slice(0, 3); results.push({ path: notePath, matches: matchingLines }); } } return JSON.stringify(results, null, 2); } async function handleListFolder(args: { path?: string; recursive?: boolean; }): Promise<string> { const folderPath = path.join(VAULT_PATH, args.path || ""); const recursive = args.recursive ?? false; if (!(await fileExists(folderPath))) { throw new Error(`Folder not found at ${args.path}`); } const entries = await fs.readdir(folderPath, { withFileTypes: true }); const result: { notes: string[]; folders: string[] } = { notes: [], folders: [], }; for (const entry of entries) { if (entry.name.startsWith(".")) continue; if (entry.isDirectory()) { result.folders.push(entry.name); if (recursive) { const subResult = await handleListFolder({ path: path.join(args.path || "", entry.name), recursive: true, }); const parsed = JSON.parse(subResult); result.notes.push( ...parsed.notes.map((n: string) => path.join(entry.name, n)) ); } } else if (entry.isFile() && entry.name.endsWith(".md")) { result.notes.push(entry.name); } } return JSON.stringify(result, null, 2); } async function handleGetTags(args: { path: string }): Promise<string> { const content = await handleReadNote(args); // Find #tags in content (excluding code blocks) const codeBlockRegex = /```[\s\S]*?```|`[^`]*`/g; const contentWithoutCode = content.replace(codeBlockRegex, ""); const tagRegex = /#[a-zA-Z][a-zA-Z0-9_/-]*/g; const tags = [...new Set(contentWithoutCode.match(tagRegex) || [])]; // Also check frontmatter tags const { frontmatter } = parseFrontmatter(content); if (frontmatter?.tags) { const fmTags = Array.isArray(frontmatter.tags) ? frontmatter.tags : [frontmatter.tags]; for (const tag of fmTags) { const formattedTag = `#${tag}`; if (!tags.includes(formattedTag)) { tags.push(formattedTag); } } } return JSON.stringify(tags, null, 2); } async function handleGetLinks(args: { path: string }): Promise<string> { const content = await handleReadNote(args); // Find wikilinks [[link]] and [[link|alias]] const wikiLinkRegex = /\[\[([^\]|]+)(?:\|[^\]]+)?\]\]/g; const wikiLinks: string[] = []; let match; while ((match = wikiLinkRegex.exec(content)) !== null) { wikiLinks.push(match[1]); } // Find markdown links [text](link.md) const mdLinkRegex = /\[([^\]]+)\]\(([^)]+\.md)\)/g; const mdLinks: string[] = []; while ((match = mdLinkRegex.exec(content)) !== null) { mdLinks.push(match[2]); } return JSON.stringify({ wikiLinks, markdownLinks: mdLinks }, null, 2); } async function handleGetBacklinks(args: { path: string }): Promise<string> { const noteName = path.basename(args.path, ".md"); const allNotes = await getAllNotes(); const backlinks: string[] = []; for (const notePath of allNotes) { if (notePath === args.path) continue; const fullPath = path.join(VAULT_PATH, notePath); const content = await fs.readFile(fullPath, "utf-8"); // Check for wikilinks to this note const wikiLinkPattern = new RegExp(`\\[\\[${noteName}(\\|[^\\]]+)?\\]\\]`); // Check for markdown links const mdLinkPattern = new RegExp(`\\]\\(${args.path}\\)`); if (wikiLinkPattern.test(content) || mdLinkPattern.test(content)) { backlinks.push(notePath); } } return JSON.stringify(backlinks, null, 2); } async function handleInsertAtHeading(args: { path: string; heading: string; content: string; position?: "start" | "end"; }): Promise<string> { const fullPath = resolvePath(args.path); const position = args.position ?? "end"; if (!(await fileExists(fullPath))) { throw new Error(`Note not found at ${args.path}`); } const fileContent = await fs.readFile(fullPath, "utf-8"); const lines = fileContent.split("\n"); // Find the heading const headingPattern = new RegExp(`^#+\\s+${args.heading}\\s*$`); let headingIndex = -1; for (let i = 0; i < lines.length; i++) { if (headingPattern.test(lines[i])) { headingIndex = i; break; } } if (headingIndex === -1) { throw new Error(`Heading "${args.heading}" not found in ${args.path}`); } // Find the end of this section (next heading of same or higher level) const headingLevel = (lines[headingIndex].match(/^#+/) || [""])[0].length; let sectionEnd = lines.length; for (let i = headingIndex + 1; i < lines.length; i++) { const lineHeadingMatch = lines[i].match(/^#+/); if (lineHeadingMatch && lineHeadingMatch[0].length <= headingLevel) { sectionEnd = i; break; } } // Insert content if (position === "start") { lines.splice(headingIndex + 1, 0, "", args.content); } else { lines.splice(sectionEnd, 0, args.content, ""); } await fs.writeFile(fullPath, lines.join("\n"), "utf-8"); return `Successfully inserted content under heading "${args.heading}" in ${args.path}`; } async function handleUpdateFrontmatter(args: { path: string; properties: Record<string, unknown>; }): Promise<string> { const fullPath = resolvePath(args.path); if (!(await fileExists(fullPath))) { throw new Error(`Note not found at ${args.path}`); } const content = await fs.readFile(fullPath, "utf-8"); const { frontmatter, body } = parseFrontmatter(content); const newFrontmatter = { ...(frontmatter || {}), ...args.properties }; // Remove null values for (const [key, value] of Object.entries(newFrontmatter)) { if (value === null) { delete newFrontmatter[key]; } } const newContent = serializeFrontmatter(newFrontmatter) + body; await fs.writeFile(fullPath, newContent, "utf-8"); return `Successfully updated frontmatter in ${args.path}`; } async function handleCreateFromTemplate(args: { templatePath: string; newPath: string; variables?: Record<string, string>; }): Promise<string> { const templateFullPath = resolvePath(args.templatePath); const newFullPath = resolvePath(args.newPath); if (!(await fileExists(templateFullPath))) { throw new Error(`Template not found at ${args.templatePath}`); } if (await fileExists(newFullPath)) { throw new Error(`Note already exists at ${args.newPath}`); } let content = await fs.readFile(templateFullPath, "utf-8"); // Replace variables if (args.variables) { for (const [key, value] of Object.entries(args.variables)) { const pattern = new RegExp(`\\{\\{${key}\\}\\}`, "g"); content = content.replace(pattern, value); } } // Replace built-in variables const now = new Date(); content = content .replace(/\{\{date\}\}/g, now.toISOString().split("T")[0]) .replace(/\{\{time\}\}/g, now.toTimeString().split(" ")[0]) .replace(/\{\{datetime\}\}/g, now.toISOString()); await ensureDir(newFullPath); await fs.writeFile(newFullPath, content, "utf-8"); return `Successfully created note at ${args.newPath} from template ${args.templatePath}`; } // Main server setup const server = new Server( { name: "obsidian-tools-mcp", version: "1.0.0", }, { capabilities: { tools: {}, }, } ); // Register tool list handler server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools, })); // Register tool call handler server.setRequestHandler(CallToolRequestSchema, async (request) => { const { name, arguments: args } = request.params; try { let result: string; switch (name) { case "create_note": result = await handleCreateNote( args as { path: string; content: string; overwrite?: boolean } ); break; case "delete_note": result = await handleDeleteNote(args as { path: string }); break; case "update_note": result = await handleUpdateNote( args as { path: string; content: string } ); break; case "append_to_note": result = await handleAppendToNote( args as { path: string; content: string; separator?: string } ); break; case "prepend_to_note": result = await handlePrependToNote( args as { path: string; content: string; separator?: string } ); break; case "rename_note": result = await handleRenameNote( args as { oldPath: string; newPath: string } ); break; case "read_note": result = await handleReadNote(args as { path: string }); break; case "search_notes": result = await handleSearchNotes( args as { query: string; limit?: number } ); break; case "search_content": result = await handleSearchContent( args as { query: string; caseSensitive?: boolean; limit?: number } ); break; case "list_folder": result = await handleListFolder( args as { path?: string; recursive?: boolean } ); break; case "get_tags": result = await handleGetTags(args as { path: string }); break; case "get_links": result = await handleGetLinks(args as { path: string }); break; case "get_backlinks": result = await handleGetBacklinks(args as { path: string }); break; case "insert_at_heading": result = await handleInsertAtHeading( args as { path: string; heading: string; content: string; position?: "start" | "end"; } ); break; case "update_frontmatter": result = await handleUpdateFrontmatter( args as { path: string; properties: Record<string, unknown> } ); break; case "create_from_template": result = await handleCreateFromTemplate( args as { templatePath: string; newPath: string; variables?: Record<string, string>; } ); break; default: throw new Error(`Unknown tool: ${name}`); } return { content: [{ type: "text", text: result }], }; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); return { content: [{ type: "text", text: `Error: ${errorMessage}` }], isError: true, }; } }); // Start server async function main() { const transport = new StdioServerTransport(); await server.connect(transport); console.error(`Obsidian Tools MCP Server running with vault: ${VAULT_PATH}`); } main().catch(console.error);

Implementation Reference

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/sureshsankaran/obsidian-tools-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server