#
tokens: 5327/50000 5/5 files
lines: on (toggle) GitHub
raw markdown copy reset
# Directory Structure

```
├── .gitignore
├── index.ts
├── LICENSE
├── package.json
├── pnpm-lock.yaml
├── README.md
└── tsconfig.json
```

# Files

--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------

```
1 | dist
2 | node_modules
```

--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------

```markdown
 1 | # MCP Obsidian
 2 | 
 3 | Model Context Protocol server for Obsidian vault integration. This allows Claude Desktop (or any MCP client) to search and read your Obsidian notes.
 4 | 
 5 | ## Quick Start (For Users)
 6 | 
 7 | ### Prerequisites
 8 | - Node.js 18+ (install via `brew install node`)
 9 | - Obsidian vault
10 | - Claude Desktop (install from https://claude.ai/desktop)
11 | 
12 | ### Configuration
13 | 
14 | 1. Open your Claude Desktop configuration file at:
15 | `~/Library/Application Support/Claude/claude_desktop_config.json`
16 | 
17 | You can find this through the Claude Desktop menu:
18 | 1. Open Claude Desktop
19 | 2. Click Claude on the Mac menu bar
20 | 3. Click "Settings"
21 | 4. Click "Developer"
22 | 
23 | 2. Add the following to your configuration:
24 | 
25 | ```json
26 | {
27 |   "tools": {
28 |     "obsidian": {
29 |       "command": "npx",
30 |       "args": ["-y", "@kazuph/mcp-obsidian"],
31 |       "env": {
32 |         "OBSIDIAN_VAULT_PATH": "/path/to/your/obsidian/vault"
33 |       }
34 |     }
35 |   }
36 | }
37 | ```
38 | 
39 | Note: Replace `/path/to/your/obsidian/vault` with your actual Obsidian vault path.
40 | 
41 | ## For Developers
42 | 
43 | ### Prerequisites
44 | - Node.js 18+ (install via `brew install node`)
45 | - Obsidian vault
46 | - Claude Desktop (install from https://claude.ai/desktop)
47 | - tsx (install via `npm install -g tsx`)
48 | 
49 | ## Installation
50 | 
51 | ```bash
52 | git clone https://github.com/kazuph/mcp-obsidian.git
53 | cd mcp-obsidian
54 | npm install
55 | npm run build
56 | ```
57 | 
58 | ## Configuration
59 | 
60 | 1. Make sure Claude Desktop is installed and running.
61 | 
62 | 2. Install tsx globally if you haven't:
63 | ```bash
64 | npm install -g tsx
65 | # or
66 | pnpm add -g tsx
67 | ```
68 | 
69 | 3. Modify your Claude Desktop config located at:
70 | `~/Library/Application Support/Claude/claude_desktop_config.json`
71 | 
72 | You can easily find this through the Claude Desktop menu:
73 | 1. Open Claude Desktop
74 | 2. Click Claude on the Mac menu bar
75 | 3. Click "Settings"
76 | 4. Click "Developer"
77 | 
78 | Add the following to your MCP client's configuration:
79 | 
80 | ```json
81 | {
82 |   "tools": {
83 |     "obsidian": {
84 |       "args": ["tsx", "/path/to/mcp-obsidian/index.ts"],
85 |       "env": {
86 |         "OBSIDIAN_VAULT_PATH": "/path/to/your/obsidian/vault"
87 |       }
88 |     }
89 |   }
90 | }
91 | ```
92 | 
93 | ## Available Tools
94 | 
95 | - `obsidian_read_notes`: Read the contents of multiple notes. Each note's content is returned with its path as a reference.
96 | - `obsidian_search_notes`: Search for notes by name (case-insensitive, supports partial matches and regex).
97 | - `obsidian_read_notes_dir`: List the directory structure under a specified path.
98 | - `obsidian_write_note`: Create a new note at the specified path.
99 | 
```

--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------

```json
 1 | {
 2 | 	"compilerOptions": {
 3 | 		"target": "ES2022",
 4 | 		"strict": true,
 5 | 		"esModuleInterop": true,
 6 | 		"skipLibCheck": true,
 7 | 		"forceConsistentCasingInFileNames": true,
 8 | 		"resolveJsonModule": true,
 9 | 		"outDir": "./dist",
10 | 		"rootDir": ".",
11 | 		"moduleResolution": "NodeNext",
12 | 		"module": "NodeNext"
13 | 	},
14 | 	"exclude": ["node_modules"],
15 | 	"include": ["./**/*.ts"]
16 | }
17 | 
```

