Release v1.0.0 - Initial Release

🎉 Initial release of Obsidian MCP Server plugin

Core Features:
- MCP server implementation with HTTP transport
- JSON-RPC 2.0 message handling
- Protocol version 2024-11-05 support

MCP Tools:
- read_note, create_note, update_note, delete_note
- search_notes, list_notes, get_vault_info

Server Features:
- Configurable HTTP server (default port: 3000)
- Health check and MCP endpoints
- Auto-start option

Security:
- Origin header validation (DNS rebinding protection)
- Optional Bearer token authentication
- CORS configuration

UI:
- Settings panel with full configuration
- Status bar indicator and ribbon icon
- Start/Stop/Restart commands

Documentation:
- Comprehensive README with examples
- Quick Start Guide and Implementation Summary
- Test client script
This commit is contained in:
2025-10-16 20:52:52 -04:00
commit 08cc6e9ea6
47 changed files with 8399 additions and 0 deletions

123
src/main.ts Normal file
View File

@@ -0,0 +1,123 @@
import { Notice, Plugin } from 'obsidian';
import { MCPServer } from './server/mcp-server';
import { MCPPluginSettings, DEFAULT_SETTINGS } from './types/settings-types';
import { MCPServerSettingTab } from './settings';
export default class MCPServerPlugin extends Plugin {
settings!: MCPPluginSettings;
mcpServer: MCPServer | null = null;
statusBarItem: HTMLElement | null = null;
async onload() {
await this.loadSettings();
// Add status bar item
this.statusBarItem = this.addStatusBarItem();
this.updateStatusBar();
// Add ribbon icon to toggle server
this.addRibbonIcon('server', 'Toggle MCP Server', async () => {
if (this.mcpServer?.isRunning()) {
await this.stopServer();
} else {
await this.startServer();
}
});
// Register commands
this.addCommand({
id: 'start-mcp-server',
name: 'Start MCP Server',
callback: async () => {
await this.startServer();
}
});
this.addCommand({
id: 'stop-mcp-server',
name: 'Stop MCP Server',
callback: async () => {
await this.stopServer();
}
});
this.addCommand({
id: 'restart-mcp-server',
name: 'Restart MCP Server',
callback: async () => {
await this.stopServer();
await this.startServer();
}
});
// Add settings tab
this.addSettingTab(new MCPServerSettingTab(this.app, this));
// Auto-start if enabled
if (this.settings.autoStart) {
await this.startServer();
}
}
async onunload() {
await this.stopServer();
}
async startServer() {
if (this.mcpServer?.isRunning()) {
new Notice('MCP Server is already running');
return;
}
try {
this.mcpServer = new MCPServer(this.app, this.settings);
await this.mcpServer.start();
new Notice(`MCP Server started on port ${this.settings.port}`);
this.updateStatusBar();
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
new Notice(`Failed to start MCP Server: ${message}`);
console.error('MCP Server start error:', error);
}
}
async stopServer() {
if (!this.mcpServer?.isRunning()) {
new Notice('MCP Server is not running');
return;
}
try {
await this.mcpServer.stop();
new Notice('MCP Server stopped');
this.updateStatusBar();
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
new Notice(`Failed to stop MCP Server: ${message}`);
console.error('MCP Server stop error:', error);
}
}
updateStatusBar() {
if (this.statusBarItem) {
const isRunning = this.mcpServer?.isRunning() ?? false;
this.statusBarItem.setText(
isRunning
? `MCP: Running (${this.settings.port})`
: 'MCP: Stopped'
);
this.statusBarItem.addClass('mcp-status-bar');
}
}
async loadSettings() {
this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData());
}
async saveSettings() {
await this.saveData(this.settings);
if (this.mcpServer) {
this.mcpServer.updateSettings(this.settings);
}
}
}

144
src/server/mcp-server.ts Normal file
View File

