Files
mcp_arch/obsidian-mcp-server/dist/index.js
2025-12-19 23:59:54 +01:00

394 lines
15 KiB
JavaScript

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