--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------

```json
 1 | {
 2 | 	"name": "@kazuph/mcp-obsidian",
 3 | 	"version": "1.0.2",
 4 | 	"description": "Model Context Protocol server for Obsidian Vaults",
 5 | 	"author": "kazuph (https://x.com/kazuph)",
 6 | 	"main": "dist/index.js",
 7 | 	"type": "module",
 8 | 	"bin": {
 9 | 		"mcp-obsidian": "dist/index.js"
10 | 	},
11 | 	"files": [
12 | 		"dist"
13 | 	],
14 | 	"scripts": {
15 | 		"build": "tsc && shx chmod +x dist/*.js",
16 | 		"prepare": "npm run build",
17 | 		"watch": "tsc --watch"
18 | 	},
19 | 	"repository": {
20 | 		"type": "git",
21 | 		"url": "git+https://github.com/kazuph/mcp-obsidian.git"
22 | 	},
23 | 	"keywords": [
24 | 		"obsidian",
25 | 		"mcp",
26 | 		"claude"
27 | 	],
28 | 	"license": "MIT",
29 | 	"publishConfig": {
30 | 		"access": "public"
31 | 	},
32 | 	"dependencies": {
33 | 		"@modelcontextprotocol/sdk": "0.5.0",
34 | 		"glob": "^10.3.10",
35 | 		"zod": "^3.23.8",
36 | 		"zod-to-json-schema": "^3.23.5"
37 | 	},
38 | 	"devDependencies": {
39 | 		"@types/node": "^20.11.0",
40 | 		"shx": "^0.3.4",
41 | 		"typescript": "^5.3.3"
42 | 	}
43 | }
44 | 
```

--------------------------------------------------------------------------------
/index.ts:
--------------------------------------------------------------------------------

