#
tokens: 46577/50000 36/40 files (page 1/2)
lines: on (toggle) GitHub
raw markdown copy reset
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 | [![smithery badge](https://smithery.ai/badge/obsidian-mcp)](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 | 
```
Page 1/2FirstPrevNextLast