#!/usr/bin/env node
/**
* Obsidian MCP Server
*
* Provides tools for interacting with Obsidian vaults:
* - Note CRUD operations
* - Search functionality
* - Link/backlink navigation
* - Graph visualization data
*/
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
import {
CallToolRequestSchema,
ListToolsRequestSchema,
ListResourcesRequestSchema,
ReadResourceRequestSchema,
} from '@modelcontextprotocol/sdk/types.js';
import { z } from 'zod';
import { createServer } from 'node:http';
import { VaultService } from './vault.js';
// 환경 변수에서 vault 경로 가져오기 (필수)
const VAULT_PATH = process.env.OBSIDIAN_VAULT_PATH;
if (!VAULT_PATH) {
console.error('Error: OBSIDIAN_VAULT_PATH environment variable is required');
process.exit(1);
}
const PORT = parseInt(process.env.PORT ?? '3847', 10);
const MODE = process.env.MODE ?? 'stdio'; // 'stdio' or 'sse'
const vault = new VaultService(VAULT_PATH);
function createMcpServer() {
const server = new Server(
{
name: 'obsidian-mcp',
version: '1.0.0',
},
{
capabilities: {
tools: {},
resources: {},
},
}
);
// Tool schemas
const ListNotesSchema = z.object({
folder: z.string().optional().describe('특정 폴더 내 노트만 조회 (선택)'),
});
const ReadNoteSchema = z.object({
path: z.string().describe('노트 경로 (예: "folder/note" 또는 "note.md")'),
});
const CreateNoteSchema = z.object({
path: z.string().describe('생성할 노트 경로'),
content: z.string().describe('노트 내용'),
frontmatter: z.record(z.unknown()).optional().describe('YAML frontmatter (선택)'),
});
const UpdateNoteSchema = z.object({
path: z.string().describe('수정할 노트 경로'),
content: z.string().describe('새로운 노트 내용'),
frontmatter: z.record(z.unknown()).optional().describe('YAML frontmatter (선택)'),
});
const DeleteNoteSchema = z.object({
path: z.string().describe('삭제할 노트 경로'),
});
const AppendToNoteSchema = z.object({
path: z.string().describe('노트 경로'),
content: z.string().describe('추가할 내용'),
});
const SearchNotesSchema = z.object({
query: z.string().describe('검색어'),
tags: z.array(z.string()).optional().describe('태그 필터 (선택)'),
folder: z.string().optional().describe('폴더 필터 (선택)'),
});
const GetBacklinksSchema = z.object({
path: z.string().describe('노트 경로'),
});
const GetGraphSchema = z.object({
center: z.string().optional().describe('중심 노트 이름 (선택)'),
depth: z.number().optional().describe('탐색 깊이 (기본값: 1)'),
});
const GetDailyNoteSchema = z.object({
date: z.string().optional().describe('날짜 (YYYY-MM-DD, 기본값: 오늘)'),
});
const CreateDailyNoteSchema = z.object({
date: z.string().optional().describe('날짜 (YYYY-MM-DD, 기본값: 오늘)'),
template: z.string().optional().describe('템플릿 내용 (선택)'),
});
// Tool definitions
const TOOLS = [
{
name: 'list_notes',
description: 'Vault 내 모든 노트 목록 조회. 각 노트의 메타데이터(경로, 이름, 태그, 링크, 백링크) 반환',
inputSchema: {
type: 'object' as const,
properties: {
folder: { type: 'string', description: '특정 폴더 내 노트만 조회 (선택)' },
},
},
},
{
name: 'read_note',
description: '특정 노트의 전체 내용과 메타데이터 조회',
inputSchema: {
type: 'object' as const,
properties: {
path: { type: 'string', description: '노트 경로 (예: "folder/note" 또는 "note.md")' },
},
required: ['path'],
},
},
{
name: 'create_note',
description: '새 노트 생성. frontmatter 포함 가능',
inputSchema: {
type: 'object' as const,
properties: {
path: { type: 'string', description: '생성할 노트 경로' },
content: { type: 'string', description: '노트 내용' },
frontmatter: { type: 'object', description: 'YAML frontmatter (선택)' },
},
required: ['path', 'content'],
},
},
{
name: 'update_note',
description: '기존 노트 내용 수정',
inputSchema: {
type: 'object' as const,
properties: {
path: { type: 'string', description: '수정할 노트 경로' },
content: { type: 'string', description: '새로운 노트 내용' },
frontmatter: { type: 'object', description: 'YAML frontmatter (선택)' },
},
required: ['path', 'content'],
},
},
{
name: 'delete_note',
description: '노트 삭제',
inputSchema: {
type: 'object' as const,
properties: {
path: { type: 'string', description: '삭제할 노트 경로' },
},
required: ['path'],
},
},
{
name: 'append_to_note',
description: '기존 노트 끝에 내용 추가',
inputSchema: {
type: 'object' as const,
properties: {
path: { type: 'string', description: '노트 경로' },
content: { type: 'string', description: '추가할 내용' },
},
required: ['path', 'content'],
},
},
{
name: 'search_notes',
description: '노트 내용/제목 검색. 태그 및 폴더 필터 지원',
inputSchema: {
type: 'object' as const,
properties: {
query: { type: 'string', description: '검색어' },
tags: { type: 'array', items: { type: 'string' }, description: '태그 필터 (선택)' },
folder: { type: 'string', description: '폴더 필터 (선택)' },
},
required: ['query'],
},
},
{
name: 'get_backlinks',
description: '특정 노트를 참조하는 모든 노트 조회',
inputSchema: {
type: 'object' as const,
properties: {
path: { type: 'string', description: '노트 경로' },
},
required: ['path'],
},
},
{
name: 'get_graph',
description: '노트 간 연결 그래프 데이터 조회. 링크/백링크/태그 관계 포함',
inputSchema: {
type: 'object' as const,
properties: {
center: { type: 'string', description: '중심 노트 이름 (선택)' },
depth: { type: 'number', description: '탐색 깊이 (기본값: 1)' },
},
},
},
{
name: 'get_daily_note',
description: '오늘 또는 특정 날짜의 데일리 노트 조회',
inputSchema: {
type: 'object' as const,
properties: {
date: { type: 'string', description: '날짜 (YYYY-MM-DD, 기본값: 오늘)' },
},
},
},
{
name: 'create_daily_note',
description: '오늘 또는 특정 날짜의 데일리 노트 생성',
inputSchema: {
type: 'object' as const,
properties: {
date: { type: 'string', description: '날짜 (YYYY-MM-DD, 기본값: 오늘)' },
template: { type: 'string', description: '템플릿 내용 (선택)' },
},
},
},
];
// List tools handler
server.setRequestHandler(ListToolsRequestSchema, async () => ({
tools: TOOLS,
}));
// Call tool handler
server.setRequestHandler(CallToolRequestSchema, async (request) => {
const { name, arguments: args } = request.params;
try {
switch (name) {
case 'list_notes': {
const { folder } = ListNotesSchema.parse(args);
const notes = await vault.listNotes(folder);
return {
content: [{ type: 'text', text: JSON.stringify(notes, null, 2) }],
};
}
case 'read_note': {
const { path } = ReadNoteSchema.parse(args);
const note = await vault.readNote(path);
return {
content: [{ type: 'text', text: JSON.stringify(note, null, 2) }],
};
}
case 'create_note': {
const { path, content, frontmatter } = CreateNoteSchema.parse(args);
const note = await vault.createNote(path, content, frontmatter);
return {
content: [{ type: 'text', text: JSON.stringify(note, null, 2) }],
};
}
case 'update_note': {
const { path, content, frontmatter } = UpdateNoteSchema.parse(args);
const note = await vault.updateNote(path, content, frontmatter);
return {
content: [{ type: 'text', text: JSON.stringify(note, null, 2) }],
};
}
case 'delete_note': {
const { path } = DeleteNoteSchema.parse(args);
await vault.deleteNote(path);
return {
content: [{ type: 'text', text: `노트 삭제 완료: ${path}` }],
};
}
case 'append_to_note': {
const { path, content } = AppendToNoteSchema.parse(args);
const note = await vault.appendToNote(path, content);
return {
content: [{ type: 'text', text: JSON.stringify(note, null, 2) }],
};
}
case 'search_notes': {
const { query, tags, folder } = SearchNotesSchema.parse(args);
const results = await vault.searchNotes(query, { tags, folder });
return {
content: [{ type: 'text', text: JSON.stringify(results, null, 2) }],
};
}
case 'get_backlinks': {
const { path } = GetBacklinksSchema.parse(args);
const backlinks = await vault.getBacklinks(path);
return {
content: [{ type: 'text', text: JSON.stringify(backlinks, null, 2) }],
};
}
case 'get_graph': {
const { center, depth } = GetGraphSchema.parse(args);
const graph = await vault.getGraph({ center, depth });
return {
content: [{ type: 'text', text: JSON.stringify(graph, null, 2) }],
};
}
case 'get_daily_note': {
const { date } = GetDailyNoteSchema.parse(args);
const note = await vault.getDailyNote(date);
if (!note) {
return {
content: [{ type: 'text', text: '데일리 노트를 찾을 수 없습니다.' }],
};
}
return {
content: [{ type: 'text', text: JSON.stringify(note, null, 2) }],
};
}
case 'create_daily_note': {
const { date, template } = CreateDailyNoteSchema.parse(args);
const note = await vault.createDailyNote(date, template);
return {
content: [{ type: 'text', text: JSON.stringify(note, null, 2) }],
};
}
default:
throw new Error(`Unknown tool: ${name}`);
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return {
content: [{ type: 'text', text: `Error: ${message}` }],
isError: true,
};
}
});
// Resources: expose vault notes as resources
server.setRequestHandler(ListResourcesRequestSchema, async () => {
const notes = await vault.listNotes();
return {
resources: notes.map((note) => ({
uri: `obsidian://${note.path}`,
name: note.name,
mimeType: 'text/markdown',
description: `Tags: ${note.tags.join(', ') || 'none'}`,
})),
};
});
server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
const uri = request.params.uri;
const path = uri.replace('obsidian://', '');
const note = await vault.readNote(path);
return {
contents: [
{
uri,
mimeType: 'text/markdown',
text: note.content,
},
],
};
});
return server;
}
// Start server in stdio mode
async function startStdioServer() {
const server = createMcpServer();
const transport = new StdioServerTransport();
await server.connect(transport);
console.error('Obsidian MCP server started (stdio mode)');
console.error(`Vault path: ${VAULT_PATH}`);
}
// Start server in SSE mode
async function startSseServer() {
const transports = new Map<string, SSEServerTransport>();
const httpServer = createServer(async (req, res) => {
// CORS headers
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
if (req.method === 'OPTIONS') {
res.writeHead(200);
res.end();
return;
}
const url = new URL(req.url ?? '/', `http://localhost:${PORT}`);
// Health check
if (url.pathname === '/health') {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ status: 'ok', vault: VAULT_PATH }));
return;
}
// SSE endpoint
if (url.pathname === '/sse') {
const server = createMcpServer();
const transport = new SSEServerTransport('/messages', res);
transports.set(transport.sessionId, transport);
res.on('close', () => {
transports.delete(transport.sessionId);
});
await server.connect(transport);
return;
}
// Messages endpoint
if (url.pathname === '/messages') {
const sessionId = url.searchParams.get('sessionId');
if (!sessionId) {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Missing sessionId' }));
return;
}
const transport = transports.get(sessionId);
if (!transport) {
res.writeHead(404, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Session not found' }));
return;
}
let body = '';
req.on('data', (chunk) => {
body += chunk;
});
req.on('end', async () => {
try {
await transport.handlePostMessage(req, res, body);
} catch (error) {
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: String(error) }));
}
});
return;
}
// Default response
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
name: 'obsidian-mcp',
version: '1.0.0',
endpoints: {
sse: '/sse',
messages: '/messages',
health: '/health',
},
}));
});
httpServer.listen(PORT, () => {
console.log(`Obsidian MCP server started (SSE mode)`);
console.log(`URL: http://localhost:${PORT}/sse`);
console.log(`Vault path: ${VAULT_PATH}`);
});
}
// Main
async function main() {
if (MODE === 'sse') {
await startSseServer();
} else {
await startStdioServer();
}
}
main().catch((error) => {
console.error('Server error:', error);
process.exit(1);
});