This is page 1 of 2. Use http://codebase.md/stevenstavrakis/obsidian-mcp?lines=true&page={x} to view the full context. # Directory Structure ``` ├── .github │ └── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md ├── .gitignore ├── bun.lockb ├── docs │ ├── creating-tools.md │ └── tool-examples.md ├── example.ts ├── LICENSE ├── package.json ├── README.md ├── src │ ├── main.ts │ ├── prompts │ │ └── list-vaults │ │ └── index.ts │ ├── resources │ │ ├── index.ts │ │ ├── resources.ts │ │ └── vault │ │ └── index.ts │ ├── server.ts │ ├── tools │ │ ├── add-tags │ │ │ └── index.ts │ │ ├── create-directory │ │ │ └── index.ts │ │ ├── create-note │ │ │ └── index.ts │ │ ├── delete-note │ │ │ └── index.ts │ │ ├── edit-note │ │ │ └── index.ts │ │ ├── list-available-vaults │ │ │ └── index.ts │ │ ├── manage-tags │ │ │ └── index.ts │ │ ├── move-note │ │ │ └── index.ts │ │ ├── read-note │ │ │ └── index.ts │ │ ├── remove-tags │ │ │ └── index.ts │ │ ├── rename-tag │ │ │ └── index.ts │ │ └── search-vault │ │ └── index.ts │ ├── types.ts │ └── utils │ ├── errors.ts │ ├── files.ts │ ├── links.ts │ ├── path.test.ts │ ├── path.ts │ ├── prompt-factory.ts │ ├── responses.ts │ ├── schema.ts │ ├── security.ts │ ├── tags.ts │ ├── tool-factory.ts │ └── vault-resolver.ts └── tsconfig.json ``` # Files -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- ``` 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | .pnpm-debug.log* 9 | 10 | # Diagnostic reports (https://nodejs.org/api/report.html) 11 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 12 | 13 | # Runtime data 14 | pids 15 | *.pid 16 | *.seed 17 | *.pid.lock 18 | 19 | # Directory for instrumented libs generated by jscoverage/JSCover 20 | lib-cov 21 | 22 | # Coverage directory used by tools like istanbul 23 | coverage 24 | *.lcov 25 | 26 | # nyc test coverage 27 | .nyc_output 28 | 29 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 30 | .grunt 31 | 32 | # Bower dependency directory (https://bower.io/) 33 | bower_components 34 | 35 | # node-waf configuration 36 | .lock-wscript 37 | 38 | # Compiled binary addons (https://nodejs.org/api/addons.html) 39 | build/Release 40 | 41 | # Dependency directories 42 | node_modules/ 43 | jspm_packages/ 44 | 45 | # Snowpack dependency directory (https://snowpack.dev/) 46 | web_modules/ 47 | 48 | # TypeScript cache 49 | *.tsbuildinfo 50 | 51 | # Optional npm cache directory 52 | .npm 53 | 54 | # Optional eslint cache 55 | .eslintcache 56 | 57 | # Optional stylelint cache 58 | .stylelintcache 59 | 60 | # Microbundle cache 61 | .rpt2_cache/ 62 | .rts2_cache_cjs/ 63 | .rts2_cache_es/ 64 | .rts2_cache_umd/ 65 | 66 | # Optional REPL history 67 | .node_repl_history 68 | 69 | # Output of 'npm pack' 70 | *.tgz 71 | 72 | # Yarn Integrity file 73 | .yarn-integrity 74 | 75 | # dotenv environment variable files 76 | .env 77 | .env.development.local 78 | .env.test.local 79 | .env.production.local 80 | .env.local 81 | 82 | # parcel-bundler cache (https://parceljs.org/) 83 | .cache 84 | .parcel-cache 85 | 86 | # Next.js build output 87 | .next 88 | out 89 | 90 | # Nuxt.js build / generate output 91 | .nuxt 92 | dist 93 | build 94 | 95 | # Gatsby files 96 | .cache/ 97 | # Comment in the public line in if your project uses Gatsby and not Next.js 98 | # https://nextjs.org/blog/next-9-1#public-directory-support 99 | # public 100 | 101 | # vuepress build output 102 | .vuepress/dist 103 | 104 | # vuepress v2.x temp and cache directory 105 | .temp 106 | .cache 107 | 108 | # Docusaurus cache and generated files 109 | .docusaurus 110 | 111 | # Serverless directories 112 | .serverless/ 113 | 114 | # FuseBox cache 115 | .fusebox/ 116 | 117 | # DynamoDB Local files 118 | .dynamodb/ 119 | 120 | # TernJS port file 121 | .tern-port 122 | 123 | # Stores VSCode versions used for testing VSCode extensions 124 | .vscode-test 125 | 126 | # yarn v2 127 | .yarn/cache 128 | .yarn/unplugged 129 | .yarn/build-state.yml 130 | .yarn/install-state.gz 131 | .pnp.* 132 | ``` -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- ```markdown 1 | # Obsidian MCP Server 2 | 3 | [](https://smithery.ai/server/obsidian-mcp) 4 | 5 | An [MCP (Model Context Protocol)](https://modelcontextprotocol.io) server that enables AI assistants to interact with Obsidian vaults, providing tools for reading, creating, editing and managing notes and tags. 6 | 7 | ## Warning!!! 8 | 9 | This MCP has read and write access (if you allow it). Please. PLEASE backup your Obsidian vault prior to using obsidian-mcp to manage your notes. I recommend using git, but any backup method will work. These tools have been tested, but not thoroughly, and this MCP is in active development. 10 | 11 | ## Features 12 | 13 | - Read and search notes in your vault 14 | - Create new notes and directories 15 | - Edit existing notes 16 | - Move and delete notes 17 | - Manage tags (add, remove, rename) 18 | - Search vault contents 19 | 20 | ## Requirements 21 | 22 | - Node.js 20 or higher (might work on lower, but I haven't tested it) 23 | - An Obsidian vault 24 | 25 | ## Install 26 | 27 | ### Installing Manually 28 | 29 | Add to your Claude Desktop configuration: 30 | 31 | - macOS: `~/Library/Application Support/Claude/claude_desktop_config.json` 32 | - Windows: `%APPDATA%\Claude\claude_desktop_config.json` 33 | 34 | ```json 35 | { 36 | "mcpServers": { 37 | "obsidian": { 38 | "command": "npx", 39 | "args": ["-y", "obsidian-mcp", "/path/to/your/vault", "/path/to/your/vault2"] 40 | } 41 | } 42 | } 43 | ``` 44 | 45 | Replace `/path/to/your/vault` with the absolute path to your Obsidian vault. For example: 46 | 47 | MacOS/Linux: 48 | 49 | ```json 50 | "/Users/username/Documents/MyVault" 51 | ``` 52 | 53 | Windows: 54 | 55 | ```json 56 | "C:\\Users\\username\\Documents\\MyVault" 57 | ``` 58 | 59 | Restart Claude for Desktop after saving the configuration. You should see the hammer icon appear, indicating the server is connected. 60 | 61 | If you have connection issues, check the logs at: 62 | 63 | - MacOS: `~/Library/Logs/Claude/mcp*.log` 64 | - Windows: `%APPDATA%\Claude\logs\mcp*.log` 65 | 66 | 67 | ### Installing via Smithery 68 | Warning: I am not affiliated with Smithery. I have not tested using it and encourage users to install manually if they can. 69 | 70 | To install Obsidian for Claude Desktop automatically via [Smithery](https://smithery.ai/server/obsidian-mcp): 71 | 72 | ```bash 73 | npx -y @smithery/cli install obsidian-mcp --client claude 74 | ``` 75 | 76 | ## Development 77 | 78 | ```bash 79 | # Clone the repository 80 | git clone https://github.com/StevenStavrakis/obsidian-mcp 81 | cd obsidian-mcp 82 | 83 | # Install dependencies 84 | npm install 85 | 86 | # Build 87 | npm run build 88 | ``` 89 | 90 | Then add to your Claude Desktop configuration: 91 | 92 | ```json 93 | { 94 | "mcpServers": { 95 | "obsidian": { 96 | "command": "node", 97 | "args": ["<absolute-path-to-obsidian-mcp>/build/main.js", "/path/to/your/vault", "/path/to/your/vault2"] 98 | } 99 | } 100 | } 101 | ``` 102 | 103 | ## Available Tools 104 | 105 | - `read-note` - Read the contents of a note 106 | - `create-note` - Create a new note 107 | - `edit-note` - Edit an existing note 108 | - `delete-note` - Delete a note 109 | - `move-note` - Move a note to a different location 110 | - `create-directory` - Create a new directory 111 | - `search-vault` - Search notes in the vault 112 | - `add-tags` - Add tags to a note 113 | - `remove-tags` - Remove tags from a note 114 | - `rename-tag` - Rename a tag across all notes 115 | - `manage-tags` - List and organize tags 116 | - `list-available-vaults` - List all available vaults (helps with multi-vault setups) 117 | 118 | ## Documentation 119 | 120 | Additional documentation can be found in the `docs` directory: 121 | 122 | - `creating-tools.md` - Guide for creating new tools 123 | - `tool-examples.md` - Examples of using the available tools 124 | 125 | ## Security 126 | 127 | This server requires access to your Obsidian vault directory. When configuring the server, make sure to: 128 | 129 | - Only provide access to your intended vault directory 130 | - Review tool actions before approving them 131 | 132 | ## Troubleshooting 133 | 134 | Common issues: 135 | 136 | 1. **Server not showing up in Claude Desktop** 137 | - Verify your configuration file syntax 138 | - Make sure the vault path is absolute and exists 139 | - Restart Claude Desktop 140 | 141 | 2. **Permission errors** 142 | - Ensure the vault path is readable/writable 143 | - Check file permissions in your vault 144 | 145 | 3. **Tool execution failures** 146 | - Check Claude Desktop logs at: 147 | - macOS: `~/Library/Logs/Claude/mcp*.log` 148 | - Windows: `%APPDATA%\Claude\logs\mcp*.log` 149 | 150 | ## License 151 | 152 | MIT 153 | ``` -------------------------------------------------------------------------------- /src/resources/index.ts: -------------------------------------------------------------------------------- ```typescript 1 | export * from "./vault"; 2 | ``` -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- ```json 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "module": "ES2020", 5 | "moduleResolution": "node", 6 | "esModuleInterop": true, 7 | "strict": true, 8 | "skipLibCheck": true, 9 | "outDir": "build", 10 | "rootDir": "src", 11 | "sourceMap": true, 12 | "allowJs": true 13 | }, 14 | "include": ["src/**/*.ts"], 15 | "exclude": ["node_modules", "build"] 16 | } 17 | ``` -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- ```markdown 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: "[FEATURE] " 5 | labels: enhancement 6 | assignees: StevenStavrakis 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | ``` -------------------------------------------------------------------------------- /src/tools/list-available-vaults/index.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { createToolResponse } from "../../utils/responses.js"; 2 | import { createToolNoArgs } from "../../utils/tool-factory.js"; 3 | 4 | export const createListAvailableVaultsTool = (vaults: Map<string, string>) => { 5 | return createToolNoArgs({ 6 | name: "list-available-vaults", 7 | description: "Lists all available vaults that can be used with other tools", 8 | handler: async () => { 9 | const availableVaults = Array.from(vaults.keys()); 10 | 11 | if (availableVaults.length === 0) { 12 | return createToolResponse("No vaults are currently available"); 13 | } 14 | 15 | const message = [ 16 | "Available vaults:", 17 | ...availableVaults.map(vault => ` - ${vault}`) 18 | ].join('\n'); 19 | 20 | return createToolResponse(message); 21 | } 22 | }, vaults); 23 | } 24 | ``` -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- ```markdown 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: "[BUG] " 5 | labels: bug 6 | assignees: StevenStavrakis 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Error logs (if available)** 24 | Instructions on how to access error logs can be found [here](https://modelcontextprotocol.io/docs/tools/debugging) 25 | The MCP instructions are only available for MacOS at this time. 26 | 27 | **Desktop (please complete the following information):** 28 | - OS: [e.g. iOS] 29 | - AI Client [e.g. Claude] 30 | 31 | **Additional context** 32 | Add any other context about the problem here. 33 | ``` -------------------------------------------------------------------------------- /src/utils/prompt-factory.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { McpError, ErrorCode } from "@modelcontextprotocol/sdk/types.js"; 2 | import { Prompt } from "../types.js"; 3 | 4 | const prompts = new Map<string, Prompt>(); 5 | 6 | /** 7 | * Register a prompt for use in the MCP server 8 | */ 9 | export function registerPrompt(prompt: Prompt): void { 10 | if (prompts.has(prompt.name)) { 11 | throw new McpError( 12 | ErrorCode.InvalidRequest, 13 | `Prompt "${prompt.name}" is already registered` 14 | ); 15 | } 16 | prompts.set(prompt.name, prompt); 17 | } 18 | 19 | /** 20 | * List all registered prompts 21 | */ 22 | export function listPrompts() { 23 | return { 24 | prompts: Array.from(prompts.values()).map(prompt => ({ 25 | name: prompt.name, 26 | description: prompt.description, 27 | arguments: prompt.arguments 28 | })) 29 | }; 30 | } 31 | 32 | /** 33 | * Get a specific prompt by name 34 | */ 35 | export async function getPrompt(name: string, vaults: Map<string, string>, args?: any) { 36 | const prompt = prompts.get(name); 37 | if (!prompt) { 38 | throw new McpError(ErrorCode.MethodNotFound, `Prompt not found: ${name}`); 39 | } 40 | 41 | try { 42 | return await prompt.handler(args, vaults); 43 | } catch (error) { 44 | if (error instanceof McpError) { 45 | throw error; 46 | } 47 | throw new McpError( 48 | ErrorCode.InternalError, 49 | `Failed to execute prompt: ${error instanceof Error ? error.message : String(error)}` 50 | ); 51 | } 52 | } 53 | ``` -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- ```json 1 | { 2 | "name": "obsidian-mcp", 3 | "version": "1.0.6", 4 | "description": "MCP server for AI assistants to interact with Obsidian vaults", 5 | "type": "module", 6 | "main": "build/main.js", 7 | "bin": { 8 | "obsidian-mcp": "./build/main.js" 9 | }, 10 | "files": [ 11 | "build", 12 | "README.md", 13 | "LICENSE" 14 | ], 15 | "exports": { 16 | ".": "./build/main.js", 17 | "./utils/*": "./build/utils/*.js", 18 | "./resources/*": "./build/resources/*.js" 19 | }, 20 | "peerDependencies": { 21 | "@modelcontextprotocol/sdk": "^1.0.4" 22 | }, 23 | "dependencies": { 24 | "yaml": "^2.6.1", 25 | "zod": "^3.22.4", 26 | "zod-to-json-schema": "^3.24.1" 27 | }, 28 | "devDependencies": { 29 | "@modelcontextprotocol/sdk": "^1.0.4", 30 | "@types/node": "^20.0.0", 31 | "typescript": "^5.0.0", 32 | "@types/bun": "latest" 33 | }, 34 | "scripts": { 35 | "build": "bun build ./src/main.ts --outdir build --target node && chmod +x build/main.js", 36 | "start": "bun build/main.js", 37 | "prepublishOnly": "npm run build", 38 | "inspect": "bunx @modelcontextprotocol/inspector bun ./build/main.js" 39 | }, 40 | "keywords": [ 41 | "obsidian", 42 | "mcp", 43 | "ai", 44 | "notes", 45 | "knowledge-management" 46 | ], 47 | "author": "Steven Stavrakis", 48 | "license": "MIT", 49 | "repository": { 50 | "type": "git", 51 | "url": "https://github.com/StevenStavrakis/obsidian-mcp" 52 | }, 53 | "engines": { 54 | "node": ">=16" 55 | } 56 | } 57 | ``` -------------------------------------------------------------------------------- /src/utils/vault-resolver.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { McpError, ErrorCode } from "@modelcontextprotocol/sdk/types.js"; 2 | 3 | export interface VaultResolutionResult { 4 | vaultPath: string; 5 | vaultName: string; 6 | } 7 | 8 | export interface DualVaultResolutionResult { 9 | source: VaultResolutionResult; 10 | destination: VaultResolutionResult; 11 | isCrossVault: boolean; 12 | } 13 | 14 | export class VaultResolver { 15 | private vaults: Map<string, string>; 16 | constructor(vaults: Map<string, string>) { 17 | if (!vaults || vaults.size === 0) { 18 | throw new Error("At least one vault is required"); 19 | } 20 | this.vaults = vaults; 21 | } 22 | 23 | /** 24 | * Resolves a single vault name to its path and validates it exists 25 | */ 26 | resolveVault(vaultName: string): VaultResolutionResult { 27 | const vaultPath = this.vaults.get(vaultName); 28 | 29 | if (!vaultPath) { 30 | throw new McpError( 31 | ErrorCode.InvalidParams, 32 | `Unknown vault: ${vaultName}. Available vaults: ${Array.from(this.vaults.keys()).join(', ')}` 33 | ); 34 | } 35 | 36 | return { vaultPath, vaultName }; 37 | } 38 | 39 | /** 40 | * Resolves source and destination vaults for operations that work across vaults 41 | */ 42 | // NOT IN USE 43 | 44 | /* 45 | resolveDualVaults(sourceVault: string, destinationVault: string): DualVaultResolutionResult { 46 | const source = this.resolveVault(sourceVault); 47 | const destination = this.resolveVault(destinationVault); 48 | const isCrossVault = sourceVault !== destinationVault; 49 | 50 | return { 51 | source, 52 | destination, 53 | isCrossVault 54 | }; 55 | } 56 | */ 57 | 58 | /** 59 | * Returns a list of available vault names 60 | */ 61 | getAvailableVaults(): string[] { 62 | return Array.from(this.vaults.keys()); 63 | } 64 | } 65 | ``` -------------------------------------------------------------------------------- /src/prompts/list-vaults/index.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { Prompt, PromptResult } from "../../types.js"; 2 | 3 | /** 4 | * Generates the system prompt for tool usage 5 | */ 6 | function generateSystemPrompt(): string { 7 | return `When using tools that require a vault name, use one of the vault names from the "list-vaults" prompt. 8 | For example, when creating a note, you must specify which vault to create it in. 9 | 10 | Available tools will help you: 11 | - Create, edit, move, and delete notes 12 | - Search for specific content within vaults 13 | - Manage tags 14 | - Create directories 15 | 16 | The search-vault tool is for finding specific content within vaults, not for listing available vaults. 17 | Use the "list-vaults" prompt to see available vaults. 18 | Do not try to directly access vault paths - use the provided tools instead.`; 19 | } 20 | 21 | export const listVaultsPrompt: Prompt = { 22 | name: "list-vaults", 23 | description: "Show available Obsidian vaults. Use this prompt to discover which vaults you can work with.", 24 | arguments: [], 25 | handler: async (_, vaults: Map<string, string>): Promise<PromptResult> => { 26 | const vaultList = Array.from(vaults.entries()) 27 | .map(([name, path]) => `- ${name}`) 28 | .join('\n'); 29 | 30 | return { 31 | messages: [ 32 | { 33 | role: "user", 34 | content: { 35 | type: "text", 36 | text: `The following Obsidian vaults are available:\n${vaultList}\n\nYou can use these vault names when working with tools. For example, to create a note in the first vault, use that vault's name in the create-note tool's arguments.` 37 | } 38 | }, 39 | { 40 | role: "assistant", 41 | content: { 42 | type: "text", 43 | text: `I see the available vaults. I'll use these vault names when working with tools that require a vault parameter. For searching within vault contents, I'll use the search-vault tool with the appropriate vault name.` 44 | } 45 | } 46 | ] 47 | }; 48 | } 49 | }; 50 | ``` -------------------------------------------------------------------------------- /src/utils/errors.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { McpError, ErrorCode } from "@modelcontextprotocol/sdk/types.js"; 2 | import { z } from "zod"; 3 | 4 | /** 5 | * Wraps common file system errors into McpErrors 6 | */ 7 | export function handleFsError(error: unknown, operation: string): never { 8 | if (error instanceof McpError) { 9 | throw error; 10 | } 11 | 12 | if (error instanceof Error) { 13 | const nodeError = error as NodeJS.ErrnoException; 14 | 15 | switch (nodeError.code) { 16 | case 'ENOENT': 17 | throw new McpError( 18 | ErrorCode.InvalidRequest, 19 | `File or directory not found: ${nodeError.message}` 20 | ); 21 | case 'EACCES': 22 | throw new McpError( 23 | ErrorCode.InvalidRequest, 24 | `Permission denied: ${nodeError.message}` 25 | ); 26 | case 'EEXIST': 27 | throw new McpError( 28 | ErrorCode.InvalidRequest, 29 | `File or directory already exists: ${nodeError.message}` 30 | ); 31 | case 'ENOSPC': 32 | throw new McpError( 33 | ErrorCode.InternalError, 34 | 'Not enough space to write file' 35 | ); 36 | default: 37 | throw new McpError( 38 | ErrorCode.InternalError, 39 | `Failed to ${operation}: ${nodeError.message}` 40 | ); 41 | } 42 | } 43 | 44 | throw new McpError( 45 | ErrorCode.InternalError, 46 | `Unexpected error during ${operation}` 47 | ); 48 | } 49 | 50 | /** 51 | * Handles Zod validation errors by converting them to McpErrors 52 | */ 53 | export function handleZodError(error: z.ZodError): never { 54 | throw new McpError( 55 | ErrorCode.InvalidRequest, 56 | `Invalid arguments: ${error.errors.map(e => e.message).join(", ")}` 57 | ); 58 | } 59 | 60 | /** 61 | * Creates a standardized error for when a note already exists 62 | */ 63 | export function createNoteExistsError(path: string): McpError { 64 | return new McpError( 65 | ErrorCode.InvalidRequest, 66 | `A note already exists at: ${path}\n\n` + 67 | 'To prevent accidental modifications, this operation has been cancelled.\n' + 68 | 'If you want to modify an existing note, please explicitly request to edit or replace it.' 69 | ); 70 | } 71 | 72 | /** 73 | * Creates a standardized error for when a note is not found 74 | */ 75 | export function createNoteNotFoundError(path: string): McpError { 76 | return new McpError( 77 | ErrorCode.InvalidRequest, 78 | `Note "${path}" not found in vault` 79 | ); 80 | } 81 | ``` -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { z } from "zod"; 2 | 3 | // Tool types 4 | export interface Tool<T = any> { 5 | name: string; 6 | description: string; 7 | inputSchema: { 8 | parse: (args: any) => T; 9 | jsonSchema: any; 10 | }; 11 | handler: (args: T) => Promise<{ 12 | content: { 13 | type: "text"; 14 | text: string; 15 | }[]; 16 | }>; 17 | } 18 | 19 | // Search types 20 | export interface SearchMatch { 21 | line: number; 22 | text: string; 23 | } 24 | 25 | export interface SearchResult { 26 | file: string; 27 | content?: string; 28 | lineNumber?: number; 29 | matches?: SearchMatch[]; 30 | } 31 | 32 | export interface SearchOperationResult { 33 | results: SearchResult[]; 34 | totalResults?: number; 35 | totalMatches?: number; 36 | matchedFiles?: number; 37 | success?: boolean; 38 | message?: string; 39 | } 40 | 41 | export interface SearchOptions { 42 | caseSensitive?: boolean; 43 | wholeWord?: boolean; 44 | useRegex?: boolean; 45 | maxResults?: number; 46 | path?: string; 47 | searchType?: 'content' | 'filename' | 'both'; 48 | } 49 | 50 | // Tag types 51 | export interface TagChange { 52 | tag: string; 53 | location: string; 54 | } 55 | 56 | // Prompt types 57 | export interface Prompt<T = any> { 58 | name: string; 59 | description: string; 60 | arguments: { 61 | name: string; 62 | description: string; 63 | required?: boolean; 64 | }[]; 65 | handler: (args: T, vaults: Map<string, string>) => Promise<PromptResult>; 66 | } 67 | 68 | export interface PromptMessage { 69 | role: "user" | "assistant"; 70 | content: { 71 | type: "text"; 72 | text: string; 73 | }; 74 | } 75 | 76 | export interface ToolResponse { 77 | content: { 78 | type: "text"; 79 | text: string; 80 | }[]; 81 | } 82 | 83 | export interface OperationResult { 84 | success: boolean; 85 | message: string; 86 | details?: Record<string, any>; 87 | } 88 | 89 | export interface BatchOperationResult { 90 | success: boolean; 91 | message: string; 92 | totalCount: number; 93 | successCount: number; 94 | failedItems: Array<{ 95 | item: string; 96 | error: string; 97 | }>; 98 | } 99 | 100 | export interface FileOperationResult { 101 | success: boolean; 102 | message: string; 103 | operation: 'create' | 'edit' | 'delete' | 'move'; 104 | path: string; 105 | } 106 | 107 | export interface TagOperationResult { 108 | success: boolean; 109 | message: string; 110 | totalCount: number; 111 | successCount: number; 112 | details: Record<string, { 113 | changes: TagChange[]; 114 | }>; 115 | failedItems: Array<{ 116 | item: string; 117 | error: string; 118 | }>; 119 | } 120 | 121 | export interface PromptResult { 122 | systemPrompt?: string; 123 | messages: PromptMessage[]; 124 | _meta?: { 125 | [key: string]: any; 126 | }; 127 | } 128 | ``` -------------------------------------------------------------------------------- /src/tools/create-directory/index.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { z } from "zod"; 2 | import { promises as fs } from "fs"; 3 | import path from "path"; 4 | import { McpError, ErrorCode } from "@modelcontextprotocol/sdk/types.js"; 5 | import { createTool } from "../../utils/tool-factory.js"; 6 | 7 | // Input validation schema with descriptions 8 | const schema = z.object({ 9 | vault: z.string() 10 | .min(1, "Vault name cannot be empty") 11 | .describe("Name of the vault where the directory should be created"), 12 | path: z.string() 13 | .min(1, "Directory path cannot be empty") 14 | .refine(dirPath => !path.isAbsolute(dirPath), 15 | "Directory path must be relative to vault root") 16 | .describe("Path of the directory to create (relative to vault root)"), 17 | recursive: z.boolean() 18 | .optional() 19 | .default(true) 20 | .describe("Create parent directories if they don't exist") 21 | }).strict(); 22 | 23 | type CreateDirectoryInput = z.infer<typeof schema>; 24 | 25 | // Helper function to create directory 26 | async function createDirectory( 27 | vaultPath: string, 28 | dirPath: string, 29 | recursive: boolean 30 | ): Promise<string> { 31 | const fullPath = path.join(vaultPath, dirPath); 32 | 33 | // Validate path is within vault 34 | const normalizedPath = path.normalize(fullPath); 35 | if (!normalizedPath.startsWith(path.normalize(vaultPath))) { 36 | throw new McpError( 37 | ErrorCode.InvalidRequest, 38 | "Directory path must be within the vault directory" 39 | ); 40 | } 41 | 42 | try { 43 | // Check if directory already exists 44 | try { 45 | await fs.access(normalizedPath); 46 | throw new McpError( 47 | ErrorCode.InvalidRequest, 48 | `A directory already exists at: ${normalizedPath}` 49 | ); 50 | } catch (error: any) { 51 | if (error.code !== 'ENOENT') { 52 | throw error; 53 | } 54 | // Directory doesn't exist, proceed with creation 55 | await fs.mkdir(normalizedPath, { recursive }); 56 | return normalizedPath; 57 | } 58 | } catch (error: any) { 59 | if (error instanceof McpError) { 60 | throw error; 61 | } 62 | throw new McpError( 63 | ErrorCode.InternalError, 64 | `Failed to create directory: ${error.message}` 65 | ); 66 | } 67 | } 68 | 69 | export function createCreateDirectoryTool(vaults: Map<string, string>) { 70 | return createTool<CreateDirectoryInput>({ 71 | name: "create-directory", 72 | description: "Create a new directory in the specified vault", 73 | schema, 74 | handler: async (args, vaultPath, _vaultName) => { 75 | const createdPath = await createDirectory(vaultPath, args.path, args.recursive ?? true); 76 | return { 77 | content: [ 78 | { 79 | type: "text", 80 | text: `Successfully created directory at: ${createdPath}` 81 | } 82 | ] 83 | }; 84 | } 85 | }, vaults); 86 | } 87 | ``` -------------------------------------------------------------------------------- /src/utils/schema.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { z } from "zod"; 2 | import { zodToJsonSchema } from "zod-to-json-schema"; 3 | import { McpError, ErrorCode } from "@modelcontextprotocol/sdk/types.js"; 4 | 5 | /** 6 | * Converts a JSON Schema object to a Zod schema 7 | */ 8 | function jsonSchemaToZod(schema: { 9 | type: string; 10 | properties: Record<string, any>; 11 | required?: string[]; 12 | }): z.ZodSchema { 13 | const requiredFields = new Set(schema.required || []); 14 | const properties: Record<string, z.ZodTypeAny> = {}; 15 | 16 | for (const [key, value] of Object.entries(schema.properties)) { 17 | let fieldSchema: z.ZodTypeAny; 18 | 19 | switch (value.type) { 20 | case 'string': 21 | fieldSchema = value.enum ? z.enum(value.enum) : z.string(); 22 | break; 23 | case 'number': 24 | fieldSchema = z.number(); 25 | break; 26 | case 'boolean': 27 | fieldSchema = z.boolean(); 28 | break; 29 | case 'array': 30 | if (value.items.type === 'string') { 31 | fieldSchema = z.array(z.string()); 32 | } else { 33 | fieldSchema = z.array(z.unknown()); 34 | } 35 | break; 36 | case 'object': 37 | if (value.properties) { 38 | fieldSchema = jsonSchemaToZod(value); 39 | } else { 40 | fieldSchema = z.record(z.unknown()); 41 | } 42 | break; 43 | default: 44 | fieldSchema = z.unknown(); 45 | } 46 | 47 | // Add description if present 48 | if (value.description) { 49 | fieldSchema = fieldSchema.describe(value.description); 50 | } 51 | 52 | // Make field optional if it's not required 53 | properties[key] = requiredFields.has(key) ? fieldSchema : fieldSchema.optional(); 54 | } 55 | 56 | return z.object(properties); 57 | } 58 | 59 | /** 60 | * Creates a tool schema handler from an existing JSON Schema 61 | */ 62 | export function createSchemaHandlerFromJson<T = any>(jsonSchema: { 63 | type: string; 64 | properties: Record<string, any>; 65 | required?: string[]; 66 | }) { 67 | const zodSchema = jsonSchemaToZod(jsonSchema); 68 | return createSchemaHandler(zodSchema); 69 | } 70 | 71 | /** 72 | * Creates a tool schema handler that manages both JSON Schema for MCP and Zod validation 73 | */ 74 | export function createSchemaHandler<T>(schema: z.ZodSchema<T>) { 75 | return { 76 | // Convert to JSON Schema for MCP interface 77 | jsonSchema: (() => { 78 | const fullSchema = zodToJsonSchema(schema) as { 79 | type: string; 80 | properties: Record<string, any>; 81 | required?: string[]; 82 | }; 83 | return { 84 | type: fullSchema.type || "object", 85 | properties: fullSchema.properties || {}, 86 | required: fullSchema.required || [] 87 | }; 88 | })(), 89 | 90 | // Validate and parse input 91 | parse: (input: unknown): T => { 92 | try { 93 | return schema.parse(input); 94 | } catch (error) { 95 | if (error instanceof z.ZodError) { 96 | throw new McpError( 97 | ErrorCode.InvalidParams, 98 | `Invalid arguments: ${error.errors.map(e => e.message).join(", ")}` 99 | ); 100 | } 101 | throw error; 102 | } 103 | } 104 | }; 105 | } 106 | ``` -------------------------------------------------------------------------------- /src/utils/security.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { McpError, ErrorCode } from "@modelcontextprotocol/sdk/types.js"; 2 | 3 | // Basic rate limiting for API protection 4 | export class RateLimiter { 5 | private requests: Map<string, number[]> = new Map(); 6 | private maxRequests: number; 7 | private timeWindow: number; 8 | 9 | constructor(maxRequests: number = 1000, timeWindow: number = 60000) { 10 | // 1000 requests per minute for local usage 11 | this.maxRequests = maxRequests; 12 | this.timeWindow = timeWindow; 13 | } 14 | 15 | checkLimit(clientId: string): boolean { 16 | const now = Date.now(); 17 | const timestamps = this.requests.get(clientId) || []; 18 | 19 | // Remove old timestamps 20 | const validTimestamps = timestamps.filter( 21 | (time) => now - time < this.timeWindow 22 | ); 23 | 24 | if (validTimestamps.length >= this.maxRequests) { 25 | return false; 26 | } 27 | 28 | validTimestamps.push(now); 29 | this.requests.set(clientId, validTimestamps); 30 | return true; 31 | } 32 | } 33 | 34 | // Message size validation to prevent memory issues 35 | const MAX_MESSAGE_SIZE = 5 * 1024 * 1024; // 5MB for local usage 36 | 37 | export function validateMessageSize(message: any): void { 38 | const size = new TextEncoder().encode(JSON.stringify(message)).length; 39 | if (size > MAX_MESSAGE_SIZE) { 40 | throw new McpError( 41 | ErrorCode.InvalidRequest, 42 | `Message size exceeds limit of ${MAX_MESSAGE_SIZE} bytes` 43 | ); 44 | } 45 | } 46 | 47 | // Connection health monitoring 48 | export class ConnectionMonitor { 49 | private lastActivity: number = Date.now(); 50 | private healthCheckInterval: NodeJS.Timeout | null = null; 51 | private heartbeatInterval: NodeJS.Timeout | null = null; 52 | private readonly timeout: number; 53 | private readonly gracePeriod: number; 54 | private readonly heartbeat: number; 55 | private initialized: boolean = false; 56 | 57 | constructor( 58 | timeout: number = 300000, 59 | gracePeriod: number = 60000, 60 | heartbeat: number = 30000 61 | ) { 62 | // 5min timeout, 1min grace period, 30s heartbeat 63 | this.timeout = timeout; 64 | this.gracePeriod = gracePeriod; 65 | this.heartbeat = heartbeat; 66 | } 67 | 68 | updateActivity() { 69 | this.lastActivity = Date.now(); 70 | } 71 | 72 | start(onTimeout: () => void) { 73 | // Start monitoring after grace period 74 | setTimeout(() => { 75 | this.initialized = true; 76 | 77 | // Set up heartbeat to keep connection alive 78 | this.heartbeatInterval = setInterval(() => { 79 | // The heartbeat itself counts as activity 80 | this.updateActivity(); 81 | }, this.heartbeat); 82 | 83 | // Set up health check 84 | this.healthCheckInterval = setInterval(() => { 85 | const now = Date.now(); 86 | const inactiveTime = now - this.lastActivity; 87 | 88 | if (inactiveTime > this.timeout) { 89 | onTimeout(); 90 | } 91 | }, 10000); // Check every 10 seconds 92 | }, this.gracePeriod); 93 | } 94 | 95 | stop() { 96 | if (this.healthCheckInterval) { 97 | clearInterval(this.healthCheckInterval); 98 | this.healthCheckInterval = null; 99 | } 100 | 101 | if (this.heartbeatInterval) { 102 | clearInterval(this.heartbeatInterval); 103 | this.heartbeatInterval = null; 104 | } 105 | } 106 | } 107 | ``` -------------------------------------------------------------------------------- /src/resources/vault/index.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { McpError, ErrorCode } from "@modelcontextprotocol/sdk/types.js"; 2 | import { promises as fs } from "fs"; 3 | 4 | export interface VaultResource { 5 | uri: string; 6 | name: string; 7 | mimeType: string; 8 | description?: string; 9 | metadata?: { 10 | path: string; 11 | isAccessible: boolean; 12 | }; 13 | } 14 | 15 | export interface VaultListResource { 16 | uri: string; 17 | name: string; 18 | mimeType: string; 19 | description: string; 20 | metadata?: { 21 | totalVaults: number; 22 | vaults: Array<{ 23 | name: string; 24 | path: string; 25 | isAccessible: boolean; 26 | }>; 27 | }; 28 | } 29 | 30 | export async function getVaultMetadata(vaultPath: string): Promise<{ 31 | isAccessible: boolean; 32 | }> { 33 | try { 34 | await fs.access(vaultPath); 35 | return { 36 | isAccessible: true 37 | }; 38 | } catch { 39 | return { 40 | isAccessible: false 41 | }; 42 | } 43 | } 44 | 45 | export async function listVaultResources(vaults: Map<string, string>): Promise<(VaultResource | VaultListResource)[]> { 46 | const resources: (VaultResource | VaultListResource)[] = []; 47 | 48 | // Add root resource that lists all vaults 49 | const vaultList: VaultListResource = { 50 | uri: "obsidian-vault://", 51 | name: "Available Vaults", 52 | mimeType: "application/json", 53 | description: "List of all available Obsidian vaults and their access status", 54 | metadata: { 55 | totalVaults: vaults.size, 56 | vaults: [] 57 | } 58 | }; 59 | 60 | // Process each vault 61 | for (const [vaultName, vaultPath] of vaults.entries()) { 62 | try { 63 | const metadata = await getVaultMetadata(vaultPath); 64 | 65 | // Add to vault list 66 | vaultList.metadata?.vaults.push({ 67 | name: vaultName, 68 | path: vaultPath, 69 | isAccessible: metadata.isAccessible 70 | }); 71 | 72 | // Add individual vault resource 73 | resources.push({ 74 | uri: `obsidian-vault://${vaultName}`, 75 | name: vaultName, 76 | mimeType: "application/json", 77 | description: `Access information for the ${vaultName} vault`, 78 | metadata: { 79 | path: vaultPath, 80 | isAccessible: metadata.isAccessible 81 | } 82 | }); 83 | } catch (error) { 84 | console.error(`Error processing vault ${vaultName}:`, error); 85 | // Still add to vault list but mark as inaccessible 86 | vaultList.metadata?.vaults.push({ 87 | name: vaultName, 88 | path: vaultPath, 89 | isAccessible: false 90 | }); 91 | } 92 | } 93 | 94 | // Add vault list as first resource 95 | resources.unshift(vaultList); 96 | 97 | return resources; 98 | } 99 | 100 | export async function readVaultResource( 101 | vaults: Map<string, string>, 102 | uri: string 103 | ): Promise<{ uri: string; mimeType: string; text: string }> { 104 | const vaultName = uri.replace("obsidian-vault://", ""); 105 | const vaultPath = vaults.get(vaultName); 106 | 107 | if (!vaultPath) { 108 | throw new McpError( 109 | ErrorCode.InvalidRequest, 110 | `Unknown vault: ${vaultName}` 111 | ); 112 | } 113 | 114 | const metadata = await getVaultMetadata(vaultPath); 115 | 116 | return { 117 | uri, 118 | mimeType: "application/json", 119 | text: JSON.stringify({ 120 | name: vaultName, 121 | path: vaultPath, 122 | isAccessible: metadata.isAccessible 123 | }, null, 2) 124 | }; 125 | } 126 | ``` -------------------------------------------------------------------------------- /src/tools/read-note/index.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { z } from "zod"; 2 | import { FileOperationResult } from "../../types.js"; 3 | import { promises as fs } from "fs"; 4 | import path from "path"; 5 | import { McpError } from "@modelcontextprotocol/sdk/types.js"; 6 | import { ensureMarkdownExtension, validateVaultPath } from "../../utils/path.js"; 7 | import { fileExists } from "../../utils/files.js"; 8 | import { createNoteNotFoundError, handleFsError } from "../../utils/errors.js"; 9 | import { createToolResponse, formatFileResult } from "../../utils/responses.js"; 10 | import { createTool } from "../../utils/tool-factory.js"; 11 | 12 | // Input validation schema with descriptions 13 | const schema = z.object({ 14 | vault: z.string() 15 | .min(1, "Vault name cannot be empty") 16 | .describe("Name of the vault containing the note"), 17 | filename: z.string() 18 | .min(1, "Filename cannot be empty") 19 | .refine(name => !name.includes('/') && !name.includes('\\'), 20 | "Filename cannot contain path separators - use the 'folder' parameter for paths instead") 21 | .describe("Just the note name without any path separators (e.g. 'my-note.md', NOT 'folder/my-note.md')"), 22 | folder: z.string() 23 | .optional() 24 | .refine(folder => !folder || !path.isAbsolute(folder), 25 | "Folder must be a relative path") 26 | .describe("Optional subfolder path relative to vault root") 27 | }).strict(); 28 | 29 | type ReadNoteInput = z.infer<typeof schema>; 30 | 31 | async function readNote( 32 | vaultPath: string, 33 | filename: string, 34 | folder?: string 35 | ): Promise<FileOperationResult & { content: string }> { 36 | const sanitizedFilename = ensureMarkdownExtension(filename); 37 | const fullPath = folder 38 | ? path.join(vaultPath, folder, sanitizedFilename) 39 | : path.join(vaultPath, sanitizedFilename); 40 | 41 | // Validate path is within vault 42 | validateVaultPath(vaultPath, fullPath); 43 | 44 | try { 45 | // Check if file exists 46 | if (!await fileExists(fullPath)) { 47 | throw createNoteNotFoundError(filename); 48 | } 49 | 50 | // Read the file content 51 | const content = await fs.readFile(fullPath, "utf-8"); 52 | 53 | return { 54 | success: true, 55 | message: "Note read successfully", 56 | path: fullPath, 57 | operation: 'edit', // Using 'edit' since we don't have a 'read' operation type 58 | content: content 59 | }; 60 | } catch (error: unknown) { 61 | if (error instanceof McpError) { 62 | throw error; 63 | } 64 | throw handleFsError(error, 'read note'); 65 | } 66 | } 67 | 68 | export function createReadNoteTool(vaults: Map<string, string>) { 69 | return createTool<ReadNoteInput>({ 70 | name: "read-note", 71 | description: `Read the content of an existing note in the vault. 72 | 73 | Examples: 74 | - Root note: { "vault": "vault1", "filename": "note.md" } 75 | - Subfolder note: { "vault": "vault1", "filename": "note.md", "folder": "journal/2024" } 76 | - INCORRECT: { "filename": "journal/2024/note.md" } (don't put path in filename)`, 77 | schema, 78 | handler: async (args, vaultPath, _vaultName) => { 79 | const result = await readNote(vaultPath, args.filename, args.folder); 80 | 81 | const formattedResult = formatFileResult({ 82 | success: result.success, 83 | message: result.message, 84 | path: result.path, 85 | operation: result.operation 86 | }); 87 | 88 | return createToolResponse( 89 | `${result.content}\n\n${formattedResult}` 90 | ); 91 | } 92 | }, vaults); 93 | } 94 | ``` -------------------------------------------------------------------------------- /src/tools/move-note/index.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { z } from "zod"; 2 | import { promises as fs } from "fs"; 3 | import path from "path"; 4 | import { McpError } from "@modelcontextprotocol/sdk/types.js"; 5 | import { ensureMarkdownExtension, validateVaultPath } from "../../utils/path.js"; 6 | import { fileExists, ensureDirectory } from "../../utils/files.js"; 7 | import { updateVaultLinks } from "../../utils/links.js"; 8 | import { createNoteExistsError, createNoteNotFoundError, handleFsError } from "../../utils/errors.js"; 9 | import { createTool } from "../../utils/tool-factory.js"; 10 | 11 | // Input validation schema with descriptions 12 | const schema = z.object({ 13 | vault: z.string() 14 | .min(1, "Vault name cannot be empty") 15 | .describe("Name of the vault containing the note"), 16 | source: z.string() 17 | .min(1, "Source path cannot be empty") 18 | .refine(name => !path.isAbsolute(name), 19 | "Source must be a relative path within the vault") 20 | .describe("Source path of the note relative to vault root (e.g., 'folder/note.md')"), 21 | destination: z.string() 22 | .min(1, "Destination path cannot be empty") 23 | .refine(name => !path.isAbsolute(name), 24 | "Destination must be a relative path within the vault") 25 | .describe("Destination path relative to vault root (e.g., 'new-folder/new-name.md')") 26 | }).strict(); 27 | 28 | type MoveNoteArgs = z.infer<typeof schema>; 29 | 30 | async function moveNote( 31 | args: MoveNoteArgs, 32 | vaultPath: string 33 | ): Promise<string> { 34 | // Ensure paths are relative to vault 35 | const fullSourcePath = path.join(vaultPath, args.source); 36 | const fullDestPath = path.join(vaultPath, args.destination); 37 | 38 | // Validate paths are within vault 39 | validateVaultPath(vaultPath, fullSourcePath); 40 | validateVaultPath(vaultPath, fullDestPath); 41 | 42 | try { 43 | // Check if source exists 44 | if (!await fileExists(fullSourcePath)) { 45 | throw createNoteNotFoundError(args.source); 46 | } 47 | 48 | // Check if destination already exists 49 | if (await fileExists(fullDestPath)) { 50 | throw createNoteExistsError(args.destination); 51 | } 52 | 53 | // Ensure destination directory exists 54 | const destDir = path.dirname(fullDestPath); 55 | await ensureDirectory(destDir); 56 | 57 | // Move the file 58 | await fs.rename(fullSourcePath, fullDestPath); 59 | 60 | // Update links in the vault 61 | const updatedFiles = await updateVaultLinks(vaultPath, args.source, args.destination); 62 | 63 | return `Successfully moved note from "${args.source}" to "${args.destination}"\n` + 64 | `Updated links in ${updatedFiles} file${updatedFiles === 1 ? '' : 's'}`; 65 | } catch (error) { 66 | if (error instanceof McpError) { 67 | throw error; 68 | } 69 | throw handleFsError(error, 'move note'); 70 | } 71 | } 72 | 73 | export function createMoveNoteTool(vaults: Map<string, string>) { 74 | return createTool<MoveNoteArgs>({ 75 | name: "move-note", 76 | description: "Move/rename a note while preserving links", 77 | schema, 78 | handler: async (args, vaultPath, vaultName) => { 79 | const argsWithExt: MoveNoteArgs = { 80 | vault: args.vault, 81 | source: ensureMarkdownExtension(args.source), 82 | destination: ensureMarkdownExtension(args.destination) 83 | }; 84 | 85 | const resultMessage = await moveNote(argsWithExt, vaultPath); 86 | 87 | return { 88 | content: [ 89 | { 90 | type: "text", 91 | text: resultMessage 92 | } 93 | ] 94 | }; 95 | } 96 | }, vaults); 97 | } 98 | ``` -------------------------------------------------------------------------------- /src/utils/tool-factory.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { z } from "zod"; 2 | import { Tool } from "../types.js"; 3 | import { McpError, ErrorCode } from "@modelcontextprotocol/sdk/types.js"; 4 | import { createSchemaHandler } from "./schema.js"; 5 | import { VaultResolver } from "./vault-resolver.js"; 6 | 7 | export interface BaseToolConfig<T> { 8 | name: string; 9 | description: string; 10 | schema?: z.ZodType<any>; 11 | handler: ( 12 | args: T, 13 | sourcePath: string, 14 | sourceVaultName: string, 15 | destinationPath?: string, 16 | destinationVaultName?: string, 17 | isCrossVault?: boolean 18 | ) => Promise<any>; 19 | } 20 | 21 | /** 22 | * Creates a standardized tool with common error handling and vault validation 23 | */ 24 | export function createTool<T extends { vault: string }>( 25 | config: BaseToolConfig<T>, 26 | vaults: Map<string, string> 27 | ): Tool { 28 | const vaultResolver = new VaultResolver(vaults); 29 | const schemaHandler = config.schema ? createSchemaHandler(config.schema) : undefined; 30 | 31 | return { 32 | name: config.name, 33 | description: config.description, 34 | inputSchema: schemaHandler || createSchemaHandler(z.object({})), 35 | handler: async (args) => { 36 | try { 37 | const validated = schemaHandler ? schemaHandler.parse(args) as T : {} as T; 38 | const { vaultPath, vaultName } = vaultResolver.resolveVault(validated.vault); 39 | return await config.handler(validated, vaultPath, vaultName); 40 | } catch (error) { 41 | if (error instanceof z.ZodError) { 42 | throw new McpError( 43 | ErrorCode.InvalidRequest, 44 | `Invalid arguments: ${error.errors.map(e => e.message).join(", ")}` 45 | ); 46 | } 47 | throw error; 48 | } 49 | } 50 | }; 51 | } 52 | 53 | /** 54 | * Creates a tool that requires no arguments 55 | */ 56 | export function createToolNoArgs( 57 | config: Omit<BaseToolConfig<{}>, "schema">, 58 | vaults: Map<string, string> 59 | ): Tool { 60 | const vaultResolver = new VaultResolver(vaults); 61 | 62 | return { 63 | name: config.name, 64 | description: config.description, 65 | inputSchema: createSchemaHandler(z.object({})), 66 | handler: async () => { 67 | try { 68 | return await config.handler({}, "", ""); 69 | } catch (error) { 70 | throw error; 71 | } 72 | } 73 | }; 74 | } 75 | 76 | /** 77 | * Creates a standardized tool that operates between two vaults 78 | */ 79 | 80 | // NOT IN USE 81 | 82 | /* 83 | export function createDualVaultTool<T extends { sourceVault: string; destinationVault: string }>( 84 | config: BaseToolConfig<T>, 85 | vaults: Map<string, string> 86 | ): Tool { 87 | const vaultResolver = new VaultResolver(vaults); 88 | const schemaHandler = createSchemaHandler(config.schema); 89 | 90 | return { 91 | name: config.name, 92 | description: config.description, 93 | inputSchema: schemaHandler, 94 | handler: async (args) => { 95 | try { 96 | const validated = schemaHandler.parse(args) as T; 97 | const { source, destination, isCrossVault } = vaultResolver.resolveDualVaults( 98 | validated.sourceVault, 99 | validated.destinationVault 100 | ); 101 | return await config.handler( 102 | validated, 103 | source.vaultPath, 104 | source.vaultName, 105 | destination.vaultPath, 106 | destination.vaultName, 107 | isCrossVault 108 | ); 109 | } catch (error) { 110 | if (error instanceof z.ZodError) { 111 | throw new McpError( 112 | ErrorCode.InvalidRequest, 113 | `Invalid arguments: ${error.errors.map(e => e.message).join(", ")}` 114 | ); 115 | } 116 | throw error; 117 | } 118 | } 119 | }; 120 | } 121 | */ 122 | ``` -------------------------------------------------------------------------------- /src/utils/files.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { promises as fs, Dirent } from "fs"; 2 | import { McpError, ErrorCode } from "@modelcontextprotocol/sdk/types.js"; 3 | import { normalizePath, safeJoinPath } from "./path.js"; 4 | 5 | /** 6 | * Recursively gets all markdown files in a directory 7 | */ 8 | export async function getAllMarkdownFiles(vaultPath: string, dir = vaultPath): Promise<string[]> { 9 | // Normalize paths upfront 10 | const normalizedVaultPath = normalizePath(vaultPath); 11 | const normalizedDir = normalizePath(dir); 12 | 13 | // Verify directory is within vault 14 | if (!normalizedDir.startsWith(normalizedVaultPath)) { 15 | throw new McpError( 16 | ErrorCode.InvalidRequest, 17 | `Search directory must be within vault: ${dir}` 18 | ); 19 | } 20 | 21 | try { 22 | const files: string[] = []; 23 | let entries: Dirent[]; 24 | 25 | try { 26 | entries = await fs.readdir(normalizedDir, { withFileTypes: true }); 27 | } catch (error) { 28 | if ((error as any).code === 'ENOENT') { 29 | throw new McpError( 30 | ErrorCode.InvalidRequest, 31 | `Directory not found: ${dir}` 32 | ); 33 | } 34 | throw error; 35 | } 36 | 37 | for (const entry of entries) { 38 | try { 39 | // Use safeJoinPath to ensure path safety 40 | const fullPath = safeJoinPath(normalizedDir, entry.name); 41 | 42 | if (entry.isDirectory()) { 43 | if (!entry.name.startsWith(".")) { 44 | const subDirFiles = await getAllMarkdownFiles(normalizedVaultPath, fullPath); 45 | files.push(...subDirFiles); 46 | } 47 | } else if (entry.isFile() && entry.name.endsWith(".md")) { 48 | files.push(fullPath); 49 | } 50 | } catch (error) { 51 | // Log but don't throw - we want to continue processing other files 52 | if (error instanceof McpError) { 53 | console.error(`Skipping ${entry.name}:`, error.message); 54 | } else { 55 | console.error(`Error processing ${entry.name}:`, error); 56 | } 57 | } 58 | } 59 | 60 | return files; 61 | } catch (error) { 62 | if (error instanceof McpError) throw error; 63 | 64 | throw new McpError( 65 | ErrorCode.InternalError, 66 | `Failed to read directory ${dir}: ${error instanceof Error ? error.message : String(error)}` 67 | ); 68 | } 69 | } 70 | 71 | /** 72 | * Ensures a directory exists, creating it if necessary 73 | */ 74 | export async function ensureDirectory(dirPath: string): Promise<void> { 75 | const normalizedPath = normalizePath(dirPath); 76 | 77 | try { 78 | await fs.mkdir(normalizedPath, { recursive: true }); 79 | } catch (error: any) { 80 | if (error.code !== 'EEXIST') { 81 | throw new McpError( 82 | ErrorCode.InternalError, 83 | `Failed to create directory ${dirPath}: ${error.message}` 84 | ); 85 | } 86 | } 87 | } 88 | 89 | /** 90 | * Checks if a file exists 91 | */ 92 | export async function fileExists(filePath: string): Promise<boolean> { 93 | const normalizedPath = normalizePath(filePath); 94 | 95 | try { 96 | await fs.access(normalizedPath); 97 | return true; 98 | } catch { 99 | return false; 100 | } 101 | } 102 | 103 | /** 104 | * Safely reads a file's contents 105 | * Returns undefined if file doesn't exist 106 | */ 107 | export async function safeReadFile(filePath: string): Promise<string | undefined> { 108 | const normalizedPath = normalizePath(filePath); 109 | 110 | try { 111 | return await fs.readFile(normalizedPath, 'utf-8'); 112 | } catch (error: any) { 113 | if (error.code === 'ENOENT') { 114 | return undefined; 115 | } 116 | throw new McpError( 117 | ErrorCode.InternalError, 118 | `Failed to read file ${filePath}: ${error.message}` 119 | ); 120 | } 121 | } 122 | ``` -------------------------------------------------------------------------------- /src/tools/create-note/index.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { z } from "zod"; 2 | import { FileOperationResult } from "../../types.js"; 3 | import { promises as fs } from "fs"; 4 | import path from "path"; 5 | import { McpError } from "@modelcontextprotocol/sdk/types.js"; 6 | import { ensureMarkdownExtension, validateVaultPath } from "../../utils/path.js"; 7 | import { ensureDirectory, fileExists } from "../../utils/files.js"; 8 | import { createNoteExistsError, handleFsError } from "../../utils/errors.js"; 9 | import { createToolResponse, formatFileResult } from "../../utils/responses.js"; 10 | import { createTool } from "../../utils/tool-factory.js"; 11 | 12 | // Input validation schema with descriptions 13 | const schema = z.object({ 14 | vault: z.string() 15 | .min(1, "Vault name cannot be empty") 16 | .describe("Name of the vault to create the note in"), 17 | filename: z.string() 18 | .min(1, "Filename cannot be empty") 19 | .refine(name => !name.includes('/') && !name.includes('\\'), 20 | "Filename cannot contain path separators - use the 'folder' parameter for paths instead. Example: use filename:'note.md', folder:'my/path' instead of filename:'my/path/note.md'") 21 | .describe("Just the note name without any path separators (e.g. 'my-note.md', NOT 'folder/my-note.md'). Will add .md extension if missing"), 22 | content: z.string() 23 | .min(1, "Content cannot be empty") 24 | .describe("Content of the note in markdown format"), 25 | folder: z.string() 26 | .optional() 27 | .refine(folder => !folder || !path.isAbsolute(folder), 28 | "Folder must be a relative path") 29 | .describe("Optional subfolder path relative to vault root (e.g. 'journal/subfolder'). Use this for the path instead of including it in filename") 30 | }).strict(); 31 | 32 | async function createNote( 33 | args: z.infer<typeof schema>, 34 | vaultPath: string, 35 | _vaultName: string 36 | ): Promise<FileOperationResult> { 37 | const sanitizedFilename = ensureMarkdownExtension(args.filename); 38 | 39 | const notePath = args.folder 40 | ? path.join(vaultPath, args.folder, sanitizedFilename) 41 | : path.join(vaultPath, sanitizedFilename); 42 | 43 | // Validate path is within vault 44 | validateVaultPath(vaultPath, notePath); 45 | 46 | try { 47 | // Create directory structure if needed 48 | const noteDir = path.dirname(notePath); 49 | await ensureDirectory(noteDir); 50 | 51 | // Check if file exists first 52 | if (await fileExists(notePath)) { 53 | throw createNoteExistsError(notePath); 54 | } 55 | 56 | // File doesn't exist, proceed with creation 57 | await fs.writeFile(notePath, args.content, 'utf8'); 58 | 59 | return { 60 | success: true, 61 | message: "Note created successfully", 62 | path: notePath, 63 | operation: 'create' 64 | }; 65 | } catch (error) { 66 | if (error instanceof McpError) { 67 | throw error; 68 | } 69 | throw handleFsError(error, 'create note'); 70 | } 71 | } 72 | 73 | type CreateNoteArgs = z.infer<typeof schema>; 74 | 75 | export function createCreateNoteTool(vaults: Map<string, string>) { 76 | return createTool<CreateNoteArgs>({ 77 | name: "create-note", 78 | description: `Create a new note in the specified vault with markdown content. 79 | 80 | Examples: 81 | - Root note: { "vault": "vault1", "filename": "note.md" } 82 | - Subfolder note: { "vault": "vault2", "filename": "note.md", "folder": "journal/2024" } 83 | - INCORRECT: { "filename": "journal/2024/note.md" } (don't put path in filename)`, 84 | schema, 85 | handler: async (args, vaultPath, vaultName) => { 86 | const result = await createNote(args, vaultPath, vaultName); 87 | return createToolResponse(formatFileResult(result)); 88 | } 89 | }, vaults); 90 | } 91 | ``` -------------------------------------------------------------------------------- /src/utils/path.test.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { describe, it } from 'bun:test'; 2 | import assert from 'node:assert'; 3 | import path from 'path'; 4 | import { normalizePath } from './path'; 5 | import { McpError, ErrorCode } from "@modelcontextprotocol/sdk/types.js"; 6 | 7 | describe('normalizePath', () => { 8 | describe('Common tests', () => { 9 | it('should handle relative paths', () => { 10 | assert.strictEqual(normalizePath('./path/to/file'), path.resolve('./path/to/file')); 11 | assert.strictEqual(normalizePath('../path/to/file'), path.resolve('../path/to/file')); 12 | }); 13 | 14 | it('should throw error for invalid paths', () => { 15 | assert.throws(() => normalizePath(''), McpError); 16 | assert.throws(() => normalizePath(null as any), McpError); 17 | assert.throws(() => normalizePath(undefined as any), McpError); 18 | assert.throws(() => normalizePath(123 as any), McpError); 19 | }); 20 | }); 21 | 22 | describe('Windows-specific tests', () => { 23 | it('should handle Windows drive letters', () => { 24 | assert.strictEqual(normalizePath('C:\\path\\to\\file'), 'C:/path/to/file'); 25 | assert.strictEqual(normalizePath('D:/path/to/file'), 'D:/path/to/file'); 26 | assert.strictEqual(normalizePath('Z:\\test\\folder'), 'Z:/test/folder'); 27 | }); 28 | 29 | it('should allow colons in Windows drive letters', () => { 30 | assert.strictEqual(normalizePath('C:\\path\\to\\file'), 'C:/path/to/file'); 31 | assert.strictEqual(normalizePath('D:/path/to/file'), 'D:/path/to/file'); 32 | assert.strictEqual(normalizePath('X:\\test\\folder'), 'X:/test/folder'); 33 | }); 34 | 35 | it('should reject Windows paths with invalid characters', () => { 36 | assert.throws(() => normalizePath('C:\\path\\to\\file<'), McpError); 37 | assert.throws(() => normalizePath('D:/path/to/file>'), McpError); 38 | assert.throws(() => normalizePath('E:\\test\\folder|'), McpError); 39 | assert.throws(() => normalizePath('F:/test/folder?'), McpError); 40 | assert.throws(() => normalizePath('G:\\test\\folder*'), McpError); 41 | }); 42 | 43 | it('should handle UNC paths correctly', () => { 44 | assert.strictEqual(normalizePath('\\\\server\\share\\path'), '//server/share/path'); 45 | assert.strictEqual(normalizePath('//server/share/path'), '//server/share/path'); 46 | assert.strictEqual(normalizePath('\\\\server\\share\\folder\\file'), '//server/share/folder/file'); 47 | }); 48 | 49 | it('should handle network drive paths', () => { 50 | assert.strictEqual(normalizePath('Z:\\network\\drive'), 'Z:/network/drive'); 51 | assert.strictEqual(normalizePath('Y:/network/drive'), 'Y:/network/drive'); 52 | }); 53 | 54 | it('should preserve path separators in UNC paths', () => { 55 | const result = normalizePath('\\\\server\\share\\path'); 56 | assert.strictEqual(result, '//server/share/path'); 57 | assert.notStrictEqual(result, path.resolve('//server/share/path')); 58 | }); 59 | 60 | it('should preserve drive letters in Windows paths', () => { 61 | const result = normalizePath('C:\\path\\to\\file'); 62 | assert.strictEqual(result, 'C:/path/to/file'); 63 | assert.notStrictEqual(result, path.resolve('C:/path/to/file')); 64 | }); 65 | }); 66 | 67 | describe('macOS/Unix-specific tests', () => { 68 | it('should handle absolute paths', () => { 69 | assert.strictEqual(normalizePath('/path/to/file'), path.resolve('/path/to/file')); 70 | }); 71 | 72 | it('should handle mixed forward/backward slashes', () => { 73 | assert.strictEqual(normalizePath('path\\to\\file'), 'path/to/file'); 74 | }); 75 | 76 | it('should handle paths with colons in filenames', () => { 77 | assert.strictEqual(normalizePath('/path/to/file:name'), path.resolve('/path/to/file:name')); 78 | }); 79 | }); 80 | }); 81 | ``` -------------------------------------------------------------------------------- /src/utils/links.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { promises as fs } from "fs"; 2 | import path from "path"; 3 | import { getAllMarkdownFiles } from "./files.js"; 4 | 5 | interface LinkUpdateOptions { 6 | filePath: string; 7 | oldPath: string; 8 | newPath?: string; 9 | isMovedToOtherVault?: boolean; 10 | isMovedFromOtherVault?: boolean; 11 | sourceVaultName?: string; 12 | destVaultName?: string; 13 | } 14 | 15 | /** 16 | * Updates markdown links in a file 17 | * @returns true if any links were updated 18 | */ 19 | export async function updateLinksInFile({ 20 | filePath, 21 | oldPath, 22 | newPath, 23 | isMovedToOtherVault, 24 | isMovedFromOtherVault, 25 | sourceVaultName, 26 | destVaultName 27 | }: LinkUpdateOptions): Promise<boolean> { 28 | const content = await fs.readFile(filePath, "utf-8"); 29 | 30 | const oldName = path.basename(oldPath, ".md"); 31 | const newName = newPath ? path.basename(newPath, ".md") : null; 32 | 33 | let newContent: string; 34 | 35 | if (isMovedToOtherVault) { 36 | // Handle move to another vault - add vault reference 37 | newContent = content 38 | .replace( 39 | new RegExp(`\\[\\[${oldName}(\\|[^\\]]*)?\\]\\]`, "g"), 40 | `[[${destVaultName}/${oldName}$1]]` 41 | ) 42 | .replace( 43 | new RegExp(`\\[([^\\]]*)\\]\\(${oldName}\\.md\\)`, "g"), 44 | `[$1](${destVaultName}/${oldName}.md)` 45 | ); 46 | } else if (isMovedFromOtherVault) { 47 | // Handle move from another vault - add note about original location 48 | newContent = content 49 | .replace( 50 | new RegExp(`\\[\\[${oldName}(\\|[^\\]]*)?\\]\\]`, "g"), 51 | `[[${newName}$1]] *(moved from ${sourceVaultName})*` 52 | ) 53 | .replace( 54 | new RegExp(`\\[([^\\]]*)\\]\\(${oldName}\\.md\\)`, "g"), 55 | `[$1](${newName}.md) *(moved from ${sourceVaultName})*` 56 | ); 57 | } else if (!newPath) { 58 | // Handle deletion - strike through the links 59 | newContent = content 60 | .replace( 61 | new RegExp(`\\[\\[${oldName}(\\|[^\\]]*)?\\]\\]`, "g"), 62 | `~~[[${oldName}$1]]~~` 63 | ) 64 | .replace( 65 | new RegExp(`\\[([^\\]]*)\\]\\(${oldName}\\.md\\)`, "g"), 66 | `~~[$1](${oldName}.md)~~` 67 | ); 68 | } else { 69 | // Handle move/rename within same vault 70 | newContent = content 71 | .replace( 72 | new RegExp(`\\[\\[${oldName}(\\|[^\\]]*)?\\]\\]`, "g"), 73 | `[[${newName}$1]]` 74 | ) 75 | .replace( 76 | new RegExp(`\\[([^\\]]*)\\]\\(${oldName}\\.md\\)`, "g"), 77 | `[$1](${newName}.md)` 78 | ); 79 | } 80 | 81 | if (content !== newContent) { 82 | await fs.writeFile(filePath, newContent, "utf-8"); 83 | return true; 84 | } 85 | 86 | return false; 87 | } 88 | 89 | /** 90 | * Updates all markdown links in the vault after a note is moved or deleted 91 | * @returns number of files updated 92 | */ 93 | export async function updateVaultLinks( 94 | vaultPath: string, 95 | oldPath: string | null | undefined, 96 | newPath: string | null | undefined, 97 | sourceVaultName?: string, 98 | destVaultName?: string 99 | ): Promise<number> { 100 | const files = await getAllMarkdownFiles(vaultPath); 101 | let updatedFiles = 0; 102 | 103 | // Determine the type of operation 104 | const isMovedToOtherVault: boolean = Boolean(oldPath !== null && newPath === null && sourceVaultName && destVaultName); 105 | const isMovedFromOtherVault: boolean = Boolean(oldPath === null && newPath !== null && sourceVaultName && destVaultName); 106 | 107 | for (const file of files) { 108 | // Skip the target file itself if it's a move operation 109 | if (newPath && file === path.join(vaultPath, newPath)) continue; 110 | 111 | if (await updateLinksInFile({ 112 | filePath: file, 113 | oldPath: oldPath || "", 114 | newPath: newPath || undefined, 115 | isMovedToOtherVault, 116 | isMovedFromOtherVault, 117 | sourceVaultName, 118 | destVaultName 119 | })) { 120 | updatedFiles++; 121 | } 122 | } 123 | 124 | return updatedFiles; 125 | } 126 | ``` -------------------------------------------------------------------------------- /src/resources/resources.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { promises as fs } from "fs"; 2 | import { McpError, ErrorCode } from "@modelcontextprotocol/sdk/types.js"; 3 | 4 | export interface VaultResource { 5 | uri: string; 6 | name: string; 7 | mimeType: string; 8 | description?: string; 9 | metadata?: { 10 | path: string; 11 | isAccessible: boolean; 12 | }; 13 | } 14 | 15 | export interface VaultListResource { 16 | uri: string; 17 | name: string; 18 | mimeType: string; 19 | description: string; 20 | metadata?: { 21 | totalVaults: number; 22 | vaults: Array<{ 23 | name: string; 24 | path: string; 25 | isAccessible: boolean; 26 | }>; 27 | }; 28 | } 29 | 30 | /** 31 | * Gets metadata for a vault 32 | */ 33 | export async function getVaultMetadata(vaultPath: string): Promise<{ 34 | isAccessible: boolean; 35 | }> { 36 | try { 37 | await fs.access(vaultPath); 38 | return { 39 | isAccessible: true 40 | }; 41 | } catch { 42 | return { 43 | isAccessible: false 44 | }; 45 | } 46 | } 47 | 48 | /** 49 | * Lists vault resources including a root resource that lists all vaults 50 | */ 51 | export async function listVaultResources(vaults: Map<string, string>): Promise<(VaultResource | VaultListResource)[]> { 52 | const resources: (VaultResource | VaultListResource)[] = []; 53 | 54 | // Add root resource that lists all vaults 55 | const vaultList: VaultListResource = { 56 | uri: "obsidian-vault://", 57 | name: "Available Vaults", 58 | mimeType: "application/json", 59 | description: "List of all available Obsidian vaults and their access status", 60 | metadata: { 61 | totalVaults: vaults.size, 62 | vaults: [] 63 | } 64 | }; 65 | 66 | // Process each vault 67 | for (const [vaultName, vaultPath] of vaults.entries()) { 68 | try { 69 | const metadata = await getVaultMetadata(vaultPath); 70 | 71 | // Add to vault list 72 | vaultList.metadata?.vaults.push({ 73 | name: vaultName, 74 | path: vaultPath, 75 | isAccessible: metadata.isAccessible 76 | }); 77 | 78 | // Add individual vault resource 79 | resources.push({ 80 | uri: `obsidian-vault://${vaultName}`, 81 | name: vaultName, 82 | mimeType: "application/json", 83 | description: `Access information for the ${vaultName} vault`, 84 | metadata: { 85 | path: vaultPath, 86 | isAccessible: metadata.isAccessible 87 | } 88 | }); 89 | } catch (error) { 90 | console.error(`Error processing vault ${vaultName}:`, error); 91 | // Still add to vault list but mark as inaccessible 92 | vaultList.metadata?.vaults.push({ 93 | name: vaultName, 94 | path: vaultPath, 95 | isAccessible: false 96 | }); 97 | } 98 | } 99 | 100 | // Add vault list as first resource 101 | resources.unshift(vaultList); 102 | 103 | return resources; 104 | } 105 | 106 | /** 107 | * Reads a vault resource by URI 108 | */ 109 | export async function readVaultResource( 110 | vaults: Map<string, string>, 111 | uri: string 112 | ): Promise<{ uri: string; mimeType: string; text: string }> { 113 | // Handle root vault list 114 | if (uri === 'obsidian-vault://') { 115 | const vaultList = []; 116 | for (const [name, path] of vaults.entries()) { 117 | const metadata = await getVaultMetadata(path); 118 | vaultList.push({ 119 | name, 120 | path, 121 | isAccessible: metadata.isAccessible 122 | }); 123 | } 124 | return { 125 | uri, 126 | mimeType: "application/json", 127 | text: JSON.stringify({ 128 | totalVaults: vaults.size, 129 | vaults: vaultList 130 | }, null, 2) 131 | }; 132 | } 133 | 134 | // Handle individual vault resources 135 | const vaultName = uri.replace("obsidian-vault://", ""); 136 | const vaultPath = vaults.get(vaultName); 137 | 138 | if (!vaultPath) { 139 | throw new McpError( 140 | ErrorCode.InvalidRequest, 141 | `Unknown vault: ${vaultName}` 142 | ); 143 | } 144 | 145 | const metadata = await getVaultMetadata(vaultPath); 146 | 147 | return { 148 | uri, 149 | mimeType: "application/json", 150 | text: JSON.stringify({ 151 | name: vaultName, 152 | path: vaultPath, 153 | isAccessible: metadata.isAccessible 154 | }, null, 2) 155 | }; 156 | } 157 | ``` -------------------------------------------------------------------------------- /src/utils/responses.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { 2 | ToolResponse, 3 | OperationResult, 4 | BatchOperationResult, 5 | FileOperationResult, 6 | TagOperationResult, 7 | SearchOperationResult, 8 | TagChange, 9 | SearchResult 10 | } from '../types.js'; 11 | 12 | /** 13 | * Creates a standardized tool response 14 | */ 15 | export function createToolResponse(message: string): ToolResponse { 16 | return { 17 | content: [{ 18 | type: "text", 19 | text: message 20 | }] 21 | }; 22 | } 23 | 24 | /** 25 | * Formats a basic operation result 26 | */ 27 | export function formatOperationResult(result: OperationResult): string { 28 | const parts: string[] = []; 29 | 30 | // Add main message 31 | parts.push(result.message); 32 | 33 | // Add details if present 34 | if (result.details) { 35 | parts.push('\nDetails:'); 36 | Object.entries(result.details).forEach(([key, value]) => { 37 | parts.push(` ${key}: ${JSON.stringify(value)}`); 38 | }); 39 | } 40 | 41 | return parts.join('\n'); 42 | } 43 | 44 | /** 45 | * Formats a batch operation result 46 | */ 47 | export function formatBatchResult(result: BatchOperationResult): string { 48 | const parts: string[] = []; 49 | 50 | // Add summary 51 | parts.push(result.message); 52 | parts.push(`\nProcessed ${result.totalCount} items: ${result.successCount} succeeded`); 53 | 54 | // Add failures if any 55 | if (result.failedItems.length > 0) { 56 | parts.push('\nErrors:'); 57 | result.failedItems.forEach(({ item, error }) => { 58 | parts.push(` ${item}: ${error}`); 59 | }); 60 | } 61 | 62 | return parts.join('\n'); 63 | } 64 | 65 | /** 66 | * Formats a file operation result 67 | */ 68 | export function formatFileResult(result: FileOperationResult): string { 69 | const operationText = { 70 | create: 'Created', 71 | edit: 'Modified', 72 | delete: 'Deleted', 73 | move: 'Moved' 74 | }[result.operation]; 75 | 76 | return `${operationText} file: ${result.path}\n${result.message}`; 77 | } 78 | 79 | /** 80 | * Formats tag changes for reporting 81 | */ 82 | function formatTagChanges(changes: TagChange[]): string { 83 | const byLocation = changes.reduce((acc, change) => { 84 | if (!acc[change.location]) acc[change.location] = new Set(); 85 | acc[change.location].add(change.tag); 86 | return acc; 87 | }, {} as Record<string, Set<string>>); 88 | 89 | const parts: string[] = []; 90 | for (const [location, tags] of Object.entries(byLocation)) { 91 | parts.push(` ${location}: ${Array.from(tags).join(', ')}`); 92 | } 93 | 94 | return parts.join('\n'); 95 | } 96 | 97 | /** 98 | * Formats a tag operation result 99 | */ 100 | export function formatTagResult(result: TagOperationResult): string { 101 | const parts: string[] = []; 102 | 103 | // Add summary 104 | parts.push(result.message); 105 | parts.push(`\nProcessed ${result.totalCount} files: ${result.successCount} modified`); 106 | 107 | // Add detailed changes 108 | for (const [filename, fileDetails] of Object.entries(result.details)) { 109 | if (fileDetails.changes.length > 0) { 110 | parts.push(`\nChanges in ${filename}:`); 111 | parts.push(formatTagChanges(fileDetails.changes)); 112 | } 113 | } 114 | 115 | // Add failures if any 116 | if (result.failedItems.length > 0) { 117 | parts.push('\nErrors:'); 118 | result.failedItems.forEach(({ item, error }) => { 119 | parts.push(` ${item}: ${error}`); 120 | }); 121 | } 122 | 123 | return parts.join('\n'); 124 | } 125 | 126 | /** 127 | * Formats search results 128 | */ 129 | export function formatSearchResult(result: SearchOperationResult): string { 130 | const parts: string[] = []; 131 | 132 | // Add summary 133 | parts.push( 134 | `Found ${result.totalMatches} match${result.totalMatches === 1 ? '' : 'es'} ` + 135 | `in ${result.matchedFiles} file${result.matchedFiles === 1 ? '' : 's'}` 136 | ); 137 | 138 | if (result.results.length === 0) { 139 | return 'No matches found.'; 140 | } 141 | 142 | // Separate filename and content matches 143 | const filenameMatches = result.results.filter(r => r.matches?.some(m => m.line === 0)); 144 | const contentMatches = result.results.filter(r => r.matches?.some(m => m.line !== 0)); 145 | 146 | // Add filename matches if any 147 | if (filenameMatches.length > 0) { 148 | parts.push('\nFilename matches:'); 149 | filenameMatches.forEach(result => { 150 | parts.push(` ${result.file}`); 151 | }); 152 | } 153 | 154 | // Add content matches if any 155 | if (contentMatches.length > 0) { 156 | parts.push('\nContent matches:'); 157 | contentMatches.forEach(result => { 158 | parts.push(`\nFile: ${result.file}`); 159 | result.matches 160 | ?.filter(m => m?.line !== 0) // Skip filename matches 161 | ?.forEach(m => m && parts.push(` Line ${m.line}: ${m.text}`)); 162 | }); 163 | } 164 | 165 | return parts.join('\n'); 166 | } 167 | 168 | /** 169 | * Creates a standardized error response 170 | */ 171 | export function createErrorResponse(error: Error): ToolResponse { 172 | return createToolResponse(`Error: ${error.message}`); 173 | } 174 | ``` -------------------------------------------------------------------------------- /src/tools/delete-note/index.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { z } from "zod"; 2 | import { promises as fs } from "fs"; 3 | import path from "path"; 4 | import { McpError } from "@modelcontextprotocol/sdk/types.js"; 5 | import { ensureMarkdownExtension, validateVaultPath } from "../../utils/path.js"; 6 | import { fileExists, ensureDirectory } from "../../utils/files.js"; 7 | import { updateVaultLinks } from "../../utils/links.js"; 8 | import { createNoteNotFoundError, handleFsError } from "../../utils/errors.js"; 9 | import { createTool } from "../../utils/tool-factory.js"; 10 | 11 | // Input validation schema with descriptions 12 | const schema = z.object({ 13 | vault: z.string() 14 | .min(1, "Vault name cannot be empty") 15 | .describe("Name of the vault containing the note"), 16 | path: z.string() 17 | .min(1, "Path cannot be empty") 18 | .refine(name => !path.isAbsolute(name), 19 | "Path must be relative to vault root") 20 | .describe("Path of the note relative to vault root (e.g., 'folder/note.md')"), 21 | reason: z.string() 22 | .optional() 23 | .describe("Optional reason for deletion (stored in trash metadata)"), 24 | permanent: z.boolean() 25 | .optional() 26 | .default(false) 27 | .describe("Whether to permanently delete instead of moving to trash (default: false)") 28 | }).strict(); 29 | 30 | 31 | interface TrashMetadata { 32 | originalPath: string; 33 | deletedAt: string; 34 | reason?: string; 35 | } 36 | 37 | async function ensureTrashDirectory(vaultPath: string): Promise<string> { 38 | const trashPath = path.join(vaultPath, ".trash"); 39 | await ensureDirectory(trashPath); 40 | return trashPath; 41 | } 42 | 43 | async function moveToTrash( 44 | vaultPath: string, 45 | notePath: string, 46 | reason?: string 47 | ): Promise<string> { 48 | const trashPath = await ensureTrashDirectory(vaultPath); 49 | const timestamp = new Date().toISOString().replace(/[:.]/g, "-"); 50 | const trashName = `${path.basename(notePath, ".md")}_${timestamp}.md`; 51 | const trashFilePath = path.join(trashPath, trashName); 52 | 53 | // Create metadata 54 | const metadata: TrashMetadata = { 55 | originalPath: notePath, 56 | deletedAt: new Date().toISOString(), 57 | reason 58 | }; 59 | 60 | try { 61 | // Read original content 62 | const content = await fs.readFile(path.join(vaultPath, notePath), "utf-8"); 63 | 64 | // Prepend metadata as YAML frontmatter 65 | const contentWithMetadata = `--- 66 | trash_metadata: 67 | original_path: ${metadata.originalPath} 68 | deleted_at: ${metadata.deletedAt}${reason ? `\n reason: ${reason}` : ""} 69 | --- 70 | 71 | ${content}`; 72 | 73 | // Write to trash with metadata 74 | await fs.writeFile(trashFilePath, contentWithMetadata); 75 | 76 | // Delete original file 77 | await fs.unlink(path.join(vaultPath, notePath)); 78 | 79 | return trashName; 80 | } catch (error) { 81 | throw handleFsError(error, 'move note to trash'); 82 | } 83 | } 84 | 85 | async function deleteNote( 86 | vaultPath: string, 87 | notePath: string, 88 | options: { 89 | permanent?: boolean; 90 | reason?: string; 91 | } = {} 92 | ): Promise<string> { 93 | const fullPath = path.join(vaultPath, notePath); 94 | 95 | // Validate path is within vault 96 | validateVaultPath(vaultPath, fullPath); 97 | 98 | try { 99 | // Check if note exists 100 | if (!await fileExists(fullPath)) { 101 | throw createNoteNotFoundError(notePath); 102 | } 103 | 104 | // Update links in other files first 105 | const updatedFiles = await updateVaultLinks(vaultPath, notePath, null); 106 | 107 | if (options.permanent) { 108 | // Permanently delete the file 109 | await fs.unlink(fullPath); 110 | return `Permanently deleted note "${notePath}"\n` + 111 | `Updated ${updatedFiles} file${updatedFiles === 1 ? '' : 's'} with broken links`; 112 | } else { 113 | // Move to trash with metadata 114 | const trashName = await moveToTrash(vaultPath, notePath, options.reason); 115 | return `Moved note "${notePath}" to trash as "${trashName}"\n` + 116 | `Updated ${updatedFiles} file${updatedFiles === 1 ? '' : 's'} with broken links`; 117 | } 118 | } catch (error) { 119 | if (error instanceof McpError) { 120 | throw error; 121 | } 122 | throw handleFsError(error, 'delete note'); 123 | } 124 | } 125 | 126 | type DeleteNoteArgs = z.infer<typeof schema>; 127 | 128 | export function createDeleteNoteTool(vaults: Map<string, string>) { 129 | return createTool<DeleteNoteArgs>({ 130 | name: "delete-note", 131 | description: "Delete a note, moving it to .trash by default or permanently deleting if specified", 132 | schema, 133 | handler: async (args, vaultPath, _vaultName) => { 134 | // Ensure .md extension 135 | const fullNotePath = ensureMarkdownExtension(args.path); 136 | 137 | const resultMessage = await deleteNote(vaultPath, fullNotePath, { 138 | reason: args.reason, 139 | permanent: args.permanent 140 | }); 141 | 142 | return { 143 | content: [ 144 | { 145 | type: "text", 146 | text: resultMessage 147 | } 148 | ] 149 | }; 150 | } 151 | }, vaults); 152 | } 153 | ``` -------------------------------------------------------------------------------- /src/tools/add-tags/index.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { z } from "zod"; 2 | import { TagOperationResult } from "../../types.js"; 3 | import { promises as fs } from "fs"; 4 | import path from "path"; 5 | import { McpError } from "@modelcontextprotocol/sdk/types.js"; 6 | import { validateVaultPath } from "../../utils/path.js"; 7 | import { fileExists, safeReadFile } from "../../utils/files.js"; 8 | import { 9 | validateTag, 10 | parseNote, 11 | stringifyNote, 12 | addTagsToFrontmatter, 13 | normalizeTag 14 | } from "../../utils/tags.js"; 15 | import { createToolResponse, formatTagResult } from "../../utils/responses.js"; 16 | import { createTool } from "../../utils/tool-factory.js"; 17 | 18 | // Input validation schema with descriptions 19 | const schema = z.object({ 20 | vault: z.string() 21 | .min(1, "Vault name cannot be empty") 22 | .describe("Name of the vault containing the notes"), 23 | files: z.array(z.string()) 24 | .min(1, "At least one file must be specified") 25 | .refine( 26 | files => files.every(f => f.endsWith('.md')), 27 | "All files must have .md extension" 28 | ) 29 | .describe("Array of note filenames to process (must have .md extension)"), 30 | tags: z.array(z.string()) 31 | .min(1, "At least one tag must be specified") 32 | .refine( 33 | tags => tags.every(validateTag), 34 | "Invalid tag format. Tags must contain only letters, numbers, and forward slashes for hierarchy." 35 | ) 36 | .describe("Array of tags to add (e.g., 'status/active', 'project/docs')"), 37 | location: z.enum(['frontmatter', 'content', 'both']) 38 | .optional() 39 | .describe("Where to add tags (default: both)"), 40 | normalize: z.boolean() 41 | .optional() 42 | .describe("Whether to normalize tag format (e.g., ProjectActive -> project-active) (default: true)"), 43 | position: z.enum(['start', 'end']) 44 | .optional() 45 | .describe("Where to add inline tags in content (default: end)") 46 | }).strict(); 47 | 48 | type AddTagsArgs = z.infer<typeof schema>; 49 | 50 | async function addTags( 51 | vaultPath: string, 52 | files: string[], 53 | tags: string[], 54 | location: 'frontmatter' | 'content' | 'both' = 'both', 55 | normalize: boolean = true, 56 | position: 'start' | 'end' = 'end' 57 | ): Promise<TagOperationResult> { 58 | const result: TagOperationResult = { 59 | success: true, 60 | message: "Tag addition completed", 61 | successCount: 0, 62 | totalCount: files.length, 63 | failedItems: [], 64 | details: {} 65 | }; 66 | 67 | for (const filename of files) { 68 | const fullPath = path.join(vaultPath, filename); 69 | result.details[filename] = { changes: [] }; 70 | 71 | try { 72 | // Validate path is within vault 73 | validateVaultPath(vaultPath, fullPath); 74 | 75 | // Check if file exists 76 | if (!await fileExists(fullPath)) { 77 | result.failedItems.push({ 78 | item: filename, 79 | error: "File not found" 80 | }); 81 | continue; 82 | } 83 | 84 | // Read file content 85 | const content = await safeReadFile(fullPath); 86 | if (!content) { 87 | result.failedItems.push({ 88 | item: filename, 89 | error: "Failed to read file" 90 | }); 91 | continue; 92 | } 93 | 94 | // Parse the note 95 | const parsed = parseNote(content); 96 | let modified = false; 97 | 98 | // Handle frontmatter tags 99 | if (location !== 'content') { 100 | const updatedFrontmatter = addTagsToFrontmatter( 101 | parsed.frontmatter, 102 | tags, 103 | normalize 104 | ); 105 | 106 | if (JSON.stringify(parsed.frontmatter) !== JSON.stringify(updatedFrontmatter)) { 107 | parsed.frontmatter = updatedFrontmatter; 108 | parsed.hasFrontmatter = true; 109 | modified = true; 110 | 111 | // Record changes 112 | tags.forEach((tag: string) => { 113 | result.details[filename].changes.push({ 114 | tag: normalize ? normalizeTag(tag) : tag, 115 | location: 'frontmatter' 116 | }); 117 | }); 118 | } 119 | } 120 | 121 | // Handle inline tags 122 | if (location !== 'frontmatter') { 123 | const tagString = tags 124 | .filter(tag => validateTag(tag)) 125 | .map((tag: string) => `#${normalize ? normalizeTag(tag) : tag}`) 126 | .join(' '); 127 | 128 | if (tagString) { 129 | if (position === 'start') { 130 | parsed.content = tagString + '\n\n' + parsed.content.trim(); 131 | } else { 132 | parsed.content = parsed.content.trim() + '\n\n' + tagString; 133 | } 134 | modified = true; 135 | 136 | // Record changes 137 | tags.forEach((tag: string) => { 138 | result.details[filename].changes.push({ 139 | tag: normalize ? normalizeTag(tag) : tag, 140 | location: 'content' 141 | }); 142 | }); 143 | } 144 | } 145 | 146 | // Save changes if modified 147 | if (modified) { 148 | const updatedContent = stringifyNote(parsed); 149 | await fs.writeFile(fullPath, updatedContent); 150 | result.successCount++; 151 | } 152 | } catch (error) { 153 | result.failedItems.push({ 154 | item: filename, 155 | error: error instanceof Error ? error.message : 'Unknown error' 156 | }); 157 | } 158 | } 159 | 160 | // Update success status based on results 161 | result.success = result.failedItems.length === 0; 162 | result.message = result.success 163 | ? `Successfully added tags to ${result.successCount} files` 164 | : `Completed with ${result.failedItems.length} errors`; 165 | 166 | return result; 167 | } 168 | 169 | export function createAddTagsTool(vaults: Map<string, string>) { 170 | return createTool<AddTagsArgs>({ 171 | name: "add-tags", 172 | description: `Add tags to notes in frontmatter and/or content. 173 | 174 | Examples: 175 | - Add to both locations: { "files": ["note.md"], "tags": ["status/active"] } 176 | - Add to frontmatter only: { "files": ["note.md"], "tags": ["project/docs"], "location": "frontmatter" } 177 | - Add to start of content: { "files": ["note.md"], "tags": ["type/meeting"], "location": "content", "position": "start" }`, 178 | schema, 179 | handler: async (args, vaultPath, _vaultName) => { 180 | const result = await addTags( 181 | vaultPath, 182 | args.files, 183 | args.tags, 184 | args.location ?? 'both', 185 | args.normalize ?? true, 186 | args.position ?? 'end' 187 | ); 188 | 189 | return createToolResponse(formatTagResult(result)); 190 | } 191 | }, vaults); 192 | } 193 | ``` -------------------------------------------------------------------------------- /example.ts: -------------------------------------------------------------------------------- ```typescript 1 | // src/types.ts 2 | export interface Tool { 3 | name: string; 4 | description: string; 5 | inputSchema: { 6 | type: string; 7 | properties: Record<string, any>; 8 | required?: string[]; 9 | }; 10 | handler: (args: any) => Promise<{ 11 | content: Array<{ 12 | type: string; 13 | text: string; 14 | }>; 15 | }>; 16 | } 17 | 18 | export interface ToolProvider { 19 | getTools(): Tool[]; 20 | } 21 | 22 | // src/tools/note-tools.ts 23 | import { z } from "zod"; 24 | import { Tool, ToolProvider } from "../types.js"; 25 | import { promises as fs } from "fs"; 26 | import path from "path"; 27 | 28 | const CreateNoteSchema = z.object({ 29 | filename: z.string(), 30 | content: z.string(), 31 | folder: z.string().optional() 32 | }); 33 | 34 | export class NoteTools implements ToolProvider { 35 | constructor(private vaultPath: string) {} 36 | 37 | getTools(): Tool[] { 38 | return [ 39 | { 40 | name: "create-note", 41 | description: "Create a new note in the vault", 42 | inputSchema: { 43 | type: "object", 44 | properties: { 45 | filename: { 46 | type: "string", 47 | description: "Name of the note (with .md extension)" 48 | }, 49 | content: { 50 | type: "string", 51 | description: "Content of the note in markdown format" 52 | }, 53 | folder: { 54 | type: "string", 55 | description: "Optional subfolder path" 56 | } 57 | }, 58 | required: ["filename", "content"] 59 | }, 60 | handler: async (args) => { 61 | const { filename, content, folder } = CreateNoteSchema.parse(args); 62 | const notePath = await this.createNote(filename, content, folder); 63 | return { 64 | content: [ 65 | { 66 | type: "text", 67 | text: `Successfully created note: ${notePath}` 68 | } 69 | ] 70 | }; 71 | } 72 | } 73 | ]; 74 | } 75 | 76 | private async createNote(filename: string, content: string, folder?: string): Promise<string> { 77 | if (!filename.endsWith(".md")) { 78 | filename = `${filename}.md`; 79 | } 80 | 81 | const notePath = folder 82 | ? path.join(this.vaultPath, folder, filename) 83 | : path.join(this.vaultPath, filename); 84 | 85 | const noteDir = path.dirname(notePath); 86 | await fs.mkdir(noteDir, { recursive: true }); 87 | 88 | try { 89 | await fs.access(notePath); 90 | throw new Error("Note already exists"); 91 | } catch (error) { 92 | if (error.code === "ENOENT") { 93 | await fs.writeFile(notePath, content); 94 | return notePath; 95 | } 96 | throw error; 97 | } 98 | } 99 | } 100 | 101 | // src/tools/search-tools.ts 102 | import { z } from "zod"; 103 | import { Tool, ToolProvider } from "../types.js"; 104 | import { promises as fs } from "fs"; 105 | import path from "path"; 106 | 107 | const SearchSchema = z.object({ 108 | query: z.string(), 109 | path: z.string().optional(), 110 | caseSensitive: z.boolean().optional() 111 | }); 112 | 113 | export class SearchTools implements ToolProvider { 114 | constructor(private vaultPath: string) {} 115 | 116 | getTools(): Tool[] { 117 | return [ 118 | { 119 | name: "search-vault", 120 | description: "Search for text across notes", 121 | inputSchema: { 122 | type: "object", 123 | properties: { 124 | query: { 125 | type: "string", 126 | description: "Search query" 127 | }, 128 | path: { 129 | type: "string", 130 | description: "Optional path to limit search scope" 131 | }, 132 | caseSensitive: { 133 | type: "boolean", 134 | description: "Whether to perform case-sensitive search" 135 | } 136 | }, 137 | required: ["query"] 138 | }, 139 | handler: async (args) => { 140 | const { query, path: searchPath, caseSensitive } = SearchSchema.parse(args); 141 | const results = await this.searchVault(query, searchPath, caseSensitive); 142 | return { 143 | content: [ 144 | { 145 | type: "text", 146 | text: this.formatSearchResults(results) 147 | } 148 | ] 149 | }; 150 | } 151 | } 152 | ]; 153 | } 154 | 155 | private async searchVault(query: string, searchPath?: string, caseSensitive = false) { 156 | // Implementation of searchVault method... 157 | } 158 | 159 | private formatSearchResults(results: any[]) { 160 | // Implementation of formatSearchResults method... 161 | } 162 | } 163 | 164 | // src/server.ts 165 | import { Server } from "@modelcontextprotocol/sdk/server/index.js"; 166 | import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; 167 | import { 168 | CallToolRequestSchema, 169 | ListToolsRequestSchema, 170 | } from "@modelcontextprotocol/sdk/types.js"; 171 | import { Tool, ToolProvider } from "./types.js"; 172 | 173 | export class ObsidianServer { 174 | private server: Server; 175 | private tools: Map<string, Tool> = new Map(); 176 | 177 | constructor() { 178 | this.server = new Server( 179 | { 180 | name: "obsidian-vault", 181 | version: "1.0.0" 182 | }, 183 | { 184 | capabilities: { 185 | tools: {} 186 | } 187 | } 188 | ); 189 | 190 | this.setupHandlers(); 191 | } 192 | 193 | registerToolProvider(provider: ToolProvider) { 194 | for (const tool of provider.getTools()) { 195 | this.tools.set(tool.name, tool); 196 | } 197 | } 198 | 199 | private setupHandlers() { 200 | this.server.setRequestHandler(ListToolsRequestSchema, async () => ({ 201 | tools: Array.from(this.tools.values()).map(tool => ({ 202 | name: tool.name, 203 | description: tool.description, 204 | inputSchema: tool.inputSchema 205 | })) 206 | })); 207 | 208 | this.server.setRequestHandler(CallToolRequestSchema, async (request) => { 209 | const { name, arguments: args } = request.params; 210 | const tool = this.tools.get(name); 211 | 212 | if (!tool) { 213 | throw new Error(`Unknown tool: ${name}`); 214 | } 215 | 216 | return tool.handler(args); 217 | }); 218 | } 219 | 220 | async start() { 221 | const transport = new StdioServerTransport(); 222 | await this.server.connect(transport); 223 | console.error("Obsidian MCP Server running on stdio"); 224 | } 225 | } 226 | 227 | // src/main.ts 228 | import { ObsidianServer } from "./server.js"; 229 | import { NoteTools } from "./tools/note-tools.js"; 230 | import { SearchTools } from "./tools/search-tools.js"; 231 | 232 | async function main() { 233 | const vaultPath = process.argv[2]; 234 | if (!vaultPath) { 235 | console.error("Please provide the path to your Obsidian vault"); 236 | process.exit(1); 237 | } 238 | 239 | try { 240 | const server = new ObsidianServer(); 241 | 242 | // Register tool providers 243 | server.registerToolProvider(new NoteTools(vaultPath)); 244 | server.registerToolProvider(new SearchTools(vaultPath)); 245 | 246 | await server.start(); 247 | } catch (error) { 248 | console.error("Fatal error:", error); 249 | process.exit(1); 250 | } 251 | } 252 | 253 | main().catch((error) => { 254 | console.error("Unhandled error:", error); 255 | process.exit(1); 256 | }); ``` -------------------------------------------------------------------------------- /docs/tool-examples.md: -------------------------------------------------------------------------------- ```markdown 1 | # Tool Implementation Examples 2 | 3 | This document provides practical examples of common tool implementation patterns and anti-patterns. 4 | 5 | ## Example 1: File Operation Tool 6 | 7 | ### ✅ Good Implementation 8 | 9 | ```typescript 10 | import { z } from "zod"; 11 | import { Tool, FileOperationResult } from "../../types.js"; 12 | import { validateVaultPath } from "../../utils/path.js"; 13 | import { handleFsError } from "../../utils/errors.js"; 14 | import { createToolResponse, formatFileResult } from "../../utils/responses.js"; 15 | import { createSchemaHandler } from "../../utils/schema.js"; 16 | 17 | const schema = z.object({ 18 | path: z.string() 19 | .min(1, "Path cannot be empty") 20 | .refine(path => !path.includes('..'), "Path cannot contain '..'") 21 | .describe("Path to the file relative to vault root"), 22 | content: z.string() 23 | .min(1, "Content cannot be empty") 24 | .describe("File content to write") 25 | }).strict(); 26 | 27 | const schemaHandler = createSchemaHandler(schema); 28 | 29 | async function writeFile( 30 | vaultPath: string, 31 | filePath: string, 32 | content: string 33 | ): Promise<FileOperationResult> { 34 | const fullPath = path.join(vaultPath, filePath); 35 | validateVaultPath(vaultPath, fullPath); 36 | 37 | try { 38 | await ensureDirectory(path.dirname(fullPath)); 39 | await fs.writeFile(fullPath, content, 'utf8'); 40 | 41 | return { 42 | success: true, 43 | message: "File written successfully", 44 | path: fullPath, 45 | operation: 'create' 46 | }; 47 | } catch (error) { 48 | throw handleFsError(error, 'write file'); 49 | } 50 | } 51 | 52 | export function createWriteFileTool(vaultPath: string): Tool { 53 | if (!vaultPath) { 54 | throw new Error("Vault path is required"); 55 | } 56 | 57 | return { 58 | name: "write-file", 59 | description: "Write content to a file in the vault", 60 | inputSchema: schemaHandler, 61 | handler: async (args) => { 62 | const validated = schemaHandler.parse(args); 63 | const result = await writeFile(vaultPath, validated.path, validated.content); 64 | return createToolResponse(formatFileResult(result)); 65 | } 66 | }; 67 | } 68 | ``` 69 | 70 | ### ❌ Bad Implementation 71 | 72 | ```typescript 73 | // Anti-pattern example 74 | export function createBadWriteFileTool(vaultPath: string): Tool { 75 | return { 76 | name: "write-file", 77 | description: "Writes a file", // Too vague 78 | inputSchema: { 79 | // Missing proper schema handler 80 | jsonSchema: { 81 | type: "object", 82 | properties: { 83 | path: { type: "string" }, 84 | content: { type: "string" } 85 | } 86 | }, 87 | parse: (input: any) => input // No validation! 88 | }, 89 | handler: async (args) => { 90 | try { 91 | // Missing path validation 92 | const filePath = path.join(vaultPath, args.path); 93 | 94 | // Direct fs operations without proper error handling 95 | await fs.writeFile(filePath, args.content); 96 | 97 | // Poor response formatting 98 | return createToolResponse("File written"); 99 | } catch (error) { 100 | // Bad error handling 101 | return createToolResponse(`Error: ${error}`); 102 | } 103 | } 104 | }; 105 | } 106 | ``` 107 | 108 | ## Example 2: Search Tool 109 | 110 | ### ✅ Good Implementation 111 | 112 | ```typescript 113 | const schema = z.object({ 114 | query: z.string() 115 | .min(1, "Search query cannot be empty") 116 | .describe("Text to search for"), 117 | caseSensitive: z.boolean() 118 | .optional() 119 | .describe("Whether to perform case-sensitive search"), 120 | path: z.string() 121 | .optional() 122 | .describe("Optional subfolder to limit search scope") 123 | }).strict(); 124 | 125 | const schemaHandler = createSchemaHandler(schema); 126 | 127 | async function searchFiles( 128 | vaultPath: string, 129 | query: string, 130 | options: SearchOptions 131 | ): Promise<SearchOperationResult> { 132 | try { 133 | const searchPath = options.path 134 | ? path.join(vaultPath, options.path) 135 | : vaultPath; 136 | 137 | validateVaultPath(vaultPath, searchPath); 138 | 139 | // Implementation details... 140 | 141 | return { 142 | success: true, 143 | message: "Search completed", 144 | results: matches, 145 | totalMatches: totalCount, 146 | matchedFiles: fileCount 147 | }; 148 | } catch (error) { 149 | throw handleFsError(error, 'search files'); 150 | } 151 | } 152 | 153 | export function createSearchTool(vaultPath: string): Tool { 154 | if (!vaultPath) { 155 | throw new Error("Vault path is required"); 156 | } 157 | 158 | return { 159 | name: "search-files", 160 | description: "Search for text in vault files", 161 | inputSchema: schemaHandler, 162 | handler: async (args) => { 163 | const validated = schemaHandler.parse(args); 164 | const result = await searchFiles(vaultPath, validated.query, { 165 | caseSensitive: validated.caseSensitive, 166 | path: validated.path 167 | }); 168 | return createToolResponse(formatSearchResult(result)); 169 | } 170 | }; 171 | } 172 | ``` 173 | 174 | ### ❌ Bad Implementation 175 | 176 | ```typescript 177 | // Anti-pattern example 178 | export function createBadSearchTool(vaultPath: string): Tool { 179 | return { 180 | name: "search", 181 | description: "Searches files", 182 | inputSchema: { 183 | jsonSchema: { 184 | type: "object", 185 | properties: { 186 | query: { type: "string" } 187 | } 188 | }, 189 | parse: (input: any) => input 190 | }, 191 | handler: async (args) => { 192 | // Bad: Recursive search without limits 193 | async function searchDir(dir: string): Promise<string[]> { 194 | const results: string[] = []; 195 | const files = await fs.readdir(dir); 196 | 197 | for (const file of files) { 198 | const fullPath = path.join(dir, file); 199 | const stat = await fs.stat(fullPath); 200 | 201 | if (stat.isDirectory()) { 202 | results.push(...await searchDir(fullPath)); 203 | } else { 204 | const content = await fs.readFile(fullPath, 'utf8'); 205 | if (content.includes(args.query)) { 206 | results.push(fullPath); 207 | } 208 | } 209 | } 210 | 211 | return results; 212 | } 213 | 214 | try { 215 | const matches = await searchDir(vaultPath); 216 | // Poor response formatting 217 | return createToolResponse( 218 | `Found matches in:\n${matches.join('\n')}` 219 | ); 220 | } catch (error) { 221 | return createToolResponse(`Search failed: ${error}`); 222 | } 223 | } 224 | }; 225 | } 226 | ``` 227 | 228 | ## Common Anti-Patterns to Avoid 229 | 230 | 1. **Poor Error Handling** 231 | ```typescript 232 | // ❌ Bad 233 | catch (error) { 234 | return createToolResponse(`Error: ${error}`); 235 | } 236 | 237 | // ✅ Good 238 | catch (error) { 239 | if (error instanceof McpError) { 240 | throw error; 241 | } 242 | throw handleFsError(error, 'operation name'); 243 | } 244 | ``` 245 | 246 | 2. **Missing Input Validation** 247 | ```typescript 248 | // ❌ Bad 249 | const input = args as { path: string }; 250 | 251 | // ✅ Good 252 | const validated = schemaHandler.parse(args); 253 | ``` 254 | 255 | 3. **Unsafe Path Operations** 256 | ```typescript 257 | // ❌ Bad 258 | const fullPath = path.join(vaultPath, args.path); 259 | 260 | // ✅ Good 261 | const fullPath = path.join(vaultPath, validated.path); 262 | validateVaultPath(vaultPath, fullPath); 263 | ``` 264 | 265 | 4. **Poor Response Formatting** 266 | ```typescript 267 | // ❌ Bad 268 | return createToolResponse(JSON.stringify(result)); 269 | 270 | // ✅ Good 271 | return createToolResponse(formatOperationResult(result)); 272 | ``` 273 | 274 | 5. **Direct File System Operations** 275 | ```typescript 276 | // ❌ Bad 277 | await fs.writeFile(path, content); 278 | 279 | // ✅ Good 280 | await ensureDirectory(path.dirname(fullPath)); 281 | await fs.writeFile(fullPath, content, 'utf8'); 282 | ``` 283 | 284 | Remember: 285 | - Always use utility functions for common operations 286 | - Validate all inputs thoroughly 287 | - Handle errors appropriately 288 | - Format responses consistently 289 | - Follow the established patterns in the codebase 290 | ``` -------------------------------------------------------------------------------- /src/tools/edit-note/index.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { z } from "zod"; 2 | import { FileOperationResult } from "../../types.js"; 3 | import { promises as fs } from "fs"; 4 | import path from "path"; 5 | import { McpError, ErrorCode } from "@modelcontextprotocol/sdk/types.js"; 6 | import { ensureMarkdownExtension, validateVaultPath } from "../../utils/path.js"; 7 | import { fileExists } from "../../utils/files.js"; 8 | import { createNoteNotFoundError, handleFsError } from "../../utils/errors.js"; 9 | import { createToolResponse, formatFileResult } from "../../utils/responses.js"; 10 | import { createTool } from "../../utils/tool-factory.js"; 11 | 12 | // Input validation schema with descriptions 13 | // Schema for delete operation 14 | const deleteSchema = z.object({ 15 | vault: z.string() 16 | .min(1, "Vault name cannot be empty") 17 | .describe("Name of the vault containing the note"), 18 | filename: z.string() 19 | .min(1, "Filename cannot be empty") 20 | .refine(name => !name.includes('/') && !name.includes('\\'), 21 | "Filename cannot contain path separators - use the 'folder' parameter for paths instead") 22 | .describe("Just the note name without any path separators (e.g. 'my-note.md', NOT 'folder/my-note.md')"), 23 | folder: z.string() 24 | .optional() 25 | .refine(folder => !folder || !path.isAbsolute(folder), 26 | "Folder must be a relative path") 27 | .describe("Optional subfolder path relative to vault root"), 28 | operation: z.literal('delete') 29 | .describe("Delete operation"), 30 | content: z.undefined() 31 | .describe("Must not provide content for delete operation") 32 | }).strict(); 33 | 34 | // Schema for non-delete operations 35 | const editSchema = z.object({ 36 | vault: z.string() 37 | .min(1, "Vault name cannot be empty") 38 | .describe("Name of the vault containing the note"), 39 | filename: z.string() 40 | .min(1, "Filename cannot be empty") 41 | .refine(name => !name.includes('/') && !name.includes('\\'), 42 | "Filename cannot contain path separators - use the 'folder' parameter for paths instead") 43 | .describe("Just the note name without any path separators (e.g. 'my-note.md', NOT 'folder/my-note.md')"), 44 | folder: z.string() 45 | .optional() 46 | .refine(folder => !folder || !path.isAbsolute(folder), 47 | "Folder must be a relative path") 48 | .describe("Optional subfolder path relative to vault root"), 49 | operation: z.enum(['append', 'prepend', 'replace']) 50 | .describe("Type of edit operation - must be one of: 'append', 'prepend', 'replace'") 51 | .refine( 52 | (op) => ['append', 'prepend', 'replace'].includes(op), 53 | { 54 | message: "Invalid operation. Must be one of: 'append', 'prepend', 'replace'", 55 | path: ['operation'] 56 | } 57 | ), 58 | content: z.string() 59 | .min(1, "Content cannot be empty for non-delete operations") 60 | .describe("New content to add/prepend/replace") 61 | }).strict(); 62 | 63 | // Combined schema using discriminated union 64 | const schema = z.discriminatedUnion('operation', [deleteSchema, editSchema]); 65 | 66 | // Types 67 | type EditOperation = 'append' | 'prepend' | 'replace' | 'delete'; 68 | 69 | async function editNote( 70 | vaultPath: string, 71 | filename: string, 72 | operation: EditOperation, 73 | content?: string, 74 | folder?: string 75 | ): Promise<FileOperationResult> { 76 | const sanitizedFilename = ensureMarkdownExtension(filename); 77 | const fullPath = folder 78 | ? path.join(vaultPath, folder, sanitizedFilename) 79 | : path.join(vaultPath, sanitizedFilename); 80 | 81 | // Validate path is within vault 82 | validateVaultPath(vaultPath, fullPath); 83 | 84 | // Create unique backup filename 85 | const timestamp = Date.now(); 86 | const backupPath = `${fullPath}.${timestamp}.backup`; 87 | 88 | try { 89 | // For non-delete operations, create backup first 90 | if (operation !== 'delete' && await fileExists(fullPath)) { 91 | await fs.copyFile(fullPath, backupPath); 92 | } 93 | 94 | switch (operation) { 95 | case 'delete': { 96 | if (!await fileExists(fullPath)) { 97 | throw createNoteNotFoundError(filename); 98 | } 99 | // For delete, create backup before deleting 100 | await fs.copyFile(fullPath, backupPath); 101 | await fs.unlink(fullPath); 102 | 103 | // On successful delete, remove backup after a short delay 104 | // This gives a small window for potential recovery if needed 105 | setTimeout(async () => { 106 | try { 107 | await fs.unlink(backupPath); 108 | } catch (error: unknown) { 109 | const errorMessage = error instanceof Error ? error.message : String(error); 110 | console.error('Failed to cleanup backup file:', errorMessage); 111 | } 112 | }, 5000); 113 | 114 | return { 115 | success: true, 116 | message: "Note deleted successfully", 117 | path: fullPath, 118 | operation: 'delete' 119 | }; 120 | } 121 | 122 | case 'append': 123 | case 'prepend': 124 | case 'replace': { 125 | // Check if file exists for non-delete operations 126 | if (!await fileExists(fullPath)) { 127 | throw createNoteNotFoundError(filename); 128 | } 129 | 130 | try { 131 | // Read existing content 132 | const existingContent = await fs.readFile(fullPath, "utf-8"); 133 | 134 | // Prepare new content based on operation 135 | let newContent: string; 136 | if (operation === 'append') { 137 | newContent = existingContent.trim() + (existingContent.trim() ? '\n\n' : '') + content; 138 | } else if (operation === 'prepend') { 139 | newContent = content + (existingContent.trim() ? '\n\n' : '') + existingContent.trim(); 140 | } else { 141 | // replace 142 | newContent = content as string; 143 | } 144 | 145 | // Write the new content 146 | await fs.writeFile(fullPath, newContent); 147 | 148 | // Clean up backup on success 149 | await fs.unlink(backupPath); 150 | 151 | return { 152 | success: true, 153 | message: `Note ${operation}ed successfully`, 154 | path: fullPath, 155 | operation: 'edit' 156 | }; 157 | } catch (error: unknown) { 158 | // On error, attempt to restore from backup 159 | if (await fileExists(backupPath)) { 160 | try { 161 | await fs.copyFile(backupPath, fullPath); 162 | await fs.unlink(backupPath); 163 | } catch (rollbackError: unknown) { 164 | const errorMessage = error instanceof Error ? error.message : String(error); 165 | const rollbackErrorMessage = rollbackError instanceof Error ? rollbackError.message : String(rollbackError); 166 | 167 | throw new McpError( 168 | ErrorCode.InternalError, 169 | `Failed to rollback changes. Original error: ${errorMessage}. Rollback error: ${rollbackErrorMessage}. Backup file preserved at ${backupPath}` 170 | ); 171 | } 172 | } 173 | throw error; 174 | } 175 | } 176 | 177 | default: { 178 | const _exhaustiveCheck: never = operation; 179 | throw new McpError( 180 | ErrorCode.InvalidParams, 181 | `Invalid operation: ${operation}` 182 | ); 183 | } 184 | } 185 | } catch (error: unknown) { 186 | // If we have a backup and haven't handled the error yet, try to restore 187 | if (await fileExists(backupPath)) { 188 | try { 189 | await fs.copyFile(backupPath, fullPath); 190 | await fs.unlink(backupPath); 191 | } catch (rollbackError: unknown) { 192 | const rollbackErrorMessage = rollbackError instanceof Error ? rollbackError.message : String(rollbackError); 193 | console.error('Failed to cleanup/restore backup during error handling:', rollbackErrorMessage); 194 | } 195 | } 196 | 197 | if (error instanceof McpError) { 198 | throw error; 199 | } 200 | throw handleFsError(error, `${operation} note`); 201 | } 202 | } 203 | 204 | type EditNoteArgs = z.infer<typeof schema>; 205 | 206 | export function createEditNoteTool(vaults: Map<string, string>) { 207 | return createTool<EditNoteArgs>({ 208 | name: "edit-note", 209 | description: `Edit an existing note in the specified vault. 210 | 211 | There is a limited and discrete list of supported operations: 212 | - append: Appends content to the end of the note 213 | - prepend: Prepends content to the beginning of the note 214 | - replace: Replaces the entire content of the note 215 | 216 | Examples: 217 | - Root note: { "vault": "vault1", "filename": "note.md", "operation": "append", "content": "new content" } 218 | - Subfolder note: { "vault": "vault2", "filename": "note.md", "folder": "journal/2024", "operation": "append", "content": "new content" } 219 | - INCORRECT: { "filename": "journal/2024/note.md" } (don't put path in filename)`, 220 | schema, 221 | handler: async (args, vaultPath, _vaultName) => { 222 | const result = await editNote( 223 | vaultPath, 224 | args.filename, 225 | args.operation, 226 | 'content' in args ? args.content : undefined, 227 | args.folder 228 | ); 229 | return createToolResponse(formatFileResult(result)); 230 | } 231 | }, vaults); 232 | } 233 | ``` -------------------------------------------------------------------------------- /src/tools/search-vault/index.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { z } from "zod"; 2 | import { SearchResult, SearchOperationResult, SearchOptions } from "../../types.js"; 3 | import { promises as fs } from "fs"; 4 | import path from "path"; 5 | import { McpError, ErrorCode } from "@modelcontextprotocol/sdk/types.js"; 6 | import { validateVaultPath, safeJoinPath, normalizePath } from "../../utils/path.js"; 7 | import { getAllMarkdownFiles } from "../../utils/files.js"; 8 | import { handleFsError } from "../../utils/errors.js"; 9 | import { extractTags, normalizeTag, matchesTagPattern } from "../../utils/tags.js"; 10 | import { createToolResponse, formatSearchResult } from "../../utils/responses.js"; 11 | import { createTool } from "../../utils/tool-factory.js"; 12 | 13 | // Input validation schema with descriptions 14 | const schema = z.object({ 15 | vault: z.string() 16 | .min(1, "Vault name cannot be empty") 17 | .describe("Name of the vault to search in"), 18 | query: z.string() 19 | .min(1, "Search query cannot be empty") 20 | .describe("Search query (required). For text search use the term directly, for tag search use tag: prefix"), 21 | path: z.string() 22 | .optional() 23 | .describe("Optional subfolder path within the vault to limit search scope"), 24 | caseSensitive: z.boolean() 25 | .optional() 26 | .default(false) 27 | .describe("Whether to perform case-sensitive search (default: false)"), 28 | searchType: z.enum(['content', 'filename', 'both']) 29 | .optional() 30 | .default('content') 31 | .describe("Type of search to perform (default: content)") 32 | }).strict(); 33 | 34 | type SearchVaultInput = z.infer<typeof schema>; 35 | 36 | // Helper functions 37 | function isTagSearch(query: string): boolean { 38 | return query.startsWith('tag:'); 39 | } 40 | 41 | function normalizeTagQuery(query: string): string { 42 | // Remove 'tag:' prefix 43 | return normalizeTag(query.slice(4)); 44 | } 45 | 46 | async function searchFilenames( 47 | vaultPath: string, 48 | query: string, 49 | options: SearchOptions 50 | ): Promise<SearchResult[]> { 51 | try { 52 | // Use safeJoinPath for path safety 53 | const searchDir = options.path ? safeJoinPath(vaultPath, options.path) : vaultPath; 54 | const files = await getAllMarkdownFiles(vaultPath, searchDir); 55 | const results: SearchResult[] = []; 56 | const searchQuery = options.caseSensitive ? query : query.toLowerCase(); 57 | 58 | for (const file of files) { 59 | const relativePath = path.relative(vaultPath, file); 60 | const searchTarget = options.caseSensitive ? relativePath : relativePath.toLowerCase(); 61 | 62 | if (searchTarget.includes(searchQuery)) { 63 | results.push({ 64 | file: relativePath, 65 | matches: [{ 66 | line: 0, // We use 0 to indicate this is a filename match 67 | text: `Filename match: ${relativePath}` 68 | }] 69 | }); 70 | } 71 | } 72 | 73 | return results; 74 | } catch (error) { 75 | if (error instanceof McpError) throw error; 76 | throw handleFsError(error, 'search filenames'); 77 | } 78 | } 79 | 80 | async function searchContent( 81 | vaultPath: string, 82 | query: string, 83 | options: SearchOptions 84 | ): Promise<SearchResult[]> { 85 | try { 86 | // Use safeJoinPath for path safety 87 | const searchDir = options.path ? safeJoinPath(vaultPath, options.path) : vaultPath; 88 | const files = await getAllMarkdownFiles(vaultPath, searchDir); 89 | const results: SearchResult[] = []; 90 | const isTagSearchQuery = isTagSearch(query); 91 | const normalizedTagQuery = isTagSearchQuery ? normalizeTagQuery(query) : ''; 92 | 93 | for (const file of files) { 94 | try { 95 | const content = await fs.readFile(file, "utf-8"); 96 | const lines = content.split("\n"); 97 | const matches: SearchResult["matches"] = []; 98 | 99 | if (isTagSearchQuery) { 100 | // For tag searches, extract all tags from the content 101 | const fileTags = extractTags(content); 102 | 103 | lines.forEach((line, index) => { 104 | // Look for tag matches in each line 105 | const lineTags = extractTags(line); 106 | const hasMatchingTag = lineTags.some(tag => { 107 | const normalizedTag = normalizeTag(tag); 108 | return normalizedTag === normalizedTagQuery || matchesTagPattern(normalizedTagQuery, normalizedTag); 109 | }); 110 | 111 | if (hasMatchingTag) { 112 | matches.push({ 113 | line: index + 1, 114 | text: line.trim() 115 | }); 116 | } 117 | }); 118 | } else { 119 | // Regular text search 120 | const searchQuery = options.caseSensitive ? query : query.toLowerCase(); 121 | 122 | lines.forEach((line, index) => { 123 | const searchLine = options.caseSensitive ? line : line.toLowerCase(); 124 | if (searchLine.includes(searchQuery)) { 125 | matches.push({ 126 | line: index + 1, 127 | text: line.trim() 128 | }); 129 | } 130 | }); 131 | } 132 | 133 | if (matches.length > 0) { 134 | results.push({ 135 | file: path.relative(vaultPath, file), 136 | matches 137 | }); 138 | } 139 | } catch (err) { 140 | console.error(`Error reading file ${file}:`, err); 141 | // Continue with other files 142 | } 143 | } 144 | 145 | return results; 146 | } catch (error) { 147 | if (error instanceof McpError) throw error; 148 | throw handleFsError(error, 'search content'); 149 | } 150 | } 151 | 152 | async function searchVault( 153 | vaultPath: string, 154 | query: string, 155 | options: SearchOptions 156 | ): Promise<SearchOperationResult> { 157 | try { 158 | // Normalize vault path upfront 159 | const normalizedVaultPath = normalizePath(vaultPath); 160 | let results: SearchResult[] = []; 161 | let errors: string[] = []; 162 | 163 | if (options.searchType === 'filename' || options.searchType === 'both') { 164 | try { 165 | const filenameResults = await searchFilenames(normalizedVaultPath, query, options); 166 | results = results.concat(filenameResults); 167 | } catch (error) { 168 | if (error instanceof McpError) { 169 | errors.push(`Filename search error: ${error.message}`); 170 | } else { 171 | errors.push(`Filename search failed: ${error instanceof Error ? error.message : String(error)}`); 172 | } 173 | } 174 | } 175 | 176 | if (options.searchType === 'content' || options.searchType === 'both') { 177 | try { 178 | const contentResults = await searchContent(normalizedVaultPath, query, options); 179 | results = results.concat(contentResults); 180 | } catch (error) { 181 | if (error instanceof McpError) { 182 | errors.push(`Content search error: ${error.message}`); 183 | } else { 184 | errors.push(`Content search failed: ${error instanceof Error ? error.message : String(error)}`); 185 | } 186 | } 187 | } 188 | 189 | const totalMatches = results.reduce((sum, result) => sum + (result.matches?.length ?? 0), 0); 190 | 191 | // If we have some results but also errors, we'll return partial results with a warning 192 | if (results.length > 0 && errors.length > 0) { 193 | return { 194 | success: true, 195 | message: `Search completed with warnings:\n${errors.join('\n')}`, 196 | results, 197 | totalMatches, 198 | matchedFiles: results.length 199 | }; 200 | } 201 | 202 | // If we have no results and errors, throw an error 203 | if (results.length === 0 && errors.length > 0) { 204 | throw new McpError( 205 | ErrorCode.InternalError, 206 | `Search failed:\n${errors.join('\n')}` 207 | ); 208 | } 209 | 210 | return { 211 | success: true, 212 | message: "Search completed successfully", 213 | results, 214 | totalMatches, 215 | matchedFiles: results.length 216 | }; 217 | } catch (error) { 218 | if (error instanceof McpError) { 219 | throw error; 220 | } 221 | throw handleFsError(error, 'search vault'); 222 | } 223 | } 224 | 225 | export const createSearchVaultTool = (vaults: Map<string, string>) => { 226 | return createTool<SearchVaultInput>({ 227 | name: "search-vault", 228 | description: `Search for specific content within vault notes (NOT for listing available vaults - use the list-vaults prompt for that). 229 | 230 | This tool searches through note contents and filenames for specific text or tags: 231 | - Content search: { "vault": "vault1", "query": "hello world", "searchType": "content" } 232 | - Filename search: { "vault": "vault2", "query": "meeting-notes", "searchType": "filename" } 233 | - Search both: { "vault": "vault1", "query": "project", "searchType": "both" } 234 | - Tag search: { "vault": "vault2", "query": "tag:status/active" } 235 | - Search in subfolder: { "vault": "vault1", "query": "hello", "path": "journal/2024" } 236 | 237 | Note: To get a list of available vaults, use the list-vaults prompt instead of this search tool.`, 238 | schema, 239 | handler: async (args, vaultPath, _vaultName) => { 240 | const options: SearchOptions = { 241 | path: args.path, 242 | caseSensitive: args.caseSensitive, 243 | searchType: args.searchType 244 | }; 245 | const result = await searchVault(vaultPath, args.query, options); 246 | return createToolResponse(formatSearchResult(result)); 247 | } 248 | }, vaults); 249 | } 250 | ``` -------------------------------------------------------------------------------- /src/server.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { Server } from "@modelcontextprotocol/sdk/server/index.js"; 2 | import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; 3 | import { 4 | CallToolRequestSchema, 5 | ListToolsRequestSchema, 6 | ListResourcesRequestSchema, 7 | ReadResourceRequestSchema, 8 | ListPromptsRequestSchema, 9 | GetPromptRequestSchema, 10 | McpError, 11 | ErrorCode 12 | } from "@modelcontextprotocol/sdk/types.js"; 13 | import { RateLimiter, ConnectionMonitor, validateMessageSize } from "./utils/security.js"; 14 | import { Tool } from "./types.js"; 15 | import { z } from "zod"; 16 | import path from "path"; 17 | import os from 'os'; 18 | import fs from 'fs'; 19 | import { 20 | listVaultResources, 21 | readVaultResource 22 | } from "./resources/resources.js"; 23 | import { listPrompts, getPrompt, registerPrompt } from "./utils/prompt-factory.js"; 24 | import { listVaultsPrompt } from "./prompts/list-vaults/index.js"; 25 | 26 | // Utility function to expand home directory 27 | function expandHome(filepath: string): string { 28 | if (filepath.startsWith('~/') || filepath === '~') { 29 | return path.join(os.homedir(), filepath.slice(1)); 30 | } 31 | return filepath; 32 | } 33 | 34 | export class ObsidianServer { 35 | private server: Server; 36 | private tools: Map<string, Tool<any>> = new Map(); 37 | private vaults: Map<string, string> = new Map(); 38 | private rateLimiter: RateLimiter; 39 | private connectionMonitor: ConnectionMonitor; 40 | 41 | constructor(vaultConfigs: { name: string; path: string }[]) { 42 | if (!vaultConfigs || vaultConfigs.length === 0) { 43 | throw new McpError( 44 | ErrorCode.InvalidRequest, 45 | 'No vault configurations provided. At least one valid Obsidian vault is required.' 46 | ); 47 | } 48 | 49 | // Initialize vaults 50 | vaultConfigs.forEach(config => { 51 | const expandedPath = expandHome(config.path); 52 | const resolvedPath = path.resolve(expandedPath); 53 | 54 | // Check if .obsidian directory exists 55 | const obsidianConfigPath = path.join(resolvedPath, '.obsidian'); 56 | try { 57 | const stats = fs.statSync(obsidianConfigPath); 58 | if (!stats.isDirectory()) { 59 | throw new McpError( 60 | ErrorCode.InvalidRequest, 61 | `Invalid Obsidian vault at ${config.path}: .obsidian exists but is not a directory` 62 | ); 63 | } 64 | } catch (error) { 65 | if ((error as NodeJS.ErrnoException).code === 'ENOENT') { 66 | throw new McpError( 67 | ErrorCode.InvalidRequest, 68 | `Invalid Obsidian vault at ${config.path}: Missing .obsidian directory. Please open this folder in Obsidian first to initialize it.` 69 | ); 70 | } 71 | throw new McpError( 72 | ErrorCode.InvalidRequest, 73 | `Error accessing vault at ${config.path}: ${(error as Error).message}` 74 | ); 75 | } 76 | 77 | this.vaults.set(config.name, resolvedPath); 78 | }); 79 | this.server = new Server( 80 | { 81 | name: "obsidian-mcp", 82 | version: "1.0.6" 83 | }, 84 | { 85 | capabilities: { 86 | resources: {}, 87 | tools: {}, 88 | prompts: {} 89 | } 90 | } 91 | ); 92 | 93 | // Initialize security features 94 | this.rateLimiter = new RateLimiter(); 95 | this.connectionMonitor = new ConnectionMonitor(); 96 | 97 | // Register prompts 98 | registerPrompt(listVaultsPrompt); 99 | 100 | this.setupHandlers(); 101 | 102 | // Setup connection monitoring with grace period for initialization 103 | this.connectionMonitor.start(() => { 104 | this.server.close(); 105 | }); 106 | 107 | // Update activity during initialization 108 | this.connectionMonitor.updateActivity(); 109 | 110 | // Setup error handler 111 | this.server.onerror = (error) => { 112 | console.error("Server error:", error); 113 | }; 114 | } 115 | 116 | registerTool<T>(tool: Tool<T>) { 117 | console.error(`Registering tool: ${tool.name}`); 118 | this.tools.set(tool.name, tool); 119 | console.error(`Current tools: ${Array.from(this.tools.keys()).join(', ')}`); 120 | } 121 | 122 | private validateRequest(request: any) { 123 | try { 124 | // Validate message size 125 | validateMessageSize(request); 126 | 127 | // Update connection activity 128 | this.connectionMonitor.updateActivity(); 129 | 130 | // Check rate limit (using method name as client id for basic implementation) 131 | if (!this.rateLimiter.checkLimit(request.method)) { 132 | throw new McpError(ErrorCode.InvalidRequest, "Rate limit exceeded"); 133 | } 134 | } catch (error) { 135 | console.error("Request validation failed:", error); 136 | throw error; 137 | } 138 | } 139 | 140 | private setupHandlers() { 141 | // List available prompts 142 | this.server.setRequestHandler(ListPromptsRequestSchema, async (request) => { 143 | this.validateRequest(request); 144 | return listPrompts(); 145 | }); 146 | 147 | // Get specific prompt 148 | this.server.setRequestHandler(GetPromptRequestSchema, async (request) => { 149 | this.validateRequest(request); 150 | const { name, arguments: args } = request.params; 151 | 152 | if (!name || typeof name !== 'string') { 153 | throw new McpError(ErrorCode.InvalidParams, "Missing or invalid prompt name"); 154 | } 155 | 156 | const result = await getPrompt(name, this.vaults, args); 157 | return { 158 | ...result, 159 | _meta: { 160 | promptName: name, 161 | timestamp: new Date().toISOString() 162 | } 163 | }; 164 | }); 165 | 166 | // List available tools 167 | this.server.setRequestHandler(ListToolsRequestSchema, async (request) => { 168 | this.validateRequest(request); 169 | return { 170 | tools: Array.from(this.tools.values()).map(tool => ({ 171 | name: tool.name, 172 | description: tool.description, 173 | inputSchema: tool.inputSchema.jsonSchema 174 | })) 175 | }; 176 | }); 177 | 178 | // List available resources 179 | this.server.setRequestHandler(ListResourcesRequestSchema, async (request) => { 180 | this.validateRequest(request); 181 | const resources = await listVaultResources(this.vaults); 182 | return { 183 | resources, 184 | resourceTemplates: [] 185 | }; 186 | }); 187 | 188 | // Read resource content 189 | this.server.setRequestHandler(ReadResourceRequestSchema, async (request) => { 190 | this.validateRequest(request); 191 | const uri = request.params?.uri; 192 | if (!uri || typeof uri !== 'string') { 193 | throw new McpError(ErrorCode.InvalidParams, "Missing or invalid URI parameter"); 194 | } 195 | 196 | if (!uri.startsWith('obsidian-vault://')) { 197 | throw new McpError(ErrorCode.InvalidParams, "Invalid URI format. Only vault resources are supported."); 198 | } 199 | 200 | return { 201 | contents: [await readVaultResource(this.vaults, uri)] 202 | }; 203 | }); 204 | 205 | this.server.setRequestHandler(CallToolRequestSchema, async (request, extra) => { 206 | this.validateRequest(request); 207 | const params = request.params; 208 | if (!params || typeof params !== 'object') { 209 | throw new McpError(ErrorCode.InvalidParams, "Invalid request parameters"); 210 | } 211 | 212 | const name = params.name; 213 | const args = params.arguments; 214 | 215 | if (!name || typeof name !== 'string') { 216 | throw new McpError(ErrorCode.InvalidParams, "Missing or invalid tool name"); 217 | } 218 | 219 | const tool = this.tools.get(name); 220 | if (!tool) { 221 | throw new McpError(ErrorCode.MethodNotFound, `Unknown tool: ${name}`); 222 | } 223 | 224 | try { 225 | // Validate and transform arguments using tool's schema handler 226 | const validatedArgs = tool.inputSchema.parse(args); 227 | 228 | // Execute tool with validated arguments 229 | const result = await tool.handler(validatedArgs); 230 | 231 | return { 232 | _meta: { 233 | toolName: name, 234 | timestamp: new Date().toISOString(), 235 | success: true 236 | }, 237 | content: result.content 238 | }; 239 | } catch (error: unknown) { 240 | if (error instanceof z.ZodError) { 241 | const formattedErrors = error.errors.map(e => { 242 | const path = e.path.join("."); 243 | const message = e.message; 244 | return `${path ? path + ': ' : ''}${message}`; 245 | }).join("\n"); 246 | 247 | throw new McpError( 248 | ErrorCode.InvalidParams, 249 | `Invalid arguments:\n${formattedErrors}` 250 | ); 251 | } 252 | 253 | // Enhance error reporting 254 | if (error instanceof McpError) { 255 | throw error; 256 | } 257 | 258 | // Convert unknown errors to McpError with helpful message 259 | throw new McpError( 260 | ErrorCode.InternalError, 261 | `Tool execution failed: ${error instanceof Error ? error.message : String(error)}` 262 | ); 263 | } 264 | }); 265 | } 266 | 267 | async start() { 268 | const transport = new StdioServerTransport(); 269 | await this.server.connect(transport); 270 | console.error("Obsidian MCP Server running on stdio"); 271 | } 272 | 273 | async stop() { 274 | this.connectionMonitor.stop(); 275 | await this.server.close(); 276 | console.error("Obsidian MCP Server stopped"); 277 | } 278 | } 279 | ``` -------------------------------------------------------------------------------- /docs/creating-tools.md: -------------------------------------------------------------------------------- ```markdown 1 | # Creating New Tools Guide 2 | 3 | This guide explains how to create new tools that integrate seamlessly with the existing codebase while following established patterns and best practices. 4 | 5 | ## Tool Structure Overview 6 | 7 | Every tool follows a consistent structure: 8 | 9 | 1. Input validation using Zod schemas 10 | 2. Core functionality implementation 11 | 3. Tool factory function that creates the tool interface 12 | 4. Standardized error handling and responses 13 | 14 | ## Step-by-Step Implementation Guide 15 | 16 | ### 1. Create the Tool Directory 17 | 18 | Create a new directory under `src/tools/` with your tool name: 19 | 20 | ```bash 21 | src/tools/your-tool-name/ 22 | └── index.ts 23 | ``` 24 | 25 | ### 2. Define the Input Schema 26 | 27 | Start by defining a Zod schema for input validation. Always include descriptions for better documentation: 28 | 29 | ```typescript 30 | const schema = z.object({ 31 | param1: z.string() 32 | .min(1, "Parameter cannot be empty") 33 | .describe("Description of what this parameter does"), 34 | param2: z.number() 35 | .min(0) 36 | .describe("Description of numeric constraints"), 37 | optionalParam: z.string() 38 | .optional() 39 | .describe("Optional parameters should have clear descriptions too") 40 | }).strict(); 41 | 42 | const schemaHandler = createSchemaHandler(schema); 43 | ``` 44 | 45 | ### 3. Implement Core Functionality 46 | 47 | Create a private async function that implements the tool's core logic: 48 | 49 | ```typescript 50 | async function performOperation( 51 | vaultPath: string, 52 | param1: string, 53 | param2: number, 54 | optionalParam?: string 55 | ): Promise<OperationResult> { 56 | try { 57 | // Implement core functionality 58 | // Use utility functions for common operations 59 | // Handle errors appropriately 60 | return { 61 | success: true, 62 | message: "Operation completed successfully", 63 | // Include relevant details 64 | }; 65 | } catch (error) { 66 | if (error instanceof McpError) { 67 | throw error; 68 | } 69 | throw handleFsError(error, 'operation name'); 70 | } 71 | } 72 | ``` 73 | 74 | ### 4. Create the Tool Factory 75 | 76 | Export a factory function that creates the tool interface: 77 | 78 | ```typescript 79 | export function createYourTool(vaultPath: string): Tool { 80 | if (!vaultPath) { 81 | throw new Error("Vault path is required"); 82 | } 83 | 84 | return { 85 | name: "your-tool-name", 86 | description: `Clear description of what the tool does. 87 | 88 | Examples: 89 | - Basic usage: { "param1": "value", "param2": 42 } 90 | - With options: { "param1": "value", "param2": 42, "optionalParam": "extra" }`, 91 | inputSchema: schemaHandler, 92 | handler: async (args) => { 93 | try { 94 | const validated = schemaHandler.parse(args); 95 | const result = await performOperation( 96 | vaultPath, 97 | validated.param1, 98 | validated.param2, 99 | validated.optionalParam 100 | ); 101 | 102 | return createToolResponse(formatOperationResult(result)); 103 | } catch (error) { 104 | if (error instanceof z.ZodError) { 105 | throw new McpError( 106 | ErrorCode.InvalidRequest, 107 | `Invalid arguments: ${error.errors.map(e => e.message).join(", ")}` 108 | ); 109 | } 110 | throw error; 111 | } 112 | } 113 | }; 114 | } 115 | ``` 116 | 117 | ## Best Practices 118 | 119 | ### Input Validation 120 | ✅ DO: 121 | - Use strict schemas with `.strict()` 122 | - Provide clear error messages for validation 123 | - Include descriptions for all parameters 124 | - Validate paths are within vault when relevant 125 | - Use discriminated unions for operations with different requirements 126 | - Keep validation logic JSON Schema-friendly 127 | 128 | #### Handling Conditional Validation 129 | 130 | When dealing with operations that have different validation requirements, prefer using discriminated unions over complex refinements: 131 | 132 | ```typescript 133 | // ✅ DO: Use discriminated unions for different operation types 134 | const deleteSchema = z.object({ 135 | operation: z.literal('delete'), 136 | target: z.string(), 137 | content: z.undefined() 138 | }).strict(); 139 | 140 | const editSchema = z.object({ 141 | operation: z.enum(['update', 'append']), 142 | target: z.string(), 143 | content: z.string().min(1) 144 | }).strict(); 145 | 146 | const schema = z.discriminatedUnion('operation', [ 147 | deleteSchema, 148 | editSchema 149 | ]); 150 | 151 | // ❌ DON'T: Use complex refinements that don't translate well to JSON Schema 152 | const schema = z.object({ 153 | operation: z.enum(['delete', 'update', 'append']), 154 | target: z.string(), 155 | content: z.string().optional() 156 | }).superRefine((data, ctx) => { 157 | if (data.operation === 'delete') { 158 | if (data.content !== undefined) { 159 | ctx.addIssue({ 160 | code: z.ZodIssueCode.custom, 161 | message: "Content not allowed for delete" 162 | }); 163 | } 164 | } else if (!data.content) { 165 | ctx.addIssue({ 166 | code: z.ZodIssueCode.custom, 167 | message: "Content required for non-delete" 168 | }); 169 | } 170 | }); 171 | ``` 172 | 173 | #### Schema Design Patterns 174 | 175 | When designing schemas: 176 | 177 | ✅ DO: 178 | - Break down complex schemas into smaller, focused schemas 179 | - Use discriminated unions for operations with different requirements 180 | - Keep validation logic simple and explicit 181 | - Consider how schemas will translate to JSON Schema 182 | - Use literal types for precise operation matching 183 | 184 | ❌ DON'T: 185 | ```typescript 186 | // Don't use complex refinements that access parent data 187 | schema.superRefine((val, ctx) => { 188 | const parent = ctx.parent; // Unreliable 189 | }); 190 | 191 | // Don't mix validation concerns 192 | const schema = z.object({ 193 | operation: z.enum(['delete', 'update']), 194 | content: z.string().superRefine((val, ctx) => { 195 | // Don't put operation-specific logic here 196 | }) 197 | }); 198 | 199 | // Don't skip schema validation 200 | const schema = z.object({ 201 | path: z.string() // Missing validation and description 202 | }); 203 | 204 | // Don't allow unsafe paths 205 | const schema = z.object({ 206 | path: z.string().describe("File path") // Missing path validation 207 | }); 208 | ``` 209 | 210 | ### Error Handling 211 | ✅ DO: 212 | - Use utility functions for common errors 213 | - Convert filesystem errors to McpErrors 214 | - Provide specific error messages 215 | 216 | ❌ DON'T: 217 | ```typescript 218 | // Don't throw raw errors 219 | catch (error) { 220 | throw error; 221 | } 222 | 223 | // Don't ignore validation errors 224 | handler: async (args) => { 225 | const result = await performOperation(args.param); // Missing validation 226 | } 227 | ``` 228 | 229 | ### Response Formatting 230 | ✅ DO: 231 | - Use response utility functions 232 | - Return standardized result objects 233 | - Include relevant operation details 234 | 235 | ❌ DON'T: 236 | ```typescript 237 | // Don't return raw strings 238 | return createToolResponse("Done"); // Too vague 239 | 240 | // Don't skip using proper response types 241 | return { 242 | message: "Success" // Missing proper response structure 243 | }; 244 | ``` 245 | 246 | ### Code Organization 247 | ✅ DO: 248 | - Split complex logic into smaller functions 249 | - Use utility functions for common operations 250 | - Keep the tool factory function clean 251 | 252 | ❌ DON'T: 253 | ```typescript 254 | // Don't mix concerns in the handler 255 | handler: async (args) => { 256 | // Don't put core logic here 257 | const files = await fs.readdir(path); 258 | // ... more direct implementation 259 | } 260 | 261 | // Don't duplicate utility functions 262 | function isValidPath(path: string) { 263 | // Don't reimplement existing utilities 264 | } 265 | ``` 266 | 267 | ## Schema Conversion Considerations 268 | 269 | When creating schemas, remember they need to be converted to JSON Schema for the MCP interface: 270 | 271 | ### JSON Schema Compatibility 272 | 273 | ✅ DO: 274 | - Test your schemas with the `createSchemaHandler` utility 275 | - Use standard Zod types that have clear JSON Schema equivalents 276 | - Structure complex validation using composition of simple schemas 277 | - Verify generated JSON Schema matches expected validation rules 278 | 279 | ❌ DON'T: 280 | - Rely heavily on refinements that don't translate to JSON Schema 281 | - Use complex validation logic that can't be represented in JSON Schema 282 | - Access parent context in nested validations 283 | - Assume all Zod features will work in JSON Schema 284 | 285 | ### Schema Handler Usage 286 | 287 | ```typescript 288 | // ✅ DO: Test schema conversion 289 | const schema = z.discriminatedUnion('operation', [ 290 | z.object({ 291 | operation: z.literal('read'), 292 | path: z.string() 293 | }), 294 | z.object({ 295 | operation: z.literal('write'), 296 | path: z.string(), 297 | content: z.string() 298 | }) 299 | ]); 300 | 301 | // Verify schema handler creation succeeds 302 | const schemaHandler = createSchemaHandler(schema); 303 | 304 | // ❌ DON'T: Use features that don't convert well 305 | const schema = z.object({ 306 | data: z.any().superRefine((val, ctx) => { 307 | // Complex custom validation that won't translate 308 | }) 309 | }); 310 | ``` 311 | 312 | ## Common Utilities 313 | 314 | Make use of existing utilities: 315 | 316 | - `createSchemaHandler`: For input validation 317 | - `handleFsError`: For filesystem error handling 318 | - `createToolResponse`: For formatting responses 319 | - `validateVaultPath`: For path validation 320 | - `ensureDirectory`: For directory operations 321 | - `formatOperationResult`: For standardized results 322 | 323 | ## Testing Your Tool 324 | 325 | 1. Ensure your tool handles edge cases: 326 | - Invalid inputs 327 | - File/directory permissions 328 | - Non-existent paths 329 | - Concurrent operations 330 | 331 | 2. Verify error messages are helpful: 332 | - Validation errors should guide the user 333 | - Operation errors should be specific 334 | - Path-related errors should be clear 335 | 336 | 3. Check response formatting: 337 | - Success messages should be informative 338 | - Error messages should be actionable 339 | - Operation details should be complete 340 | 341 | ## Integration 342 | 343 | After implementing your tool: 344 | 345 | 1. Export it from `src/tools/index.ts` 346 | 2. Register it in `src/server.ts` 347 | 3. Update any relevant documentation 348 | 4. Add appropriate error handling utilities if needed 349 | 350 | Remember: Tools should be focused, well-documented, and follow the established patterns in the codebase. When in doubt, look at existing tools like `create-note` or `edit-note` as references. 351 | ``` -------------------------------------------------------------------------------- /src/tools/remove-tags/index.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { z } from "zod"; 2 | import { promises as fs } from "fs"; 3 | import path from "path"; 4 | import { McpError } from "@modelcontextprotocol/sdk/types.js"; 5 | import { validateVaultPath } from "../../utils/path.js"; 6 | import { fileExists, safeReadFile } from "../../utils/files.js"; 7 | import { 8 | validateTag, 9 | parseNote, 10 | stringifyNote, 11 | removeTagsFromFrontmatter, 12 | removeInlineTags 13 | } from "../../utils/tags.js"; 14 | import { createTool } from "../../utils/tool-factory.js"; 15 | 16 | // Input validation schema with descriptions 17 | const schema = z.object({ 18 | vault: z.string() 19 | .min(1, "Vault name cannot be empty") 20 | .describe("Name of the vault containing the notes"), 21 | files: z.array(z.string()) 22 | .min(1, "At least one file must be specified") 23 | .refine( 24 | files => files.every(f => f.endsWith('.md')), 25 | "All files must have .md extension" 26 | ) 27 | .describe("Array of note filenames to process (must have .md extension)"), 28 | tags: z.array(z.string()) 29 | .min(1, "At least one tag must be specified") 30 | .refine( 31 | tags => tags.every(tag => /^[a-zA-Z0-9\/]+$/.test(tag)), 32 | "Tags must contain only letters, numbers, and forward slashes. Do not include the # symbol. Examples: 'project', 'work/active', 'tasks/2024/q1'" 33 | ) 34 | .describe("Array of tags to remove (without # symbol). Example: ['project', 'work/active']"), 35 | options: z.object({ 36 | location: z.enum(['frontmatter', 'content', 'both']) 37 | .default('both') 38 | .describe("Where to remove tags from (default: both)"), 39 | normalize: z.boolean() 40 | .default(true) 41 | .describe("Whether to normalize tag format (e.g., ProjectActive -> project-active) (default: true)"), 42 | preserveChildren: z.boolean() 43 | .default(false) 44 | .describe("Whether to preserve child tags when removing parent tags (default: false)"), 45 | patterns: z.array(z.string()) 46 | .default([]) 47 | .describe("Tag patterns to match for removal (supports * wildcard) (default: [])") 48 | }).default({ 49 | location: 'both', 50 | normalize: true, 51 | preserveChildren: false, 52 | patterns: [] 53 | }) 54 | }); 55 | 56 | interface RemoveTagsReport { 57 | success: string[]; 58 | errors: { file: string; error: string }[]; 59 | details: { 60 | [filename: string]: { 61 | removedTags: Array<{ 62 | tag: string; 63 | location: 'frontmatter' | 'content'; 64 | line?: number; 65 | context?: string; 66 | }>; 67 | preservedTags: Array<{ 68 | tag: string; 69 | location: 'frontmatter' | 'content'; 70 | line?: number; 71 | context?: string; 72 | }>; 73 | }; 74 | }; 75 | } 76 | 77 | type RemoveTagsInput = z.infer<typeof schema>; 78 | 79 | async function removeTags( 80 | vaultPath: string, 81 | params: Omit<RemoveTagsInput, 'vault'> 82 | ): Promise<RemoveTagsReport> { 83 | const results: RemoveTagsReport = { 84 | success: [], 85 | errors: [], 86 | details: {} 87 | }; 88 | 89 | for (const filename of params.files) { 90 | const fullPath = path.join(vaultPath, filename); 91 | 92 | try { 93 | // Validate path is within vault 94 | validateVaultPath(vaultPath, fullPath); 95 | 96 | // Check if file exists 97 | if (!await fileExists(fullPath)) { 98 | results.errors.push({ 99 | file: filename, 100 | error: "File not found" 101 | }); 102 | continue; 103 | } 104 | 105 | // Read file content 106 | const content = await safeReadFile(fullPath); 107 | if (!content) { 108 | results.errors.push({ 109 | file: filename, 110 | error: "Failed to read file" 111 | }); 112 | continue; 113 | } 114 | 115 | // Parse the note 116 | const parsed = parseNote(content); 117 | let modified = false; 118 | results.details[filename] = { 119 | removedTags: [], 120 | preservedTags: [] 121 | }; 122 | 123 | // Handle frontmatter tags 124 | if (params.options.location !== 'content') { 125 | const { frontmatter: updatedFrontmatter, report } = removeTagsFromFrontmatter( 126 | parsed.frontmatter, 127 | params.tags, 128 | { 129 | normalize: params.options.normalize, 130 | preserveChildren: params.options.preserveChildren, 131 | patterns: params.options.patterns 132 | } 133 | ); 134 | 135 | results.details[filename].removedTags.push(...report.removed); 136 | results.details[filename].preservedTags.push(...report.preserved); 137 | 138 | if (JSON.stringify(parsed.frontmatter) !== JSON.stringify(updatedFrontmatter)) { 139 | parsed.frontmatter = updatedFrontmatter; 140 | modified = true; 141 | } 142 | } 143 | 144 | // Handle inline tags 145 | if (params.options.location !== 'frontmatter') { 146 | const { content: newContent, report } = removeInlineTags( 147 | parsed.content, 148 | params.tags, 149 | { 150 | normalize: params.options.normalize, 151 | preserveChildren: params.options.preserveChildren, 152 | patterns: params.options.patterns 153 | } 154 | ); 155 | 156 | results.details[filename].removedTags.push(...report.removed); 157 | results.details[filename].preservedTags.push(...report.preserved); 158 | 159 | if (parsed.content !== newContent) { 160 | parsed.content = newContent; 161 | modified = true; 162 | } 163 | } 164 | 165 | // Save changes if modified 166 | if (modified) { 167 | const updatedContent = stringifyNote(parsed); 168 | await fs.writeFile(fullPath, updatedContent); 169 | results.success.push(filename); 170 | } 171 | } catch (error) { 172 | results.errors.push({ 173 | file: filename, 174 | error: error instanceof Error ? error.message : 'Unknown error' 175 | }); 176 | } 177 | } 178 | 179 | return results; 180 | } 181 | 182 | export function createRemoveTagsTool(vaults: Map<string, string>) { 183 | return createTool<RemoveTagsInput>({ 184 | name: "remove-tags", 185 | description: `Remove tags from notes in frontmatter and/or content. 186 | 187 | Examples: 188 | - Simple: { "files": ["note.md"], "tags": ["project", "status"] } 189 | - With hierarchy: { "files": ["note.md"], "tags": ["work/active", "priority/high"] } 190 | - With options: { "files": ["note.md"], "tags": ["status"], "options": { "location": "frontmatter" } } 191 | - Pattern matching: { "files": ["note.md"], "options": { "patterns": ["status/*"] } } 192 | - INCORRECT: { "tags": ["#project"] } (don't include # symbol)`, 193 | schema, 194 | handler: async (args, vaultPath, _vaultName) => { 195 | const results = await removeTags(vaultPath, { 196 | files: args.files, 197 | tags: args.tags, 198 | options: args.options 199 | }); 200 | 201 | // Format detailed response message 202 | let message = ''; 203 | 204 | // Add success summary 205 | if (results.success.length > 0) { 206 | message += `Successfully processed tags in: ${results.success.join(', ')}\n\n`; 207 | } 208 | 209 | // Add detailed changes for each file 210 | for (const [filename, details] of Object.entries(results.details)) { 211 | if (details.removedTags.length > 0 || details.preservedTags.length > 0) { 212 | message += `Changes in ${filename}:\n`; 213 | 214 | if (details.removedTags.length > 0) { 215 | message += ' Removed tags:\n'; 216 | const byLocation = details.removedTags.reduce((acc, change) => { 217 | if (!acc[change.location]) acc[change.location] = new Map(); 218 | const key = change.line ? `${change.location} (line ${change.line})` : change.location; 219 | const locationMap = acc[change.location]; 220 | if (locationMap) { 221 | if (!locationMap.has(key)) { 222 | locationMap.set(key, new Set()); 223 | } 224 | const tagSet = locationMap.get(key); 225 | if (tagSet) { 226 | tagSet.add(change.tag); 227 | } 228 | } 229 | return acc; 230 | }, {} as Record<string, Map<string, Set<string>>>); 231 | 232 | for (const [location, locationMap] of Object.entries(byLocation)) { 233 | for (const [key, tags] of locationMap.entries()) { 234 | message += ` ${key}: ${Array.from(tags).join(', ')}\n`; 235 | } 236 | } 237 | } 238 | 239 | if (details.preservedTags.length > 0) { 240 | message += ' Preserved tags:\n'; 241 | const byLocation = details.preservedTags.reduce((acc, change) => { 242 | if (!acc[change.location]) acc[change.location] = new Map(); 243 | const key = change.line ? `${change.location} (line ${change.line})` : change.location; 244 | const locationMap = acc[change.location]; 245 | if (locationMap) { 246 | if (!locationMap.has(key)) { 247 | locationMap.set(key, new Set()); 248 | } 249 | const tagSet = locationMap.get(key); 250 | if (tagSet) { 251 | tagSet.add(change.tag); 252 | } 253 | } 254 | return acc; 255 | }, {} as Record<string, Map<string, Set<string>>>); 256 | 257 | for (const [location, locationMap] of Object.entries(byLocation)) { 258 | for (const [key, tags] of locationMap.entries()) { 259 | message += ` ${key}: ${Array.from(tags).join(', ')}\n`; 260 | } 261 | } 262 | } 263 | 264 | message += '\n'; 265 | } 266 | } 267 | 268 | // Add errors if any 269 | if (results.errors.length > 0) { 270 | message += 'Errors:\n'; 271 | results.errors.forEach(error => { 272 | message += ` ${error.file}: ${error.error}\n`; 273 | }); 274 | } 275 | 276 | return { 277 | content: [{ 278 | type: "text", 279 | text: message.trim() 280 | }] 281 | }; 282 | } 283 | }, vaults); 284 | } 285 | ``` -------------------------------------------------------------------------------- /src/tools/manage-tags/index.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { z } from "zod"; 2 | import { promises as fs } from "fs"; 3 | import path from "path"; 4 | import { McpError, ErrorCode } from "@modelcontextprotocol/sdk/types.js"; 5 | import { validateVaultPath } from "../../utils/path.js"; 6 | import { fileExists, safeReadFile } from "../../utils/files.js"; 7 | import { 8 | validateTag, 9 | parseNote, 10 | stringifyNote, 11 | addTagsToFrontmatter, 12 | removeTagsFromFrontmatter, 13 | removeInlineTags, 14 | normalizeTag 15 | } from "../../utils/tags.js"; 16 | import { createTool } from "../../utils/tool-factory.js"; 17 | 18 | // Input validation schema 19 | const schema = z.object({ 20 | vault: z.string() 21 | .min(1, "Vault name cannot be empty") 22 | .describe("Name of the vault containing the notes"), 23 | files: z.array(z.string()) 24 | .min(1, "At least one file must be specified") 25 | .refine( 26 | files => files.every(f => f.endsWith('.md')), 27 | "All files must have .md extension" 28 | ), 29 | operation: z.enum(['add', 'remove']) 30 | .describe("Whether to add or remove the specified tags"), 31 | tags: z.array(z.string()) 32 | .min(1, "At least one tag must be specified") 33 | .refine( 34 | tags => tags.every(validateTag), 35 | "Invalid tag format. Tags must contain only letters, numbers, and forward slashes for hierarchy." 36 | ), 37 | options: z.object({ 38 | location: z.enum(['frontmatter', 'content', 'both']) 39 | .default('frontmatter') 40 | .describe("Where to add/remove tags"), 41 | normalize: z.boolean() 42 | .default(true) 43 | .describe("Whether to normalize tag format"), 44 | position: z.enum(['start', 'end']) 45 | .default('end') 46 | .describe("Where to add inline tags in content"), 47 | preserveChildren: z.boolean() 48 | .default(false) 49 | .describe("Whether to preserve child tags when removing parent tags"), 50 | patterns: z.array(z.string()) 51 | .default([]) 52 | .describe("Tag patterns to match for removal (supports * wildcard)") 53 | }).default({ 54 | location: 'both', 55 | normalize: true, 56 | position: 'end', 57 | preserveChildren: false, 58 | patterns: [] 59 | }) 60 | }).strict(); 61 | 62 | type ManageTagsInput = z.infer<typeof schema>; 63 | 64 | interface OperationParams { 65 | files: string[]; 66 | operation: 'add' | 'remove'; 67 | tags: string[]; 68 | options: { 69 | location: 'frontmatter' | 'content' | 'both'; 70 | normalize: boolean; 71 | position: 'start' | 'end'; 72 | preserveChildren: boolean; 73 | patterns: string[]; 74 | }; 75 | } 76 | 77 | interface OperationReport { 78 | success: string[]; 79 | errors: { file: string; error: string }[]; 80 | details: { 81 | [filename: string]: { 82 | removedTags: Array<{ 83 | tag: string; 84 | location: 'frontmatter' | 'content'; 85 | line?: number; 86 | context?: string; 87 | }>; 88 | preservedTags: Array<{ 89 | tag: string; 90 | location: 'frontmatter' | 'content'; 91 | line?: number; 92 | context?: string; 93 | }>; 94 | }; 95 | }; 96 | } 97 | 98 | async function manageTags( 99 | vaultPath: string, 100 | operation: ManageTagsInput 101 | ): Promise<OperationReport> { 102 | const results: OperationReport = { 103 | success: [], 104 | errors: [], 105 | details: {} 106 | }; 107 | 108 | for (const filename of operation.files) { 109 | const fullPath = path.join(vaultPath, filename); 110 | 111 | try { 112 | // Validate path is within vault 113 | validateVaultPath(vaultPath, fullPath); 114 | 115 | // Check if file exists 116 | if (!await fileExists(fullPath)) { 117 | results.errors.push({ 118 | file: filename, 119 | error: "File not found" 120 | }); 121 | continue; 122 | } 123 | 124 | // Read file content 125 | const content = await safeReadFile(fullPath); 126 | if (!content) { 127 | results.errors.push({ 128 | file: filename, 129 | error: "Failed to read file" 130 | }); 131 | continue; 132 | } 133 | 134 | // Parse the note 135 | const parsed = parseNote(content); 136 | let modified = false; 137 | results.details[filename] = { 138 | removedTags: [], 139 | preservedTags: [] 140 | }; 141 | 142 | if (operation.operation === 'add') { 143 | // Handle frontmatter tags for add operation 144 | if (operation.options.location !== 'content') { 145 | const updatedFrontmatter = addTagsToFrontmatter( 146 | parsed.frontmatter, 147 | operation.tags, 148 | operation.options.normalize 149 | ); 150 | 151 | if (JSON.stringify(parsed.frontmatter) !== JSON.stringify(updatedFrontmatter)) { 152 | parsed.frontmatter = updatedFrontmatter; 153 | parsed.hasFrontmatter = true; 154 | modified = true; 155 | } 156 | } 157 | 158 | // Handle inline tags for add operation 159 | if (operation.options.location !== 'frontmatter') { 160 | const tagString = operation.tags 161 | .filter(tag => validateTag(tag)) 162 | .map(tag => `#${operation.options.normalize ? normalizeTag(tag) : tag}`) 163 | .join(' '); 164 | 165 | if (tagString) { 166 | if (operation.options.position === 'start') { 167 | parsed.content = tagString + '\n\n' + parsed.content.trim(); 168 | } else { 169 | parsed.content = parsed.content.trim() + '\n\n' + tagString; 170 | } 171 | modified = true; 172 | } 173 | } 174 | } else { 175 | // Handle frontmatter tags for remove operation 176 | if (operation.options.location !== 'content') { 177 | const { frontmatter: updatedFrontmatter, report } = removeTagsFromFrontmatter( 178 | parsed.frontmatter, 179 | operation.tags, 180 | { 181 | normalize: operation.options.normalize, 182 | preserveChildren: operation.options.preserveChildren, 183 | patterns: operation.options.patterns 184 | } 185 | ); 186 | 187 | results.details[filename].removedTags.push(...report.removed); 188 | results.details[filename].preservedTags.push(...report.preserved); 189 | 190 | if (JSON.stringify(parsed.frontmatter) !== JSON.stringify(updatedFrontmatter)) { 191 | parsed.frontmatter = updatedFrontmatter; 192 | modified = true; 193 | } 194 | } 195 | 196 | // Handle inline tags for remove operation 197 | if (operation.options.location !== 'frontmatter') { 198 | const { content: newContent, report } = removeInlineTags( 199 | parsed.content, 200 | operation.tags, 201 | { 202 | normalize: operation.options.normalize, 203 | preserveChildren: operation.options.preserveChildren, 204 | patterns: operation.options.patterns 205 | } 206 | ); 207 | 208 | results.details[filename].removedTags.push(...report.removed); 209 | results.details[filename].preservedTags.push(...report.preserved); 210 | 211 | if (parsed.content !== newContent) { 212 | parsed.content = newContent; 213 | modified = true; 214 | } 215 | } 216 | } 217 | 218 | // Save changes if modified 219 | if (modified) { 220 | const updatedContent = stringifyNote(parsed); 221 | await fs.writeFile(fullPath, updatedContent); 222 | results.success.push(filename); 223 | } 224 | } catch (error) { 225 | results.errors.push({ 226 | file: filename, 227 | error: error instanceof Error ? error.message : 'Unknown error' 228 | }); 229 | } 230 | } 231 | 232 | return results; 233 | } 234 | 235 | export function createManageTagsTool(vaults: Map<string, string>) { 236 | return createTool<ManageTagsInput>({ 237 | name: "manage-tags", 238 | description: `Add or remove tags from notes, supporting both frontmatter and inline tags. 239 | 240 | Examples: 241 | - Add tags: { "vault": "vault1", "files": ["note.md"], "operation": "add", "tags": ["project", "status/active"] } 242 | - Remove tags: { "vault": "vault1", "files": ["note.md"], "operation": "remove", "tags": ["project"] } 243 | - With options: { "vault": "vault1", "files": ["note.md"], "operation": "add", "tags": ["status"], "options": { "location": "frontmatter" } } 244 | - Pattern matching: { "vault": "vault1", "files": ["note.md"], "operation": "remove", "options": { "patterns": ["status/*"] } } 245 | - INCORRECT: { "tags": ["#project"] } (don't include # symbol)`, 246 | schema, 247 | handler: async (args, vaultPath, _vaultName) => { 248 | const results = await manageTags(vaultPath, args); 249 | 250 | // Format detailed response message 251 | let message = ''; 252 | 253 | // Add success summary 254 | if (results.success.length > 0) { 255 | message += `Successfully processed tags in: ${results.success.join(', ')}\n\n`; 256 | } 257 | 258 | // Add detailed changes for each file 259 | for (const [filename, details] of Object.entries(results.details)) { 260 | if (details.removedTags.length > 0 || details.preservedTags.length > 0) { 261 | message += `Changes in ${filename}:\n`; 262 | 263 | if (details.removedTags.length > 0) { 264 | message += ' Removed tags:\n'; 265 | details.removedTags.forEach(change => { 266 | message += ` - ${change.tag} (${change.location}`; 267 | if (change.line) { 268 | message += `, line ${change.line}`; 269 | } 270 | message += ')\n'; 271 | }); 272 | } 273 | 274 | if (details.preservedTags.length > 0) { 275 | message += ' Preserved tags:\n'; 276 | details.preservedTags.forEach(change => { 277 | message += ` - ${change.tag} (${change.location}`; 278 | if (change.line) { 279 | message += `, line ${change.line}`; 280 | } 281 | message += ')\n'; 282 | }); 283 | } 284 | 285 | message += '\n'; 286 | } 287 | } 288 | 289 | // Add errors if any 290 | if (results.errors.length > 0) { 291 | message += 'Errors:\n'; 292 | results.errors.forEach(error => { 293 | message += ` ${error.file}: ${error.error}\n`; 294 | }); 295 | } 296 | 297 | return { 298 | content: [{ 299 | type: "text", 300 | text: message.trim() 301 | }] 302 | }; 303 | } 304 | }, vaults); 305 | } 306 | ```