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