@@ -0,0 +1,144 @@
import { App } from 'obsidian';
import express, { Express } from 'express';
import { Server } from 'http';
import {
JSONRPCRequest,
JSONRPCResponse,
ErrorCodes,
InitializeResult,
ListToolsResult,
CallToolResult
} from '../types/mcp-types';
import { MCPServerSettings } from '../types/settings-types';
import { ToolRegistry } from '../tools';
import { setupMiddleware } from './middleware';
import { setupRoutes } from './routes';
export class MCPServer {
private app: Express;
private server: Server | null = null;
private obsidianApp: App;
private settings: MCPServerSettings;
private toolRegistry: ToolRegistry;
constructor(obsidianApp: App, settings: MCPServerSettings) {
this.obsidianApp = obsidianApp;
this.settings = settings;
this.app = express();
this.toolRegistry = new ToolRegistry(obsidianApp);
setupMiddleware(this.app, this.settings, this.createErrorResponse.bind(this));
setupRoutes(this.app, this.handleRequest.bind(this), this.createErrorResponse.bind(this));
}
private async handleRequest(request: JSONRPCRequest): Promise<JSONRPCResponse> {
try {
switch (request.method) {
case 'initialize':
return this.createSuccessResponse(request.id, await this.handleInitialize(request.params));
case 'tools/list':
return this.createSuccessResponse(request.id, await this.handleListTools());
case 'tools/call':
return this.createSuccessResponse(request.id, await this.handleCallTool(request.params));
case 'ping':
return this.createSuccessResponse(request.id, {});
default:
return this.createErrorResponse(request.id, ErrorCodes.MethodNotFound, `Method not found: ${request.method}`);
}
} catch (error) {
console.error('Error handling request:', error);
return this.createErrorResponse(request.id, ErrorCodes.InternalError, (error as Error).message);
}
}
private async handleInitialize(_params: any): Promise<InitializeResult> {
return {
protocolVersion: "2024-11-05",
capabilities: {
tools: {}
},
serverInfo: {
name: "obsidian-mcp-server",
version: "1.0.0"
}
};
}
private async handleListTools(): Promise<ListToolsResult> {
return {
tools: this.toolRegistry.getToolDefinitions()
};
}
private async handleCallTool(params: any): Promise<CallToolResult> {
const { name, arguments: args } = params;
return await this.toolRegistry.callTool(name, args);
}
private createSuccessResponse(id: string | number | undefined, result: any): JSONRPCResponse {
return {
jsonrpc: "2.0",
id: id ?? null,
result
};
}
private createErrorResponse(id: string | number | undefined | null, code: number, message: string, data?: any): JSONRPCResponse {
return {
jsonrpc: "2.0",
id: id ?? null,
error: {
code,
message,
data
}
};
}
public async start(): Promise<void> {
return new Promise((resolve, reject) => {
try {
this.server = this.app.listen(this.settings.port, '127.0.0.1', () => {
console.log(`MCP Server listening on http://127.0.0.1:${this.settings.port}/mcp`);
resolve();
});
this.server.on('error', (error: any) => {
if (error.code === 'EADDRINUSE') {
reject(new Error(`Port ${this.settings.port} is already in use`));
} else {
reject(error);
}
});
} catch (error) {
reject(error);
}
});
}
public async stop(): Promise<void> {
return new Promise((resolve, reject) => {
if (this.server) {
this.server.close((err?: Error) => {
if (err) {
reject(err);
} else {
console.log('MCP Server stopped');
this.server = null;
resolve();
}
});
} else {
resolve();
}
});
}
public isRunning(): boolean {
return this.server !== null;
}
public updateSettings(settings: MCPServerSettings): void {
this.settings = settings;
}
}

54
src/server/middleware.ts Normal file
View File

@@ -0,0 +1,54 @@
import { Express, Request, Response } from 'express';
import express from 'express';
import cors from 'cors';
import { MCPServerSettings } from '../types/settings-types';
import { ErrorCodes } from '../types/mcp-types';
export function setupMiddleware(app: Express, settings: MCPServerSettings, createErrorResponse: (id: any, code: number, message: string) => any): void {
// Parse JSON bodies
app.use(express.json());
// CORS configuration
if (settings.enableCORS) {
const corsOptions = {
origin: (origin: string | undefined, callback: (err: Error | null, allow?: boolean) => void) => {
// Allow requests with no origin (like mobile apps or curl requests)
if (!origin) return callback(null, true);
if (settings.allowedOrigins.includes('*') ||
settings.allowedOrigins.includes(origin)) {
callback(null, true);
} else {
callback(new Error('Not allowed by CORS'));
}
},
credentials: true
};
app.use(cors(corsOptions));
}
// Authentication middleware
if (settings.enableAuth && settings.apiKey) {
app.use((req: Request, res: Response, next: any) => {
const authHeader = req.headers.authorization;
const apiKey = authHeader?.replace('Bearer ', '');
if (apiKey !== settings.apiKey) {
return res.status(401).json(createErrorResponse(null, ErrorCodes.InvalidRequest, 'Unauthorized'));
}
next();
});
}
// Origin validation for security (DNS rebinding protection)
app.use((req: Request, res: Response, next: any) => {
const host = req.headers.host;
// Only allow localhost connections
if (host && !host.startsWith('localhost') && !host.startsWith('127.0.0.1')) {
return res.status(403).json(createErrorResponse(null, ErrorCodes.InvalidRequest, 'Only localhost connections allowed'));
}
next();
});
}

