394 lines
15 KiB
JavaScript
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);
|