Obsidian MCP

by newtype-01
Verified
  • src
#!/usr/bin/env node import { Server } from '@modelcontextprotocol/sdk/server/index.js'; import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; import { CallToolRequestSchema, ErrorCode, ListResourcesRequestSchema, ListToolsRequestSchema, McpError, ReadResourceRequestSchema, } from '@modelcontextprotocol/sdk/types.js'; import axios, { AxiosInstance } from 'axios'; import * as path from 'path'; import * as fs from 'fs'; // Obsidian API configuration const VAULT_PATH = process.env.OBSIDIAN_VAULT_PATH || './vault'; const API_TOKEN = process.env.OBSIDIAN_API_TOKEN || ''; const API_PORT = process.env.OBSIDIAN_API_PORT || '27123'; const API_BASE_URL = `http://localhost:${API_PORT}`; class ObsidianMcpServer { private server: Server; private api: AxiosInstance; constructor() { // Initialize MCP server this.server = new Server( { name: 'obsidian-mcp-server', version: '0.1.0', }, { capabilities: { resources: {}, tools: {}, }, } ); // Initialize Obsidian API client this.api = axios.create({ baseURL: API_BASE_URL, headers: { 'Authorization': `Bearer ${API_TOKEN}`, 'Content-Type': 'application/json', }, }); // Set up request handlers this.setupResourceHandlers(); this.setupToolHandlers(); // Error handling this.server.onerror = (error) => console.error('[MCP Error]', error); process.on('SIGINT', async () => { await this.server.close(); process.exit(0); }); } private setupResourceHandlers() { // List available resources this.server.setRequestHandler(ListResourcesRequestSchema, async () => { try { // Get list of files in the vault const files = await this.listVaultFiles(); // Map files to resources const resources = files.map(file => ({ uri: `obsidian://${encodeURIComponent(file)}`, name: path.basename(file), mimeType: 'text/markdown', description: `Markdown note: ${file}`, })); return { resources }; } catch (error) { console.error('Error listing resources:', error); throw new McpError( ErrorCode.InternalError, `Failed to list resources: ${error instanceof Error ? error.message : String(error)}` ); } }); // Read resource content this.server.setRequestHandler(ReadResourceRequestSchema, async (request) => { try { const match = request.params.uri.match(/^obsidian:\/\/(.+)$/); if (!match) { throw new McpError( ErrorCode.InvalidRequest, `Invalid URI format: ${request.params.uri}` ); } const filePath = decodeURIComponent(match[1]); const content = await this.readNote(filePath); return { contents: [ { uri: request.params.uri, mimeType: 'text/markdown', text: content, }, ], }; } catch (error) { console.error('Error reading resource:', error); throw new McpError( ErrorCode.InternalError, `Failed to read resource: ${error instanceof Error ? error.message : String(error)}` ); } }); } private setupToolHandlers() { // List available tools this.server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: [ { name: 'list_notes', description: 'List all notes in the Obsidian vault', inputSchema: { type: 'object', properties: { folder: { type: 'string', description: 'Folder path within the vault (optional)', }, }, required: [], }, }, { name: 'delete_note', description: 'Delete a note from the Obsidian vault', inputSchema: { type: 'object', properties: { path: { type: 'string', description: 'Path to the note within the vault', }, }, required: ['path'], }, }, { name: 'read_note', description: 'Read the content of a note in the Obsidian vault', inputSchema: { type: 'object', properties: { path: { type: 'string', description: 'Path to the note within the vault', }, }, required: ['path'], }, }, { name: 'create_note', description: 'Create a new note in the Obsidian vault', inputSchema: { type: 'object', properties: { path: { type: 'string', description: 'Path where the note should be created', }, content: { type: 'string', description: 'Content of the note', }, }, required: ['path', 'content'], }, }, { name: 'update_note', description: 'Update an existing note in the Obsidian vault', inputSchema: { type: 'object', properties: { path: { type: 'string', description: 'Path to the note within the vault', }, content: { type: 'string', description: 'New content of the note', }, }, required: ['path', 'content'], }, }, { name: 'search_vault', description: 'Search for content in the Obsidian vault', inputSchema: { type: 'object', properties: { query: { type: 'string', description: 'Search query', }, }, required: ['query'], }, }, { name: 'manage_folder', description: 'Create, rename, move, or delete a folder in the Obsidian vault', inputSchema: { type: 'object', properties: { operation: { type: 'string', description: 'The operation to perform: create, rename, move, or delete', enum: ['create', 'rename', 'move', 'delete'] }, path: { type: 'string', description: 'Path to the folder within the vault' }, newPath: { type: 'string', description: 'New path for the folder (required for rename and move operations)' } }, required: ['operation', 'path'], }, }, ], })); // Handle tool calls this.server.setRequestHandler(CallToolRequestSchema, async (request) => { try { switch (request.params.name) { case 'list_notes': return await this.handleListNotes(request.params.arguments); case 'read_note': return await this.handleReadNote(request.params.arguments); case 'create_note': return await this.handleCreateNote(request.params.arguments); case 'update_note': return await this.handleUpdateNote(request.params.arguments); case 'search_vault': return await this.handleSearchVault(request.params.arguments); case 'delete_note': return await this.handleDeleteNote(request.params.arguments); case 'manage_folder': return await this.handleManageFolder(request.params.arguments); default: throw new McpError( ErrorCode.MethodNotFound, `Unknown tool: ${request.params.name}` ); } } catch (error) { console.error(`Error executing tool ${request.params.name}:`, error); return { content: [ { type: 'text', text: `Error: ${error instanceof Error ? error.message : String(error)}`, }, ], isError: true, }; } }); } // Tool handler implementations private async handleListNotes(args: any) { const folder = args?.folder || ''; const files = await this.listVaultFiles(folder); return { content: [ { type: 'text', text: JSON.stringify(files, null, 2), }, ], }; } private async handleReadNote(args: any) { if (!args?.path) { throw new Error('Path is required'); } const content = await this.readNote(args.path); return { content: [ { type: 'text', text: content, }, ], }; } private async handleCreateNote(args: any) { if (!args?.path || !args?.content) { throw new Error('Path and content are required'); } await this.createNote(args.path, args.content); return { content: [ { type: 'text', text: `Note created successfully at ${args.path}`, }, ], }; } private async handleUpdateNote(args: any) { if (!args?.path || !args?.content) { throw new Error('Path and content are required'); } await this.updateNote(args.path, args.content); return { content: [ { type: 'text', text: `Note updated successfully at ${args.path}`, }, ], }; } private async handleSearchVault(args: any) { if (!args?.query) { throw new Error('Search query is required'); } const results = await this.searchVault(args.query); return { content: [ { type: 'text', text: JSON.stringify(results, null, 2), }, ], }; } private async handleDeleteNote(args: any) { if (!args?.path) { throw new Error('Path is required'); } await this.deleteNote(args.path); return { content: [ { type: 'text', text: `Note deleted successfully: ${args.path}`, }, ], }; } // Tool handler for folder operations private async handleManageFolder(args: any) { if (!args?.operation || !args?.path) { throw new Error('Operation and path are required'); } const operation = args.operation; const folderPath = args.path; const newPath = args.newPath; switch (operation) { case 'create': await this.createFolder(folderPath); return { content: [ { type: 'text', text: `Folder created successfully at ${folderPath}`, }, ], }; case 'rename': if (!newPath) { throw new Error('New path is required for rename operation'); } await this.renameFolder(folderPath, newPath); return { content: [ { type: 'text', text: `Folder renamed from ${folderPath} to ${newPath}`, }, ], }; case 'move': if (!newPath) { throw new Error('New path is required for move operation'); } await this.moveFolder(folderPath, newPath); return { content: [ { type: 'text', text: `Folder moved from ${folderPath} to ${newPath}`, }, ], }; case 'delete': await this.deleteFolder(folderPath); return { content: [ { type: 'text', text: `Folder deleted successfully: ${folderPath}`, }, ], }; default: throw new Error(`Unknown folder operation: ${operation}`); } } // Obsidian API methods private async listVaultFiles(folder: string = ''): Promise<string[]> { try { // First try using the Obsidian API const response = await this.api.get('/vault'); return response.data.files || []; } catch (error) { console.warn('API request failed, falling back to file system:', error); // Fallback to file system if API fails const basePath = path.join(VAULT_PATH, folder); return this.listFilesRecursively(basePath); } } private listFilesRecursively(dir: string): string[] { const files: string[] = []; const items = fs.readdirSync(dir); for (const item of items) { if (item === '.obsidian' || item === '.git' || item === '.DS_Store') { continue; } const fullPath = path.join(dir, item); const stat = fs.statSync(fullPath); if (stat.isDirectory()) { files.push(...this.listFilesRecursively(fullPath)); } else if (item.endsWith('.md')) { // Get path relative to vault const relativePath = path.relative(VAULT_PATH, fullPath); files.push(relativePath); } } return files; } private async readNote(notePath: string): Promise<string> { try { // First try using the Obsidian API const response = await this.api.get(`/vault/${encodeURIComponent(notePath)}`); return response.data.content || ''; } catch (error) { console.warn('API request failed, falling back to file system:', error); // Fallback to file system if API fails const fullPath = path.join(VAULT_PATH, notePath); return fs.readFileSync(fullPath, 'utf-8'); } } private async createNote(notePath: string, content: string): Promise<void> { try { // First try using the Obsidian API await this.api.post(`/vault/${encodeURIComponent(notePath)}`, { content }); } catch (error) { console.warn('API request failed, falling back to file system:', error); // Fallback to file system if API fails const fullPath = path.join(VAULT_PATH, notePath); const dir = path.dirname(fullPath); if (!fs.existsSync(dir)) { fs.mkdirSync(dir, { recursive: true }); } fs.writeFileSync(fullPath, content, 'utf-8'); } } private async updateNote(notePath: string, content: string): Promise<void> { try { // First try using the Obsidian API await this.api.put(`/vault/${encodeURIComponent(notePath)}`, { content }); } catch (error) { console.warn('API request failed, falling back to file system:', error); // Fallback to file system if API fails const fullPath = path.join(VAULT_PATH, notePath); if (!fs.existsSync(fullPath)) { throw new Error(`Note not found: ${notePath}`); } fs.writeFileSync(fullPath, content, 'utf-8'); } } private async searchVault(query: string): Promise<any[]> { try { // First try using the Obsidian API const response = await this.api.get(`/search?query=${encodeURIComponent(query)}`); return response.data.results || []; } catch (error) { console.warn('API request failed, falling back to simple search:', error); // Fallback to simple search if API fails const files = await this.listVaultFiles(); const results = []; for (const file of files) { const content = await this.readNote(file); if (content.toLowerCase().includes(query.toLowerCase())) { results.push({ path: file, score: 1, matches: [{ line: content.split('\n').findIndex(line => line.toLowerCase().includes(query.toLowerCase())) }], }); } } return results; } } private async deleteNote(notePath: string): Promise<void> { try { // First try using the Obsidian API await this.api.delete(`/vault/${encodeURIComponent(notePath)}`); } catch (error) { console.warn('API request failed, falling back to file system:', error); // Fallback to file system if API fails const fullPath = path.join(VAULT_PATH, notePath); if (!fs.existsSync(fullPath)) { throw new Error(`Note not found: ${notePath}`); } fs.unlinkSync(fullPath); // Check if parent directory is empty and remove it if it is const dir = path.dirname(fullPath); if (dir !== VAULT_PATH) { const items = fs.readdirSync(dir); if (items.length === 0) { fs.rmdirSync(dir); } } } } // Folder operation methods private async createFolder(folderPath: string): Promise<void> { try { // First try using the Obsidian API await this.api.post(`/folders/${encodeURIComponent(folderPath)}`); } catch (error) { console.warn('API request failed, falling back to file system:', error); // Fallback to file system if API fails const fullPath = path.join(VAULT_PATH, folderPath); if (!fs.existsSync(fullPath)) { fs.mkdirSync(fullPath, { recursive: true }); } } } private async renameFolder(folderPath: string, newPath: string): Promise<void> { try { // First try using the Obsidian API await this.api.put(`/folders/${encodeURIComponent(folderPath)}`, { newPath }); } catch (error) { console.warn('API request failed, falling back to file system:', error); // Fallback to file system if API fails const fullPath = path.join(VAULT_PATH, folderPath); const newFullPath = path.join(VAULT_PATH, newPath); if (!fs.existsSync(fullPath)) { throw new Error(`Folder not found: ${folderPath}`); } if (fs.existsSync(newFullPath)) { throw new Error(`Destination folder already exists: ${newPath}`); } // Create parent directory if it doesn't exist const parentDir = path.dirname(newFullPath); if (!fs.existsSync(parentDir)) { fs.mkdirSync(parentDir, { recursive: true }); } fs.renameSync(fullPath, newFullPath); } } private async moveFolder(folderPath: string, newPath: string): Promise<void> { // Move is essentially the same as rename in this context await this.renameFolder(folderPath, newPath); } private async deleteFolder(folderPath: string): Promise<void> { try { // First try using the Obsidian API await this.api.delete(`/folders/${encodeURIComponent(folderPath)}`); } catch (error) { console.warn('API request failed, falling back to file system:', error); // Fallback to file system if API fails const fullPath = path.join(VAULT_PATH, folderPath); if (!fs.existsSync(fullPath)) { throw new Error(`Folder not found: ${folderPath}`); } // Recursively delete the folder and its contents this.deleteFolderRecursive(fullPath); } } private deleteFolderRecursive(folderPath: string): void { if (fs.existsSync(folderPath)) { fs.readdirSync(folderPath).forEach((file) => { const curPath = path.join(folderPath, file); if (fs.lstatSync(curPath).isDirectory()) { // Recursive call for directories this.deleteFolderRecursive(curPath); } else { // Delete file fs.unlinkSync(curPath); } }); // Delete empty directory fs.rmdirSync(folderPath); } } // Start the server async run() { const transport = new StdioServerTransport(); await this.server.connect(transport); console.error('Obsidian MCP server running on stdio'); } } // Create and run the server const server = new ObsidianMcpServer(); server.run().catch(console.error);