#
tokens: 3882/50000 5/5 files
lines: off (toggle) GitHub
raw markdown copy
# Directory Structure

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

# Files

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

```
dist
node_modules
```

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

```markdown
# MCP Obsidian

Model Context Protocol server for Obsidian vault integration. This allows Claude Desktop (or any MCP client) to search and read your Obsidian notes.

## Quick Start (For Users)

### Prerequisites
- Node.js 18+ (install via `brew install node`)
- Obsidian vault
- Claude Desktop (install from https://claude.ai/desktop)

### Configuration

1. Open your Claude Desktop configuration file at:
`~/Library/Application Support/Claude/claude_desktop_config.json`

You can find this through the Claude Desktop menu:
1. Open Claude Desktop
2. Click Claude on the Mac menu bar
3. Click "Settings"
4. Click "Developer"

2. Add the following to your configuration:

```json
{
  "tools": {
    "obsidian": {
      "command": "npx",
      "args": ["-y", "@kazuph/mcp-obsidian"],
      "env": {
        "OBSIDIAN_VAULT_PATH": "/path/to/your/obsidian/vault"
      }
    }
  }
}
```

Note: Replace `/path/to/your/obsidian/vault` with your actual Obsidian vault path.

## For Developers

### Prerequisites
- Node.js 18+ (install via `brew install node`)
- Obsidian vault
- Claude Desktop (install from https://claude.ai/desktop)
- tsx (install via `npm install -g tsx`)

## Installation

```bash
git clone https://github.com/kazuph/mcp-obsidian.git
cd mcp-obsidian
npm install
npm run build
```

## Configuration

1. Make sure Claude Desktop is installed and running.

2. Install tsx globally if you haven't:
```bash
npm install -g tsx
# or
pnpm add -g tsx
```

3. Modify your Claude Desktop config located at:
`~/Library/Application Support/Claude/claude_desktop_config.json`

You can easily find this through the Claude Desktop menu:
1. Open Claude Desktop
2. Click Claude on the Mac menu bar
3. Click "Settings"
4. Click "Developer"

Add the following to your MCP client's configuration:

```json
{
  "tools": {
    "obsidian": {
      "args": ["tsx", "/path/to/mcp-obsidian/index.ts"],
      "env": {
        "OBSIDIAN_VAULT_PATH": "/path/to/your/obsidian/vault"
      }
    }
  }
}
```

## Available Tools

- `obsidian_read_notes`: Read the contents of multiple notes. Each note's content is returned with its path as a reference.
- `obsidian_search_notes`: Search for notes by name (case-insensitive, supports partial matches and regex).
- `obsidian_read_notes_dir`: List the directory structure under a specified path.
- `obsidian_write_note`: Create a new note at the specified path.

```

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

```json
{
	"compilerOptions": {
		"target": "ES2022",
		"strict": true,
		"esModuleInterop": true,
		"skipLibCheck": true,
		"forceConsistentCasingInFileNames": true,
		"resolveJsonModule": true,
		"outDir": "./dist",
		"rootDir": ".",
		"moduleResolution": "NodeNext",
		"module": "NodeNext"
	},
	"exclude": ["node_modules"],
	"include": ["./**/*.ts"]
}

```

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

```json
{
	"name": "@kazuph/mcp-obsidian",
	"version": "1.0.2",
	"description": "Model Context Protocol server for Obsidian Vaults",
	"author": "kazuph (https://x.com/kazuph)",
	"main": "dist/index.js",
	"type": "module",
	"bin": {
		"mcp-obsidian": "dist/index.js"
	},
	"files": [
		"dist"
	],
	"scripts": {
		"build": "tsc && shx chmod +x dist/*.js",
		"prepare": "npm run build",
		"watch": "tsc --watch"
	},
	"repository": {
		"type": "git",
		"url": "git+https://github.com/kazuph/mcp-obsidian.git"
	},
	"keywords": [
		"obsidian",
		"mcp",
		"claude"
	],
	"license": "MIT",
	"publishConfig": {
		"access": "public"
	},
	"dependencies": {
		"@modelcontextprotocol/sdk": "0.5.0",
		"glob": "^10.3.10",
		"zod": "^3.23.8",
		"zod-to-json-schema": "^3.23.5"
	},
	"devDependencies": {
		"@types/node": "^20.11.0",
		"shx": "^0.3.4",
		"typescript": "^5.3.3"
	}
}

```

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