25
src/server/routes.ts Normal file
View File

@@ -0,0 +1,25 @@
import { Express, Request, Response } from 'express';
import { JSONRPCRequest, JSONRPCResponse, ErrorCodes } from '../types/mcp-types';
export function setupRoutes(
app: Express,
handleRequest: (request: JSONRPCRequest) => Promise<JSONRPCResponse>,
createErrorResponse: (id: any, code: number, message: string) => JSONRPCResponse
): void {
// Main MCP endpoint
app.post('/mcp', async (req: Request, res: Response) => {
try {
const request = req.body as JSONRPCRequest;
const response = await handleRequest(request);
res.json(response);
} catch (error) {
console.error('MCP request error:', error);
res.status(500).json(createErrorResponse(null, ErrorCodes.InternalError, 'Internal server error'));
}
});
// Health check endpoint
app.get('/health', (_req: Request, res: Response) => {
res.json({ status: 'ok', timestamp: Date.now() });
});
}

153
src/settings.ts Normal file
View File

@@ -0,0 +1,153 @@
import { App, PluginSettingTab, Setting } from 'obsidian';
import { MCPPluginSettings } from './types/settings-types';
import MCPServerPlugin from './main';
export class MCPServerSettingTab extends PluginSettingTab {
plugin: MCPServerPlugin;
constructor(app: App, plugin: MCPServerPlugin) {
super(app, plugin);
this.plugin = plugin;
}
display(): void {
const {containerEl} = this;
containerEl.empty();
containerEl.createEl('h2', {text: 'MCP Server Settings'});
// Network disclosure
const disclosureEl = containerEl.createEl('div', {cls: 'mcp-disclosure'});
disclosureEl.createEl('p', {
text: '⚠️ This plugin runs a local HTTP server to expose vault operations via the Model Context Protocol (MCP). The server only accepts connections from localhost (127.0.0.1) for security.'
});
disclosureEl.style.backgroundColor = 'var(--background-secondary)';
disclosureEl.style.padding = '12px';
disclosureEl.style.marginBottom = '16px';
disclosureEl.style.borderRadius = '4px';
// Auto-start setting
new Setting(containerEl)
.setName('Auto-start server')
.setDesc('Automatically start the MCP server when Obsidian launches')
.addToggle(toggle => toggle
.setValue(this.plugin.settings.autoStart)
.onChange(async (value) => {
this.plugin.settings.autoStart = value;
await this.plugin.saveSettings();
}));
// Port setting
new Setting(containerEl)
.setName('Port')
.setDesc('Port number for the HTTP server (requires restart)')
.addText(text => text
.setPlaceholder('3000')
.setValue(String(this.plugin.settings.port))
.onChange(async (value) => {
const port = parseInt(value);
if (!isNaN(port) && port > 0 && port < 65536) {
this.plugin.settings.port = port;
await this.plugin.saveSettings();
}
}));
// CORS setting
new Setting(containerEl)
.setName('Enable CORS')
.setDesc('Enable Cross-Origin Resource Sharing')
.addToggle(toggle => toggle
.setValue(this.plugin.settings.enableCORS)
.onChange(async (value) => {
this.plugin.settings.enableCORS = value;
await this.plugin.saveSettings();
}));
// Allowed origins
new Setting(containerEl)
.setName('Allowed origins')
.setDesc('Comma-separated list of allowed origins (* for all)')
.addText(text => text
.setPlaceholder('*')
.setValue(this.plugin.settings.allowedOrigins.join(', '))
.onChange(async (value) => {
this.plugin.settings.allowedOrigins = value
.split(',')
.map(s => s.trim())
.filter(s => s.length > 0);
await this.plugin.saveSettings();
}));
// Authentication
new Setting(containerEl)
.setName('Enable authentication')
.setDesc('Require API key for requests')
.addToggle(toggle => toggle
.setValue(this.plugin.settings.enableAuth)
.onChange(async (value) => {
this.plugin.settings.enableAuth = value;
await this.plugin.saveSettings();
}));
// API Key
new Setting(containerEl)
.setName('API Key')
.setDesc('API key for authentication (Bearer token)')
.addText(text => text
.setPlaceholder('Enter API key')
.setValue(this.plugin.settings.apiKey || '')
.onChange(async (value) => {
this.plugin.settings.apiKey = value;
await this.plugin.saveSettings();
}));
// Server status
containerEl.createEl('h3', {text: 'Server Status'});
const statusEl = containerEl.createEl('div', {cls: 'mcp-server-status'});
const isRunning = this.plugin.mcpServer?.isRunning() ?? false;
statusEl.createEl('p', {
text: isRunning
? `✅ Server is running on http://127.0.0.1:${this.plugin.settings.port}/mcp`
: '⭕ Server is stopped'
});
// Control buttons
const buttonContainer = containerEl.createEl('div', {cls: 'mcp-button-container'});
if (isRunning) {
buttonContainer.createEl('button', {text: 'Stop Server'})
.addEventListener('click', async () => {
await this.plugin.stopServer();
this.display();
});
buttonContainer.createEl('button', {text: 'Restart Server'})
.addEventListener('click', async () => {
await this.plugin.stopServer();
await this.plugin.startServer();
this.display();
});
} else {
buttonContainer.createEl('button', {text: 'Start Server'})
.addEventListener('click', async () => {
await this.plugin.startServer();
this.display();
});
}
// Connection info
if (isRunning) {
containerEl.createEl('h3', {text: 'Connection Information'});
const infoEl = containerEl.createEl('div', {cls: 'mcp-connection-info'});
infoEl.createEl('p', {text: 'MCP Endpoint:'});
infoEl.createEl('code', {text: `http://127.0.0.1:${this.plugin.settings.port}/mcp`});
infoEl.createEl('p', {text: 'Health Check:'});
infoEl.createEl('code', {text: `http://127.0.0.1:${this.plugin.settings.port}/health`});
}
}
}