```typescript
  1 | #!/usr/bin/env node
  2 | 
  3 | import { Server } from "@modelcontextprotocol/sdk/server/index.js";
  4 | import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
  5 | import {
  6 | 	CallToolRequestSchema,
  7 | 	ListToolsRequestSchema,
  8 | 	ToolSchema,
  9 | } from "@modelcontextprotocol/sdk/types.js";
 10 | import fs from "node:fs/promises";
 11 | import path from "node:path";
 12 | import os from "node:os";
 13 | import { z } from "zod";
 14 | import { zodToJsonSchema } from "zod-to-json-schema";
 15 | 
 16 | // Maximum number of search results to return
 17 | const SEARCH_LIMIT = 200;
 18 | 
 19 | interface Config {
 20 | 	obsidianVaultPath: string;
 21 | }
 22 | 
 23 | // Configuration from environment variables
 24 | const config: Config = {
 25 | 	obsidianVaultPath: process.env.OBSIDIAN_VAULT_PATH || "",
 26 | };
 27 | 
 28 | if (!config.obsidianVaultPath) {
 29 | 	console.error("Error: OBSIDIAN_VAULT_PATH environment variable is required");
 30 | 	process.exit(1);
 31 | }
 32 | 
 33 | // Store allowed directories in normalized form
 34 | const vaultDirectories = [
 35 | 	normalizePath(path.resolve(expandHome(config.obsidianVaultPath))),
 36 | ];
 37 | 
 38 | // Normalize all paths consistently
 39 | function normalizePath(p: string): string {
 40 | 	return path.normalize(p).toLowerCase();
 41 | }
 42 | 
 43 | function expandHome(filepath: string): string {
 44 | 	if (filepath.startsWith("~/") || filepath === "~") {
 45 | 		return path.join(os.homedir(), filepath.slice(1));
 46 | 	}
 47 | 	return filepath;
 48 | }
 49 | 
 50 | // Validate that all directories exist and are accessible
 51 | await Promise.all(
 52 | 	vaultDirectories.map(async (dir) => {
 53 | 		try {
 54 | 			const stats = await fs.stat(dir);
 55 | 			if (!stats.isDirectory()) {
 56 | 				console.error(`Error: ${dir} is not a directory`);
 57 | 				process.exit(1);
 58 | 			}
 59 | 		} catch (error) {
 60 | 			console.error(`Error accessing directory ${dir}:`, error);
 61 | 			process.exit(1);
 62 | 		}
 63 | 	}),
 64 | );
 65 | 
 66 | // Security utilities
 67 | async function validatePath(requestedPath: string): Promise<string> {
 68 | 	// Ignore hidden files/directories starting with "."
 69 | 	const pathParts = requestedPath.split(path.sep);
 70 | 	if (pathParts.some((part) => part.startsWith("."))) {
 71 | 		throw new Error("Access denied - hidden files/directories not allowed");
 72 | 	}
 73 | 
 74 | 	const expandedPath = expandHome(requestedPath);
 75 | 	const absolute = path.isAbsolute(expandedPath)
 76 | 		? path.resolve(expandedPath)
 77 | 		: path.resolve(process.cwd(), expandedPath);
 78 | 
 79 | 	const normalizedRequested = normalizePath(absolute);
 80 | 
 81 | 	// Check if path is within allowed directories
 82 | 	const isAllowed = vaultDirectories.some((dir) =>
 83 | 		normalizedRequested.startsWith(dir),
 84 | 	);
 85 | 	if (!isAllowed) {
 86 | 		throw new Error(
 87 | 			`Access denied - path outside allowed directories: ${absolute} not in ${vaultDirectories.join(
 88 | 				", ",
 89 | 			)}`,
 90 | 		);
 91 | 	}
 92 | 
 93 | 	// Handle symlinks by checking their real path
 94 | 	try {
 95 | 		const realPath = await fs.realpath(absolute);
 96 | 		const normalizedReal = normalizePath(realPath);
 97 | 		const isRealPathAllowed = vaultDirectories.some((dir) =>
 98 | 			normalizedReal.startsWith(dir),
 99 | 		);
100 | 		if (!isRealPathAllowed) {
101 | 			throw new Error(
102 | 				"Access denied - symlink target outside allowed directories",
103 | 			);
104 | 		}
105 | 		return realPath;
106 | 	} catch (error) {
107 | 		// For new files that don't exist yet, verify parent directory
108 | 		const parentDir = path.dirname(absolute);
109 | 		try {
110 | 			const realParentPath = await fs.realpath(parentDir);
111 | 			const normalizedParent = normalizePath(realParentPath);
112 | 			const isParentAllowed = vaultDirectories.some((dir) =>
113 | 				normalizedParent.startsWith(dir),
114 | 			);
115 | 			if (!isParentAllowed) {
116 | 				throw new Error(
117 | 					"Access denied - parent directory outside allowed directories",
118 | 				);
119 | 			}
120 | 			return absolute;
121 | 		} catch {
122 | 			throw new Error(`Parent directory does not exist: ${parentDir}`);
123 | 		}
124 | 	}
125 | }
126 | 
127 | // Schema definitions
128 | const ReadNotesArgsSchema = z.object({
129 | 	paths: z.array(z.string()),
130 | });
131 | 
132 | const SearchNotesArgsSchema = z.object({
133 | 	query: z.string(),
134 | });
135 | 
136 | const ReadNotesDirArgsSchema = z.object({
137 | 	path: z.string(),
138 | });
139 | 
140 | const WriteNoteArgsSchema = z.object({
141 | 	path: z.string(),
142 | 	content: z.string(),
143 | });
144 | 
145 | const ToolInputSchema = ToolSchema.shape.inputSchema;
146 | type ToolInput = z.infer<typeof ToolInputSchema>;
147 | 
148 | // Server setup
149 | const server = new Server(
150 | 	{
151 | 		name: "mcp-obsidian",
152 | 		version: "1.0.0",
153 | 	},
154 | 	{
155 | 		capabilities: {
156 | 			tools: {},
157 | 		},
158 | 	},
159 | );
160 | 
161 | /**
162 |  * Search for notes in the allowed directories that match the query.
163 |  * @param query - The query to search for.
164 |  * @returns An array of relative paths to the notes (from root) that match the query.
165 |  */
166 | async function searchNotes(query: string): Promise<string[]> {
167 | 	const results: string[] = [];
168 | 
169 | 	async function search(basePath: string, currentPath: string) {
170 | 		const entries = await fs.readdir(currentPath, { withFileTypes: true });
171 | 
172 | 		for (const entry of entries) {
173 | 			const fullPath = path.join(currentPath, entry.name);
174 | 
175 | 			try {
176 | 				// Validate each path before processing
177 | 				await validatePath(fullPath);
178 | 
179 | 				let matches = entry.name.toLowerCase().includes(query.toLowerCase());
180 | 				try {
181 | 					matches =
182 | 						matches ||
183 | 						new RegExp(query.replace(/[*]/g, ".*"), "i").test(entry.name);
184 | 				} catch {
185 | 					// Ignore invalid regex
186 | 				}
187 | 
188 | 				if (entry.name.endsWith(".md") && matches) {
189 | 					// Turn into relative path
190 | 					results.push(fullPath.replace(basePath, ""));
191 | 				}
192 | 
193 | 				if (entry.isDirectory()) {
194 | 					await search(basePath, fullPath);
195 | 				}
196 | 			} catch (error) {
197 | 				// Skip invalid paths during search
198 | 				console.error(`Error searching ${fullPath}:`, error);
199 | 			}
200 | 		}
201 | 	}
202 | 
203 | 	await Promise.all(vaultDirectories.map((dir) => search(dir, dir)));
204 | 	return results;
205 | }
206 | 
207 | // Tool handlers
208 | server.setRequestHandler(ListToolsRequestSchema, async () => {
209 | 	const tools = [
210 | 		{
211 | 			name: "obsidian_read_notes",
212 | 			description:
213 | 				"Read the contents of multiple notes. Each note's content is returned with its " +
214 | 				"path as a reference. Failed reads for individual notes won't stop " +
215 | 				"the entire operation. Reading too many at once may result in an error.",
216 | 			inputSchema: zodToJsonSchema(ReadNotesArgsSchema) as ToolInput,
217 | 		},
218 | 		{
219 | 			name: "obsidian_search_notes",
220 | 			description:
221 | 				"Searches for a note by its name. The search " +
222 | 				"is case-insensitive and matches partial names. " +
223 | 				"Queries can also be a valid regex. Returns paths of the notes " +
224 | 				"that match the query.",
225 | 			inputSchema: zodToJsonSchema(SearchNotesArgsSchema) as ToolInput,
226 | 		},
227 | 		{
228 | 			name: "obsidian_read_notes_dir",
229 | 			description:
230 | 				"Lists only the directory structure under the specified path. " +
231 | 				"Returns the relative paths of all directories without file contents.",
232 | 			inputSchema: zodToJsonSchema(ReadNotesDirArgsSchema) as ToolInput,
233 | 		},
234 | 		{
235 | 			name: "obsidian_write_note",
236 | 			description:
237 | 				"Creates a new note at the specified path. Before writing, " +
238 | 				"check the directory structure using obsidian_read_notes_dir. " +
239 | 				"If the target directory is unclear, the operation will be paused " +
240 | 				"and you will be prompted to specify the correct directory.",
241 | 			inputSchema: zodToJsonSchema(WriteNoteArgsSchema) as ToolInput,
242 | 		},
243 | 	];
244 | 
245 | 	return { tools };
246 | });
247 | 
248 | server.setRequestHandler(CallToolRequestSchema, async (request) => {
249 | 	try {
250 | 		const { name, arguments: args } = request.params;
251 | 
252 | 		switch (name) {
253 | 			case "obsidian_read_notes": {
254 | 				const parsed = ReadNotesArgsSchema.safeParse(args);
255 | 				if (!parsed.success) {
256 | 					throw new Error(
257 | 						`Invalid arguments for obsidian_read_notes: ${parsed.error}`,
258 | 					);
259 | 				}
260 | 				const results = await Promise.all(
261 | 					parsed.data.paths.map(async (filePath: string) => {
262 | 						try {
263 | 							const validPath = await validatePath(
264 | 								path.join(vaultDirectories[0], filePath),
265 | 							);
266 | 							const content = await fs.readFile(validPath, "utf-8");
267 | 							return `${filePath}:\n${content}\n`;
268 | 						} catch (error) {
269 | 							const errorMessage =
270 | 								error instanceof Error ? error.message : String(error);
271 | 							return `${filePath}: Error - ${errorMessage}`;
272 | 						}
273 | 					}),
274 | 				);
275 | 				return {
276 | 					content: [{ type: "text", text: results.join("\n---\n") }],
277 | 				};
278 | 			}
279 | 			case "obsidian_search_notes": {
280 | 				const parsed = SearchNotesArgsSchema.safeParse(args);
281 | 				if (!parsed.success) {
282 | 					throw new Error(
283 | 						`Invalid arguments for obsidian_search_notes: ${parsed.error}`,
284 | 					);
285 | 				}
286 | 				const results = await searchNotes(parsed.data.query);
287 | 
288 | 				const limitedResults = results.slice(0, SEARCH_LIMIT);
289 | 				return {
290 | 					content: [
291 | 						{
292 | 							type: "text",
293 | 							text:
294 | 								(limitedResults.length > 0
295 | 									? limitedResults.join("\n")
296 | 									: "No matches found") +
297 | 								(results.length > SEARCH_LIMIT
298 | 									? `\n\n... ${
299 | 											results.length - SEARCH_LIMIT
300 | 										} more results not shown.`
301 | 									: ""),
302 | 						},
303 | 					],
304 | 				};
305 | 			}
306 | 			case "obsidian_read_notes_dir": {
307 | 				const parsed = ReadNotesDirArgsSchema.safeParse(args);
308 | 				if (!parsed.success) {
309 | 					throw new Error(
310 | 						`Invalid arguments for obsidian_read_notes_dir: ${parsed.error}`,
311 | 					);
312 | 				}
313 | 
314 | 				const validPath = await validatePath(
315 | 					path.join(vaultDirectories[0], parsed.data.path),
316 | 				);
317 | 
318 | 				const dirs: string[] = [];
319 | 
320 | 				async function listDirs(currentPath: string) {
321 | 					const entries = await fs.readdir(currentPath, {
322 | 						withFileTypes: true,
323 | 					});
324 | 					for (const entry of entries) {
325 | 						if (entry.isDirectory()) {
326 | 							const fullPath = path.join(currentPath, entry.name);
327 | 							try {
328 | 								await validatePath(fullPath);
329 | 								dirs.push(fullPath.replace(vaultDirectories[0], ""));
330 | 								await listDirs(fullPath);
331 | 							} catch (error) {
332 | 								console.error(`Error listing ${fullPath}:`, error);
333 | 							}
334 | 						}
335 | 					}
336 | 				}
337 | 
338 | 				await listDirs(validPath);
339 | 				return {
340 | 					content: [{ type: "text", text: dirs.join("\n") }],
341 | 				};
342 | 			}
343 | 			case "obsidian_write_note": {
344 | 				const parsed = WriteNoteArgsSchema.safeParse(args);
345 | 				if (!parsed.success) {
346 | 					throw new Error(
347 | 						`Invalid arguments for obsidian_write_note: ${parsed.error}`,
348 | 					);
349 | 				}
350 | 
351 | 				try {
352 | 					const validPath = await validatePath(
353 | 						path.join(vaultDirectories[0], parsed.data.path),
354 | 					);
355 | 					await fs.writeFile(validPath, parsed.data.content, "utf-8");
356 | 					return {
357 | 						content: [
358 | 							{
359 | 								type: "text",
360 | 								text: `Note successfully written to ${parsed.data.path}`,
361 | 							},
362 | 						],
363 | 					};
364 | 				} catch (error) {
365 | 					return {
366 | 						content: [
367 | 							{
368 | 								type: "text",
369 | 								text: `Please specify the target directory. Available directories:\n${vaultDirectories.join(
370 | 									"\n",
371 | 								)}`,
372 | 							},
373 | 						],
374 | 						isError: true,
375 | 					};
376 | 				}
377 | 			}
378 | 			default:
379 | 				throw new Error(`Unknown tool: ${name}`);
380 | 		}
381 | 	} catch (error) {
382 | 		const errorMessage = error instanceof Error ? error.message : String(error);
383 | 		return {
384 | 			content: [{ type: "text", text: `Error: ${errorMessage}` }],
385 | 			isError: true,
386 | 		};
387 | 	}
388 | });
389 | 
390 | // Start server
391 | async function runServer() {
392 | 	const transport = new StdioServerTransport();
393 | 	await server.connect(transport);
394 | 	console.error("MCP Obsidian Server running on stdio");
395 | 	console.error("Allowed directories:", vaultDirectories);
396 | }
397 | 
398 | runServer().catch((error) => {
399 | 	console.error("Fatal error running server:", error);
400 | 	process.exit(1);
401 | });
402 | 
```