```typescript
#!/usr/bin/env node

import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import {
	CallToolRequestSchema,
	ListToolsRequestSchema,
	ToolSchema,
} from "@modelcontextprotocol/sdk/types.js";
import fs from "node:fs/promises";
import path from "node:path";
import os from "node:os";
import { z } from "zod";
import { zodToJsonSchema } from "zod-to-json-schema";

// Maximum number of search results to return
const SEARCH_LIMIT = 200;

interface Config {
	obsidianVaultPath: string;
}

// Configuration from environment variables
const config: Config = {
	obsidianVaultPath: process.env.OBSIDIAN_VAULT_PATH || "",
};

if (!config.obsidianVaultPath) {
	console.error("Error: OBSIDIAN_VAULT_PATH environment variable is required");
	process.exit(1);
}

// Store allowed directories in normalized form
const vaultDirectories = [
	normalizePath(path.resolve(expandHome(config.obsidianVaultPath))),
];

// Normalize all paths consistently
function normalizePath(p: string): string {
	return path.normalize(p).toLowerCase();
}

function expandHome(filepath: string): string {
	if (filepath.startsWith("~/") || filepath === "~") {
		return path.join(os.homedir(), filepath.slice(1));
	}
	return filepath;
}

// Validate that all directories exist and are accessible
await Promise.all(
	vaultDirectories.map(async (dir) => {
		try {
			const stats = await fs.stat(dir);
			if (!stats.isDirectory()) {
				console.error(`Error: ${dir} is not a directory`);
				process.exit(1);
			}
		} catch (error) {
			console.error(`Error accessing directory ${dir}:`, error);
			process.exit(1);
		}
	}),
);

// Security utilities
async function validatePath(requestedPath: string): Promise<string> {
	// Ignore hidden files/directories starting with "."
	const pathParts = requestedPath.split(path.sep);
	if (pathParts.some((part) => part.startsWith("."))) {
		throw new Error("Access denied - hidden files/directories not allowed");
	}

	const expandedPath = expandHome(requestedPath);
	const absolute = path.isAbsolute(expandedPath)
		? path.resolve(expandedPath)
		: path.resolve(process.cwd(), expandedPath);

	const normalizedRequested = normalizePath(absolute);

	// Check if path is within allowed directories
	const isAllowed = vaultDirectories.some((dir) =>
		normalizedRequested.startsWith(dir),
	);
	if (!isAllowed) {
		throw new Error(
			`Access denied - path outside allowed directories: ${absolute} not in ${vaultDirectories.join(
				", ",
			)}`,
		);
	}

	// Handle symlinks by checking their real path
	try {
		const realPath = await fs.realpath(absolute);
		const normalizedReal = normalizePath(realPath);
		const isRealPathAllowed = vaultDirectories.some((dir) =>
			normalizedReal.startsWith(dir),
		);
		if (!isRealPathAllowed) {
			throw new Error(
				"Access denied - symlink target outside allowed directories",
			);
		}
		return realPath;
	} catch (error) {
		// For new files that don't exist yet, verify parent directory
		const parentDir = path.dirname(absolute);
		try {
			const realParentPath = await fs.realpath(parentDir);
			const normalizedParent = normalizePath(realParentPath);
			const isParentAllowed = vaultDirectories.some((dir) =>
				normalizedParent.startsWith(dir),
			);
			if (!isParentAllowed) {
				throw new Error(
					"Access denied - parent directory outside allowed directories",
				);
			}
			return absolute;
		} catch {
			throw new Error(`Parent directory does not exist: ${parentDir}`);
		}
	}
}

// Schema definitions
const ReadNotesArgsSchema = z.object({
	paths: z.array(z.string()),
});

const SearchNotesArgsSchema = z.object({
	query: z.string(),
});

const ReadNotesDirArgsSchema = z.object({
	path: z.string(),
});

const WriteNoteArgsSchema = z.object({
	path: z.string(),
	content: z.string(),
});

const ToolInputSchema = ToolSchema.shape.inputSchema;
type ToolInput = z.infer<typeof ToolInputSchema>;

// Server setup
const server = new Server(
	{
		name: "mcp-obsidian",
		version: "1.0.0",
	},
	{
		capabilities: {
			tools: {},
		},
	},
);

/**
 * Search for notes in the allowed directories that match the query.
 * @param query - The query to search for.
 * @returns An array of relative paths to the notes (from root) that match the query.
 */
async function searchNotes(query: string): Promise<string[]> {
	const results: string[] = [];

	async function search(basePath: string, currentPath: string) {
		const entries = await fs.readdir(currentPath, { withFileTypes: true });

		for (const entry of entries) {
			const fullPath = path.join(currentPath, entry.name);

			try {
				// Validate each path before processing
				await validatePath(fullPath);

				let matches = entry.name.toLowerCase().includes(query.toLowerCase());
				try {
					matches =
						matches ||
						new RegExp(query.replace(/[*]/g, ".*"), "i").test(entry.name);
				} catch {
					// Ignore invalid regex
				}

				if (entry.name.endsWith(".md") && matches) {
					// Turn into relative path
					results.push(fullPath.replace(basePath, ""));
				}

				if (entry.isDirectory()) {
					await search(basePath, fullPath);
				}
			} catch (error) {
				// Skip invalid paths during search
				console.error(`Error searching ${fullPath}:`, error);
			}
		}
	}

	await Promise.all(vaultDirectories.map((dir) => search(dir, dir)));
	return results;
}

// Tool handlers
server.setRequestHandler(ListToolsRequestSchema, async () => {
	const tools = [
		{
			name: "obsidian_read_notes",
			description:
				"Read the contents of multiple notes. Each note's content is returned with its " +
				"path as a reference. Failed reads for individual notes won't stop " +
				"the entire operation. Reading too many at once may result in an error.",
			inputSchema: zodToJsonSchema(ReadNotesArgsSchema) as ToolInput,
		},
		{
			name: "obsidian_search_notes",
			description:
				"Searches for a note by its name. The search " +
				"is case-insensitive and matches partial names. " +
				"Queries can also be a valid regex. Returns paths of the notes " +
				"that match the query.",
			inputSchema: zodToJsonSchema(SearchNotesArgsSchema) as ToolInput,
		},
		{
			name: "obsidian_read_notes_dir",
			description:
				"Lists only the directory structure under the specified path. " +
				"Returns the relative paths of all directories without file contents.",
			inputSchema: zodToJsonSchema(ReadNotesDirArgsSchema) as ToolInput,
		},
		{
			name: "obsidian_write_note",
			description:
				"Creates a new note at the specified path. Before writing, " +
				"check the directory structure using obsidian_read_notes_dir. " +
				"If the target directory is unclear, the operation will be paused " +
				"and you will be prompted to specify the correct directory.",
			inputSchema: zodToJsonSchema(WriteNoteArgsSchema) as ToolInput,
		},
	];

	return { tools };
});

server.setRequestHandler(CallToolRequestSchema, async (request) => {
	try {
		const { name, arguments: args } = request.params;

		switch (name) {
			case "obsidian_read_notes": {
				const parsed = ReadNotesArgsSchema.safeParse(args);
				if (!parsed.success) {
					throw new Error(
						`Invalid arguments for obsidian_read_notes: ${parsed.error}`,
					);
				}
				const results = await Promise.all(
					parsed.data.paths.map(async (filePath: string) => {
						try {
							const validPath = await validatePath(
								path.join(vaultDirectories[0], filePath),
							);
							const content = await fs.readFile(validPath, "utf-8");
							return `${filePath}:\n${content}\n`;
						} catch (error) {
							const errorMessage =
								error instanceof Error ? error.message : String(error);
							return `${filePath}: Error - ${errorMessage}`;
						}
					}),
				);
				return {
					content: [{ type: "text", text: results.join("\n---\n") }],
				};
			}
			case "obsidian_search_notes": {
				const parsed = SearchNotesArgsSchema.safeParse(args);
				if (!parsed.success) {
					throw new Error(
						`Invalid arguments for obsidian_search_notes: ${parsed.error}`,
					);
				}
				const results = await searchNotes(parsed.data.query);

				const limitedResults = results.slice(0, SEARCH_LIMIT);
				return {
					content: [
						{
							type: "text",
							text:
								(limitedResults.length > 0
									? limitedResults.join("\n")
									: "No matches found") +
								(results.length > SEARCH_LIMIT
									? `\n\n... ${
											results.length - SEARCH_LIMIT
										} more results not shown.`
									: ""),
						},
					],
				};
			}
			case "obsidian_read_notes_dir": {
				const parsed = ReadNotesDirArgsSchema.safeParse(args);
				if (!parsed.success) {
					throw new Error(
						`Invalid arguments for obsidian_read_notes_dir: ${parsed.error}`,
					);
				}

				const validPath = await validatePath(
					path.join(vaultDirectories[0], parsed.data.path),
				);

				const dirs: string[] = [];

				async function listDirs(currentPath: string) {
					const entries = await fs.readdir(currentPath, {
						withFileTypes: true,
					});
					for (const entry of entries) {
						if (entry.isDirectory()) {
							const fullPath = path.join(currentPath, entry.name);
							try {
								await validatePath(fullPath);
								dirs.push(fullPath.replace(vaultDirectories[0], ""));
								await listDirs(fullPath);
							} catch (error) {
								console.error(`Error listing ${fullPath}:`, error);
							}
						}
					}
				}

				await listDirs(validPath);
				return {
					content: [{ type: "text", text: dirs.join("\n") }],
				};
			}
			case "obsidian_write_note": {
				const parsed = WriteNoteArgsSchema.safeParse(args);
				if (!parsed.success) {
					throw new Error(
						`Invalid arguments for obsidian_write_note: ${parsed.error}`,
					);
				}

				try {
					const validPath = await validatePath(
						path.join(vaultDirectories[0], parsed.data.path),
					);
					await fs.writeFile(validPath, parsed.data.content, "utf-8");
					return {
						content: [
							{
								type: "text",
								text: `Note successfully written to ${parsed.data.path}`,
							},
						],
					};
				} catch (error) {
					return {
						content: [
							{
								type: "text",
								text: `Please specify the target directory. Available directories:\n${vaultDirectories.join(
									"\n",
								)}`,
							},
						],
						isError: true,
					};
				}
			}
			default:
				throw new Error(`Unknown tool: ${name}`);
		}
	} catch (error) {
		const errorMessage = error instanceof Error ? error.message : String(error);
		return {
			content: [{ type: "text", text: `Error: ${errorMessage}` }],
			isError: true,
		};
	}
});

// Start server
async function runServer() {
	const transport = new StdioServerTransport();
	await server.connect(transport);
	console.error("MCP Obsidian Server running on stdio");
	console.error("Allowed directories:", vaultDirectories);
}

runServer().catch((error) => {
	console.error("Fatal error running server:", error);
	process.exit(1);
});

```