149
src/tools/index.ts Normal file
View File

@@ -0,0 +1,149 @@
import { App } from 'obsidian';
import { Tool, CallToolResult } from '../types/mcp-types';
import { NoteTools } from './note-tools';
import { VaultTools } from './vault-tools';
export class ToolRegistry {
private noteTools: NoteTools;
private vaultTools: VaultTools;
constructor(app: App) {
this.noteTools = new NoteTools(app);
this.vaultTools = new VaultTools(app);
}
getToolDefinitions(): Tool[] {
return [
{
name: "read_note",
description: "Read the content of a note from the Obsidian vault",
inputSchema: {
type: "object",
properties: {
path: {
type: "string",
description: "Path to the note within the vault (e.g., 'folder/note.md')"
}
},
required: ["path"]
}
},
{
name: "create_note",
description: "Create a new note in the Obsidian vault",
inputSchema: {
type: "object",
properties: {
path: {
type: "string",
description: "Path for the new note (e.g., 'folder/note.md')"
},
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 to update"
},
content: {
type: "string",
description: "New content for the note"
}
},
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"
}
},
required: ["path"]
}
},
{
name: "search_notes",
description: "Search for notes in the Obsidian vault",
inputSchema: {
type: "object",
properties: {
query: {
type: "string",
description: "Search query string"
}
},
required: ["query"]
}
},
{
name: "get_vault_info",
description: "Get information about the Obsidian vault",
inputSchema: {
type: "object",
properties: {}
}
},
{
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"
}
}
}
}
];
}
async callTool(name: string, args: any): Promise<CallToolResult> {
try {
switch (name) {
case "read_note":
return await this.noteTools.readNote(args.path);
case "create_note":
return await this.noteTools.createNote(args.path, args.content);
case "update_note":
return await this.noteTools.updateNote(args.path, args.content);
case "delete_note":
return await this.noteTools.deleteNote(args.path);
case "search_notes":
return await this.vaultTools.searchNotes(args.query);
case "get_vault_info":
return await this.vaultTools.getVaultInfo();
case "list_notes":
return await this.vaultTools.listNotes(args.folder);
default:
return {
content: [{ type: "text", text: `Unknown tool: ${name}` }],
isError: true
};
}
} catch (error) {
return {
content: [{ type: "text", text: `Error: ${(error as Error).message}` }],
isError: true
};
}
}
}

68
src/tools/note-tools.ts Normal file
View File

@@ -0,0 +1,68 @@
import { App, TFile } from 'obsidian';
import { CallToolResult } from '../types/mcp-types';
export class NoteTools {
constructor(private app: App) {}
async readNote(path: string): Promise<CallToolResult> {
const file = this.app.vault.getAbstractFileByPath(path);
if (!file || !(file instanceof TFile)) {
return {
content: [{ type: "text", text: `Note not found: ${path}` }],
isError: true
};
}
const content = await this.app.vault.read(file);
return {
content: [{ type: "text", text: content }]
};
}
async createNote(path: string, content: string): Promise<CallToolResult> {
try {
const file = await this.app.vault.create(path, content);
return {
content: [{ type: "text", text: `Note created successfully: ${file.path}` }]
};
} catch (error) {
return {
content: [{ type: "text", text: `Failed to create note: ${(error as Error).message}` }],
isError: true
};
}
}
async updateNote(path: string, content: string): Promise<CallToolResult> {
const file = this.app.vault.getAbstractFileByPath(path);
if (!file || !(file instanceof TFile)) {
return {
content: [{ type: "text", text: `Note not found: ${path}` }],
isError: true
};
}
await this.app.vault.modify(file, content);
return {
content: [{ type: "text", text: `Note updated successfully: ${path}` }]
};
}
async deleteNote(path: string): Promise<CallToolResult> {
const file = this.app.vault.getAbstractFileByPath(path);
if (!file || !(file instanceof TFile)) {
return {
content: [{ type: "text", text: `Note not found: ${path}` }],
isError: true
};
}
await this.app.vault.delete(file);
return {
content: [{ type: "text", text: `Note deleted successfully: ${path}` }]
};
}
}

77
src/tools/vault-tools.ts Normal file
View File

@@ -0,0 +1,77 @@
import { App, TFile, TFolder } from 'obsidian';
import { CallToolResult } from '../types/mcp-types';
export class VaultTools {
constructor(private app: App) {}
async searchNotes(query: string): Promise<CallToolResult> {
const files = this.app.vault.getMarkdownFiles();
const results: string[] = [];
for (const file of files) {
const content = await this.app.vault.read(file);
if (content.toLowerCase().includes(query.toLowerCase()) ||
file.basename.toLowerCase().includes(query.toLowerCase())) {
results.push(file.path);
}
}
return {
content: [{
type: "text",
text: results.length > 0
? `Found ${results.length} notes:\n${results.join('\n')}`
: 'No notes found matching the query'
}]
};
}
async getVaultInfo(): Promise<CallToolResult> {
const files = this.app.vault.getFiles();
const markdownFiles = this.app.vault.getMarkdownFiles();
const info = {
name: this.app.vault.getName(),
totalFiles: files.length,
markdownFiles: markdownFiles.length,
rootPath: (this.app.vault.adapter as any).basePath || 'Unknown'
};
return {
content: [{
type: "text",
text: JSON.stringify(info, null, 2)
}]
};
}
async listNotes(folder?: string): Promise<CallToolResult> {
let files: TFile[];
if (folder) {
const folderObj = this.app.vault.getAbstractFileByPath(folder);
if (!folderObj || !(folderObj instanceof TFolder)) {
return {
content: [{ type: "text", text: `Folder not found: ${folder}` }],
isError: true
};
}
files = [];
this.app.vault.getMarkdownFiles().forEach((file: TFile) => {
if (file.path.startsWith(folder + '/')) {
files.push(file);
}
});
} else {
files = this.app.vault.getMarkdownFiles();
}
const noteList = files.map(f => f.path).join('\n');
return {
content: [{
type: "text",
text: `Found ${files.length} notes:\n${noteList}`
}]
};
}
}

63
src/types/mcp-types.ts Normal file
View File

@@ -0,0 +1,63 @@
// MCP Protocol Types
export interface JSONRPCRequest {
jsonrpc: "2.0";
id?: string | number;
method: string;
params?: any;
}
export interface JSONRPCResponse {
jsonrpc: "2.0";
id: string | number | null;
result?: any;
error?: JSONRPCError;
}
export interface JSONRPCError {
code: number;
message: string;
data?: any;
}
export enum ErrorCodes {
ParseError = -32700,
InvalidRequest = -32600,
MethodNotFound = -32601,
InvalidParams = -32602,
InternalError = -32603
}
export interface InitializeResult {
protocolVersion: string;
capabilities: {
tools?: {};
};
serverInfo: {
name: string;
version: string;
};
}
export interface Tool {
name: string;
description: string;
inputSchema: {
type: string;
properties: Record<string, any>;
required?: string[];
};
}
export interface ListToolsResult {
tools: Tool[];
}
export interface ContentBlock {
type: "text";
text: string;
}
export interface CallToolResult {
content: ContentBlock[];
isError?: boolean;
}

View File

@@ -0,0 +1,21 @@
// Settings Types
export interface MCPServerSettings {
port: number;
enableCORS: boolean;
allowedOrigins: string[];
apiKey?: string;
enableAuth: boolean;
}
export interface MCPPluginSettings extends MCPServerSettings {
autoStart: boolean;
}
export const DEFAULT_SETTINGS: MCPPluginSettings = {
port: 3000,
enableCORS: true,
allowedOrigins: ['*'],
apiKey: '',
enableAuth: false,
autoStart: false
};