# Directory Structure
```
├── .gitignore
├── .prettierrc
├── eslint.config.js
├── LICENSE
├── package.json
├── pnpm-lock.yaml
├── README.md
├── src
│ ├── constants.ts
│ ├── file-system.ts
│ ├── index.ts
│ ├── prompts.ts
│ ├── schemas.ts
│ └── types.ts
└── tsconfig.json
```
# Files
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
```
1 | build/
2 | node_modules/
3 |
```
--------------------------------------------------------------------------------
/.prettierrc:
--------------------------------------------------------------------------------
```
1 | {
2 | "singleQuote": true,
3 | "trailingComma": "none",
4 | "bracketSpacing": true,
5 | "proseWrap": "preserve",
6 | "semi": false,
7 | "printWidth": 80,
8 | "plugins": ["prettier-plugin-organize-imports"]
9 | }
10 |
```
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
```markdown
1 | # Obsidian iCloud MCP
2 |
3 | Connecting Obsidian Vaults that are stored in iCloud Drive to AI via the Model Context Protocol (MCP).
4 |
5 | > [!WARNING]
6 | > Obsidian iCloud MCP is fully tested on MacOS. If you are using Windows or Linux, please test it and let me know if it works.
7 |
8 | ## Usage with Claude Desktop
9 |
10 | Add this to your [`claude_desktop_config.json`](https://modelcontextprotocol.io/quickstart/user):
11 |
12 | ### Debugging in Development
13 |
14 | ```json
15 | {
16 | "mcpServers": {
17 | "obsidian-mcp": {
18 | "command": "node",
19 | "args": [
20 | "/path/to/obsidian-mcp/build/index.js",
21 | "/Users/<USERNAME>/Library/Mobile\\ Documents/iCloud~md~obsidian/Documents/<VAULT_NAME_1>",
22 | "/Users/<USERNAME>/Library/Mobile\\ Documents/iCloud~md~obsidian/Documents/<VAULT_NAME_2>"
23 | ]
24 | }
25 | }
26 | }
27 | ```
28 |
29 | Using [`npx @modelcontextprotocol/inspector node path/to/server/index.js arg1 arg2 arg3 arg...`](https://modelcontextprotocol.io/docs/tools/inspector) to inspect servers locally developed.
30 |
31 | ### Using in Production
32 |
33 | ```json
34 | {
35 | "mcpServers": {
36 | "obsidian-mcp": {
37 | "command": "npx",
38 | "args": [
39 | "-y",
40 | "obsidian-mcp",
41 | "/Users/<USERNAME>/Library/Mobile\\ Documents/iCloud~md~obsidian/Documents/<VAULT_NAME_1>",
42 | "/Users/<USERNAME>/Library/Mobile\\ Documents/iCloud~md~obsidian/Documents/<VAULT_NAME_2>"
43 | ]
44 | }
45 | }
46 | }
47 | ```
48 |
```
--------------------------------------------------------------------------------
/src/constants.ts:
--------------------------------------------------------------------------------
```typescript
1 | export const MCP_SERVER_NAME = 'obsidian-mcp'
2 |
3 | export const MCP_SERVER_VERSION = '1.0.0'
4 |
```
--------------------------------------------------------------------------------
/src/types.ts:
--------------------------------------------------------------------------------
```typescript
1 | export interface Resource {
2 | [x: string]: unknown
3 | name: string
4 | uri: string
5 | description?: string
6 | mimeType?: string
7 | }
8 |
9 | export interface DirectoryNode {
10 | name: string
11 | type: 'directory'
12 | children: (DirectoryNode | FileNode)[]
13 | }
14 |
15 | export interface FileNode {
16 | name: string
17 | type: 'file'
18 | uri: string
19 | mimeType: string
20 | }
21 |
```
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
```json
1 | {
2 | "compilerOptions": {
3 | "target": "ES2022",
4 | "module": "Node16",
5 | "moduleResolution": "Node16",
6 | "outDir": "./build",
7 | "rootDir": "./src",
8 | "strict": true,
9 | "esModuleInterop": true,
10 | "skipLibCheck": true,
11 | "forceConsistentCasingInFileNames": true
12 | },
13 | "include": [
14 | "src/**/*"
15 | ],
16 | "exclude": [
17 | "node_modules"
18 | ]
19 | }
```
--------------------------------------------------------------------------------
/eslint.config.js:
--------------------------------------------------------------------------------
```javascript
1 | import js from '@eslint/js'
2 | import { defineConfig } from 'eslint/config'
3 | import globals from 'globals'
4 | import tseslint from 'typescript-eslint'
5 |
6 | export default defineConfig([
7 | { files: ['**/*.{js,mjs,cjs,ts}'] },
8 | {
9 | files: ['**/*.{js,mjs,cjs,ts}'],
10 | languageOptions: { globals: globals.browser }
11 | },
12 | {
13 | files: ['**/*.{js,mjs,cjs,ts}'],
14 | plugins: { js },
15 | extends: ['js/recommended']
16 | },
17 | tseslint.configs.recommended
18 | ])
19 |
```
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
```json
1 | {
2 | "name": "obsidian-mcp",
3 | "version": "1.0.0",
4 | "description": "Connecting Obsidian Vaults that are stored in local to AI via the Model Context Protocol (MCP).",
5 | "type": "module",
6 | "bin": {
7 | "obsidian-mcp": "./build/index.js"
8 | },
9 | "scripts": {
10 | "dev": "tsc --watch",
11 | "build": "rimraf build && tsc && chmod 755 build/index.js",
12 | "format": "prettier ./.prettierrc -w ./src",
13 | "lint": "eslint --fix ./src"
14 | },
15 | "files": [
16 | "build"
17 | ],
18 | "keywords": [
19 | "Obsidian",
20 | "Model Context Protocol(MCP)"
21 | ],
22 | "author": "Yancey Leo <[email protected]>",
23 | "license": "MIT",
24 | "dependencies": {
25 | "@modelcontextprotocol/sdk": "^1.7.0",
26 | "flexsearch": "^0.8.105",
27 | "glob": "^11.0.1",
28 | "gray-matter": "^4.0.3",
29 | "mime": "^4.0.6",
30 | "remove-markdown": "^0.6.0",
31 | "rimraf": "^6.0.1",
32 | "zod": "^3.24.2",
33 | "zod-to-json-schema": "^3.24.5"
34 | },
35 | "devDependencies": {
36 | "@eslint/js": "^9.23.0",
37 | "@types/node": "^22.13.11",
38 | "eslint": "^9.23.0",
39 | "globals": "^16.0.0",
40 | "prettier": "^3.5.3",
41 | "prettier-plugin-organize-imports": "^4.1.0",
42 | "typescript": "^5.8.2",
43 | "typescript-eslint": "^8.28.0"
44 | }
45 | }
46 |
```
--------------------------------------------------------------------------------
/src/schemas.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { ToolSchema } from '@modelcontextprotocol/sdk/types.js'
2 | import { z } from 'zod'
3 |
4 | export const ReadFileArgsSchema = z.object({
5 | path: z.string()
6 | })
7 |
8 | export const ReadMultipleFilesArgsSchema = z.object({
9 | paths: z.array(z.string())
10 | })
11 |
12 | export const WriteFileArgsSchema = z.object({
13 | path: z.string(),
14 | content: z.string()
15 | })
16 |
17 | export const RemoveFileArgsSchema = z.object({
18 | path: z.string()
19 | })
20 |
21 | export const RemoveMultipleFilesArgsSchema = z.object({
22 | paths: z.array(z.string())
23 | })
24 |
25 | export const EditFileArgsSchema = z.object({
26 | path: z.string(),
27 | newText: z.string(),
28 | dryRun: z
29 | .boolean()
30 | .default(false)
31 | .describe('Preview changes before real editing.')
32 | })
33 |
34 | export const ListDirectoryArgsSchema = z.object({
35 | path: z.string()
36 | })
37 |
38 | export const CreateDirectoryArgsSchema = z.object({
39 | path: z.string()
40 | })
41 |
42 | export const RemoveDirectoryArgsSchema = z.object({
43 | path: z.string()
44 | })
45 |
46 | export const RemoveMultipleDirectoryArgsSchema = z.object({
47 | paths: z.array(z.string())
48 | })
49 |
50 | export const MoveFileArgsSchema = z.object({
51 | source: z.string(),
52 | destination: z.string()
53 | })
54 |
55 | export const FullTextSearchArgsSchema = z.object({
56 | query: z.string()
57 | })
58 |
59 | export type ToolInput = z.infer<typeof ToolSchema.shape.inputSchema>
60 |
```
--------------------------------------------------------------------------------
/src/prompts.ts:
--------------------------------------------------------------------------------
```typescript
1 | export const readFilePrompt = (rootPaths: string[]) =>
2 | `Your task is to read file from ${rootPaths.join(', ')}. ` +
3 | 'Read the complete contents of a file from the file system. ' +
4 | 'Handles various text encodings and provides detailed error messages ' +
5 | 'if the file cannot be read. Use this tool when you need to examine ' +
6 | 'the contents of a single file. Only works within allowed directories.'
7 |
8 | export const readMultipleFilesPrompt = () =>
9 | 'Read the contents of multiple files simultaneously. This is more ' +
10 | 'efficient than reading files one by one when you need to analyze ' +
11 | "or compare multiple files. Each file's content is returned with its " +
12 | "path as a reference. Failed reads for individual files won't stop " +
13 | 'the entire operation. Only works within allowed directories.'
14 |
15 | export const writeFilePrompt = (rootPaths: string[]) =>
16 | `Your task is to write file to an appropriate path under ${rootPaths.join(', ')}. ` +
17 | "The path you'll write should follow user's instruction and make sure it hasn't been occupied." +
18 | 'Create a new file or completely overwrite an existing file with new content. ' +
19 | 'Use with caution as it will overwrite existing files without warning. ' +
20 | 'Handles text content with proper encoding. Only works within allowed directories.'
21 |
22 | export const editFilePrompt = (rootPaths: string[]) =>
23 | `Edit a specific file under ${rootPaths.join(', ')}. ` +
24 | 'Display the modified content to the user for review; the original file will only be updated upon user confirmation. ' +
25 | 'Only works within allowed directories.'
26 |
27 | export const removeFilePrompt = () => ''
28 |
29 | export const removeMultipleFilesPrompt = () => ''
30 |
31 | export const createDirectoryPrompt = () =>
32 | 'Create a new directory or ensure a directory exists. Can create multiple ' +
33 | 'nested directories in one operation. If the directory already exists, ' +
34 | 'this operation will succeed silently. Perfect for setting up directory ' +
35 | 'structures for projects or ensuring required paths exist. Only works within allowed directories.'
36 |
37 | export const listDirectoryPrompt = (rootPaths: string[]) =>
38 | `Your task is to list directory under ${rootPaths.join(', ')}. ` +
39 | 'Get a detailed listing of all files and directories in a specified path. ' +
40 | 'Results clearly distinguish between files and directories with [FILE] and [DIR] ' +
41 | 'prefixes. This tool is essential for understanding directory structure and ' +
42 | 'finding specific files within a directory. Only works within allowed directories.'
43 |
44 | export const removeDirectoryPrompt = () => ''
45 |
46 | export const removeMultipleDirectoryPrompt = () => ''
47 |
48 | export const moveFileDirectoryPrompt = () =>
49 | 'Move or rename files and directories. Can move files between directories ' +
50 | 'and rename them in a single operation. If the destination exists, the ' +
51 | 'operation will fail. Works across different directories and can be used ' +
52 | 'for simple renaming within the same directory. Both source and destination must be within allowed directories.'
53 |
54 | export const fullTextSearchDirectoryPrompt = () =>
55 | "Tokenize the user's query and the search engine tool will return relevant contents. " +
56 | "summarized those contents based on the user's query."
57 |
```
--------------------------------------------------------------------------------
/src/index.ts:
--------------------------------------------------------------------------------
```typescript
1 | #!/usr/bin/env node
2 |
3 | import { Server } from '@modelcontextprotocol/sdk/server/index.js'
4 | import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
5 | import {
6 | CallToolRequestSchema,
7 | ListResourcesRequestSchema,
8 | ListToolsRequestSchema,
9 | ReadResourceRequestSchema
10 | } from '@modelcontextprotocol/sdk/types.js'
11 | import { zodToJsonSchema } from 'zod-to-json-schema'
12 | import { MCP_SERVER_NAME, MCP_SERVER_VERSION } from './constants.js'
13 | import {
14 | createDirectory,
15 | editFile,
16 | flattenDirectory,
17 | fullTextSearch,
18 | listDirectory,
19 | moveFile,
20 | readFile,
21 | readFileFromUri,
22 | readMultipleFiles,
23 | removeDirectory,
24 | removeFile,
25 | removeMultipleDirectory,
26 | removeMultipleFiles,
27 | writeFile
28 | } from './file-system.js'
29 | import {
30 | createDirectoryPrompt,
31 | editFilePrompt,
32 | fullTextSearchDirectoryPrompt,
33 | listDirectoryPrompt,
34 | moveFileDirectoryPrompt,
35 | readFilePrompt,
36 | readMultipleFilesPrompt,
37 | removeDirectoryPrompt,
38 | removeFilePrompt,
39 | removeMultipleDirectoryPrompt,
40 | removeMultipleFilesPrompt,
41 | writeFilePrompt
42 | } from './prompts.js'
43 | import {
44 | CreateDirectoryArgsSchema,
45 | EditFileArgsSchema,
46 | FullTextSearchArgsSchema,
47 | ListDirectoryArgsSchema,
48 | MoveFileArgsSchema,
49 | ReadFileArgsSchema,
50 | ReadMultipleFilesArgsSchema,
51 | RemoveDirectoryArgsSchema,
52 | RemoveFileArgsSchema,
53 | RemoveMultipleDirectoryArgsSchema,
54 | RemoveMultipleFilesArgsSchema,
55 | ToolInput,
56 | WriteFileArgsSchema
57 | } from './schemas.js'
58 |
59 | const server = new Server(
60 | {
61 | name: MCP_SERVER_NAME,
62 | version: MCP_SERVER_VERSION
63 | },
64 | {
65 | capabilities: {
66 | tools: {},
67 | resources: {},
68 | prompts: {}
69 | }
70 | }
71 | )
72 |
73 | const args = process.argv.slice(2)
74 | if (args.length === 0) {
75 | console.error(
76 | `Usage: ${MCP_SERVER_NAME} <obsidian-directory> [additional-directories...]`
77 | )
78 | process.exit(1)
79 | }
80 |
81 | server.setRequestHandler(ListResourcesRequestSchema, async () => {
82 | const resources = (
83 | await Promise.all(args.map((arg) => flattenDirectory(arg)))
84 | ).flat()
85 | return {
86 | resources
87 | }
88 | })
89 |
90 | server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
91 | const content = await readFileFromUri(request.params.uri)
92 |
93 | if (content === null) throw new Error('Error reading file from URL')
94 | return content
95 | })
96 |
97 | server.setRequestHandler(ListToolsRequestSchema, async () => {
98 | return {
99 | tools: [
100 | {
101 | name: 'read_file',
102 | description: readFilePrompt(args),
103 | inputSchema: zodToJsonSchema(ReadFileArgsSchema) as ToolInput
104 | },
105 | {
106 | name: 'read_multiple_files',
107 | description: readMultipleFilesPrompt(),
108 | inputSchema: zodToJsonSchema(ReadMultipleFilesArgsSchema) as ToolInput
109 | },
110 | {
111 | name: 'write_file',
112 | description: writeFilePrompt(args),
113 | inputSchema: zodToJsonSchema(WriteFileArgsSchema) as ToolInput
114 | },
115 | {
116 | name: 'edit_file',
117 | description: editFilePrompt(args),
118 | inputSchema: zodToJsonSchema(EditFileArgsSchema) as ToolInput
119 | },
120 | {
121 | name: 'remove_file',
122 | description: removeFilePrompt(),
123 | inputSchema: zodToJsonSchema(RemoveFileArgsSchema) as ToolInput
124 | },
125 | {
126 | name: 'remove_multiple_files',
127 | description: removeMultipleFilesPrompt(),
128 | inputSchema: zodToJsonSchema(RemoveMultipleFilesArgsSchema) as ToolInput
129 | },
130 | {
131 | name: 'create_directory',
132 | description: createDirectoryPrompt(),
133 | inputSchema: zodToJsonSchema(CreateDirectoryArgsSchema) as ToolInput
134 | },
135 | {
136 | name: 'list_directory',
137 | description: listDirectoryPrompt(args),
138 | inputSchema: zodToJsonSchema(ListDirectoryArgsSchema) as ToolInput
139 | },
140 | {
141 | name: 'remove_directory',
142 | description: removeDirectoryPrompt(),
143 | inputSchema: zodToJsonSchema(RemoveDirectoryArgsSchema) as ToolInput
144 | },
145 | {
146 | name: 'remove_multiple_directory',
147 | description: removeMultipleDirectoryPrompt(),
148 | inputSchema: zodToJsonSchema(
149 | RemoveMultipleDirectoryArgsSchema
150 | ) as ToolInput
151 | },
152 | {
153 | name: 'move_file',
154 | description: moveFileDirectoryPrompt(),
155 | inputSchema: zodToJsonSchema(MoveFileArgsSchema) as ToolInput
156 | },
157 | {
158 | name: 'full_text_search',
159 | description: fullTextSearchDirectoryPrompt(),
160 | inputSchema: zodToJsonSchema(FullTextSearchArgsSchema) as ToolInput
161 | }
162 | ]
163 | }
164 | })
165 |
166 | server.setRequestHandler(CallToolRequestSchema, async (request) => {
167 | try {
168 | const { name, arguments: args } = request.params
169 |
170 | switch (name) {
171 | case 'read_file': {
172 | return readFile(args)
173 | }
174 |
175 | case 'read_multiple_files': {
176 | return readMultipleFiles(args)
177 | }
178 |
179 | case 'write_file': {
180 | return writeFile(args)
181 | }
182 |
183 | case 'edit_file': {
184 | return editFile(args)
185 | }
186 |
187 | case 'remove_file': {
188 | return removeFile(args)
189 | }
190 |
191 | case 'remove_multiple_files': {
192 | return removeMultipleFiles(args)
193 | }
194 |
195 | case 'create_directory': {
196 | return createDirectory(args)
197 | }
198 |
199 | case 'list_directory': {
200 | return listDirectory(args)
201 | }
202 |
203 | case 'remove_directory': {
204 | return removeDirectory(args)
205 | }
206 |
207 | case 'remove_multiple_directory': {
208 | return removeMultipleDirectory(args)
209 | }
210 |
211 | case 'move_file': {
212 | return moveFile(args)
213 | }
214 |
215 | case 'full_text_search': {
216 | return fullTextSearch(args)
217 | }
218 |
219 | default:
220 | throw new Error(`Unknown tool: ${name}`)
221 | }
222 | } catch (error) {
223 | const errorMessage = error instanceof Error ? error.message : String(error)
224 | return {
225 | content: [{ type: 'text', text: `Error: ${errorMessage}` }],
226 | isError: true
227 | }
228 | }
229 | })
230 |
231 | async function main() {
232 | const transport = new StdioServerTransport()
233 | await server.connect(transport)
234 | }
235 |
236 | main().catch((error) => {
237 | console.error('Fatal error in main():', error)
238 | process.exit(1)
239 | })
240 |
```
--------------------------------------------------------------------------------
/src/file-system.ts:
--------------------------------------------------------------------------------
```typescript
1 | // @ts-expect-error FIXME:
2 | // It says: Could not find a declaration file for module 'flexsearch'. But after installing @type/flexsearch still doesn't work.
3 | import flexsearch from 'flexsearch'
4 | import fs from 'fs/promises'
5 | import { glob } from 'glob'
6 | import matter from 'gray-matter'
7 | import mime from 'mime'
8 | import path from 'path'
9 | import removeMd from 'remove-markdown'
10 | import { rimraf } from 'rimraf'
11 | import { fileURLToPath } from 'url'
12 | import {
13 | CreateDirectoryArgsSchema,
14 | EditFileArgsSchema,
15 | FullTextSearchArgsSchema,
16 | ListDirectoryArgsSchema,
17 | MoveFileArgsSchema,
18 | ReadFileArgsSchema,
19 | ReadMultipleFilesArgsSchema,
20 | RemoveDirectoryArgsSchema,
21 | RemoveFileArgsSchema,
22 | RemoveMultipleDirectoryArgsSchema,
23 | RemoveMultipleFilesArgsSchema,
24 | WriteFileArgsSchema
25 | } from './schemas.js'
26 | import { DirectoryNode, Resource } from './types.js'
27 |
28 | export async function flattenDirectory(
29 | directoryPath: string
30 | ): Promise<Resource[]> {
31 | const flattenedFiles: Resource[] = []
32 |
33 | async function traverseDirectory(currentPath: string, relativeDir: string) {
34 | try {
35 | const entries = await fs.readdir(currentPath, { withFileTypes: true })
36 |
37 | for (const entry of entries) {
38 | const fullPath = path.join(currentPath, entry.name)
39 | const relativeName = path.join(relativeDir, entry.name)
40 |
41 | if (entry.isFile()) {
42 | const fileUrl = new URL(`file://${path.resolve(fullPath)}`).toString()
43 | const mimeType = mime.getType(fullPath) || 'application/octet-stream'
44 |
45 | flattenedFiles.push({
46 | uri: fileUrl,
47 | name: entry.name,
48 | mimeType
49 | })
50 | } else if (entry.isDirectory()) {
51 | await traverseDirectory(fullPath, relativeName)
52 | }
53 | }
54 | } catch (error) {
55 | console.error(
56 | `Error reading directory ${currentPath}:`,
57 | error instanceof Error ? error.message : error
58 | )
59 | }
60 | }
61 |
62 | const absoluteDirectoryPath = path.resolve(directoryPath)
63 | await traverseDirectory(
64 | absoluteDirectoryPath,
65 | path.basename(absoluteDirectoryPath)
66 | )
67 |
68 | return flattenedFiles
69 | }
70 |
71 | export async function readFileFromUri(fileUri: string) {
72 | try {
73 | const fileUrl = new URL(fileUri)
74 | if (fileUrl.protocol !== 'file:') {
75 | throw new Error('Invalid URL protocol. Only file:// URLs are supported.')
76 | }
77 | const filePath = fileURLToPath(fileUrl)
78 | const content = await fs.readFile(filePath, 'utf-8')
79 | return {
80 | contents: [
81 | {
82 | uri: fileUri,
83 | mimeType: mime.getType(filePath) || 'application/octet-stream',
84 | text: content
85 | }
86 | ]
87 | }
88 | } catch (error) {
89 | console.error(
90 | `Error reading file ${fileUri}:`,
91 | error instanceof Error ? error.message : error
92 | )
93 |
94 | return null
95 | }
96 | }
97 |
98 | export async function getDirectoryTree(
99 | directoryPath: string
100 | ): Promise<DirectoryNode | null> {
101 | async function traverseDirectory(
102 | currentPath: string,
103 | currentName: string
104 | ): Promise<DirectoryNode | null> {
105 | try {
106 | const entries = await fs.readdir(currentPath, { withFileTypes: true })
107 | const node: DirectoryNode = {
108 | name: currentName,
109 | type: 'directory',
110 | children: []
111 | }
112 |
113 | for (const entry of entries) {
114 | const fullPath = path.join(currentPath, entry.name)
115 |
116 | if (entry.isFile()) {
117 | const fileUrl = new URL(`file://${path.resolve(fullPath)}`).toString()
118 | const mimeType = mime.getType(fullPath) || 'application/octet-stream'
119 | node.children.push({
120 | name: entry.name,
121 | type: 'file',
122 | uri: fileUrl,
123 | mimeType
124 | })
125 | } else if (entry.isDirectory()) {
126 | const childNode = await traverseDirectory(fullPath, entry.name)
127 | if (childNode) {
128 | node.children.push(childNode)
129 | }
130 | }
131 | }
132 | return node
133 | } catch (error) {
134 | console.error(
135 | `Error reading directory ${currentPath}:`,
136 | error instanceof Error ? error.message : error
137 | )
138 | return null
139 | }
140 | }
141 |
142 | try {
143 | const absoluteDirectoryPath = path.resolve(directoryPath)
144 | const baseName = path.basename(absoluteDirectoryPath)
145 | const tree = await traverseDirectory(absoluteDirectoryPath, baseName)
146 | return tree
147 | } catch (error) {
148 | console.error(
149 | `Error processing directory ${directoryPath}:`,
150 | error instanceof Error ? error.message : error
151 | )
152 | return null
153 | }
154 | }
155 |
156 | export async function getFileStats(filePath: string) {
157 | try {
158 | const stats = await fs.stat(filePath)
159 | return {
160 | size: stats.size,
161 | created: stats.birthtime,
162 | modified: stats.mtime,
163 | accessed: stats.atime,
164 | isDirectory: stats.isDirectory(),
165 | isFile: stats.isFile(),
166 | permissions: stats.mode.toString(8).slice(-3)
167 | }
168 | } catch (error) {
169 | const errorMessage = error instanceof Error ? error.message : String(error)
170 | return {
171 | content: [{ type: 'text', text: `Error: ${errorMessage}` }],
172 | isError: true
173 | }
174 | }
175 | }
176 |
177 | export async function getAllMarkdownPaths(rootPaths: string[]) {
178 | const filePaths = (
179 | await Promise.all(rootPaths.map((rootPath) => glob(`${rootPath}/**/*.md`)))
180 | ).flat()
181 |
182 | return filePaths
183 | }
184 |
185 | export async function readMarkdown(filePath: string) {
186 | const content = await fs.readFile(filePath, 'utf-8')
187 | const frontMatter = matter(content)
188 |
189 | return {
190 | id: filePath,
191 | title:
192 | (frontMatter.data.title as string | undefined) ??
193 | path.basename(filePath, '.md'),
194 | content: removeMd(content)
195 | }
196 | }
197 |
198 | export async function readAllMarkdowns(filePaths: string[]) {
199 | const markdowns = await Promise.all(
200 | filePaths.map((filePath) => readMarkdown(filePath))
201 | )
202 |
203 | return markdowns
204 | }
205 |
206 | export async function readFile(args?: Record<string, unknown>) {
207 | const parsed = ReadFileArgsSchema.safeParse(args)
208 | if (!parsed.success) {
209 | throw new Error(`Invalid arguments for read_file: ${parsed.error}`)
210 | }
211 |
212 | const content = await fs.readFile(parsed.data.path, 'utf-8')
213 | return {
214 | content: [{ type: 'text', text: content }]
215 | }
216 | }
217 |
218 | export async function readMultipleFiles(args?: Record<string, unknown>) {
219 | const parsed = ReadMultipleFilesArgsSchema.safeParse(args)
220 | if (!parsed.success) {
221 | throw new Error(
222 | `Invalid arguments for read_multiple_files: ${parsed.error}`
223 | )
224 | }
225 |
226 | const results = await Promise.all(
227 | parsed.data.paths.map(async (filePath: string) => {
228 | const content = await fs.readFile(filePath, 'utf-8')
229 | return `${filePath}:\n${content}\n`
230 | })
231 | )
232 | return {
233 | content: [{ type: 'text', text: results.join('\n---\n') }]
234 | }
235 | }
236 |
237 | export async function writeFile(args?: Record<string, unknown>) {
238 | const parsed = WriteFileArgsSchema.safeParse(args)
239 | if (!parsed.success) {
240 | throw new Error(`Invalid arguments for write_file: ${parsed.error}`)
241 | }
242 |
243 | await fs.writeFile(parsed.data.path, parsed.data.content, 'utf-8')
244 | return {
245 | content: [
246 | { type: 'text', text: `Successfully wrote to ${parsed.data.path}` }
247 | ]
248 | }
249 | }
250 |
251 | export async function editFile(args?: Record<string, unknown>) {
252 | const parsed = EditFileArgsSchema.safeParse(args)
253 | if (!parsed.success) {
254 | throw new Error(`Invalid arguments for edit_file: ${parsed.error}`)
255 | }
256 |
257 | if (!parsed.data.dryRun) {
258 | await fs.writeFile(parsed.data.path, parsed.data.newText)
259 | }
260 |
261 | return {
262 | content: [{ type: 'text', text: parsed.data.newText }]
263 | }
264 | }
265 |
266 | export async function removeFile(args?: Record<string, unknown>) {
267 | const parsed = RemoveFileArgsSchema.safeParse(args)
268 | if (!parsed.success) {
269 | throw new Error(`Invalid arguments for remove_file: ${parsed.error}`)
270 | }
271 | const result = await fs.unlink(parsed.data.path)
272 | return {
273 | content: [{ type: 'text', text: result }]
274 | }
275 | }
276 |
277 | export async function removeMultipleFiles(args?: Record<string, unknown>) {
278 | const parsed = RemoveMultipleFilesArgsSchema.safeParse(args)
279 | if (!parsed.success) {
280 | throw new Error(
281 | `Invalid arguments for remove_multiple_files: ${parsed.error}`
282 | )
283 | }
284 | const result = await Promise.all(
285 | parsed.data.paths.map((path) => fs.unlink(path))
286 | )
287 |
288 | return {
289 | content: [{ type: 'text', text: result }]
290 | }
291 | }
292 |
293 | export async function createDirectory(args?: Record<string, unknown>) {
294 | const parsed = CreateDirectoryArgsSchema.safeParse(args)
295 | if (!parsed.success) {
296 | throw new Error(`Invalid arguments for create_directory: ${parsed.error}`)
297 | }
298 | await fs.mkdir(parsed.data.path, { recursive: true })
299 | return {
300 | content: [
301 | {
302 | type: 'text',
303 | text: `Successfully created directory ${parsed.data.path}`
304 | }
305 | ]
306 | }
307 | }
308 |
309 | export async function listDirectory(args?: Record<string, unknown>) {
310 | const parsed = ListDirectoryArgsSchema.safeParse(args)
311 | if (!parsed.success) {
312 | throw new Error(`Invalid arguments for list_directory: ${parsed.error}`)
313 | }
314 | const entries = await fs.readdir(parsed.data.path, {
315 | withFileTypes: true
316 | })
317 | const formatted = entries
318 | .map((entry) => `${entry.isDirectory() ? '[DIR]' : '[FILE]'} ${entry.name}`)
319 | .join('\n')
320 | return {
321 | content: [{ type: 'text', text: formatted }]
322 | }
323 | }
324 |
325 | export async function removeDirectory(args?: Record<string, unknown>) {
326 | const parsed = RemoveDirectoryArgsSchema.safeParse(args)
327 | if (!parsed.success) {
328 | throw new Error(`Invalid arguments for remove_directory: ${parsed.error}`)
329 | }
330 | const result = await rimraf(parsed.data.path)
331 | return {
332 | content: [{ type: 'text', text: result }]
333 | }
334 | }
335 |
336 | export async function removeMultipleDirectory(args?: Record<string, unknown>) {
337 | const parsed = RemoveMultipleDirectoryArgsSchema.safeParse(args)
338 | if (!parsed.success) {
339 | throw new Error(`Invalid arguments for edit_file: ${parsed.error}`)
340 | }
341 | const result = await Promise.all(
342 | parsed.data.paths.map((path) => rimraf(path))
343 | )
344 |
345 | return {
346 | content: [{ type: 'text', text: result }]
347 | }
348 | }
349 |
350 | export async function moveFile(args?: Record<string, unknown>) {
351 | const parsed = MoveFileArgsSchema.safeParse(args)
352 | if (!parsed.success) {
353 | throw new Error(`Invalid arguments for move_file: ${parsed.error}`)
354 | }
355 | await fs.rename(parsed.data.source, parsed.data.destination)
356 | return {
357 | content: [
358 | {
359 | type: 'text',
360 | text: `Successfully moved ${parsed.data.source} to ${parsed.data.destination}`
361 | }
362 | ]
363 | }
364 | }
365 |
366 | // TODO: Build index phrase should be mounted on service start, rather than a single request.
367 | export async function fullTextSearch(args?: Record<string, unknown>) {
368 | const parsed = FullTextSearchArgsSchema.safeParse(args)
369 | if (!parsed.success) {
370 | throw new Error(`Invalid arguments for full_text_search: ${parsed.error}`)
371 | }
372 |
373 | const filePaths = await getAllMarkdownPaths(process.argv.slice(2))
374 | const documents = await readAllMarkdowns(filePaths)
375 |
376 | const index = new flexsearch.Document({
377 | document: {
378 | id: 'id',
379 | store: true,
380 | index: [
381 | {
382 | field: 'title',
383 | tokenize: 'forward',
384 | encoder: flexsearch.Charset.LatinBalance
385 | },
386 | {
387 | field: 'content',
388 | tokenize: 'forward',
389 | encoder: flexsearch.Charset.LatinBalance
390 | }
391 | ]
392 | }
393 | })
394 |
395 | documents.forEach((file) => {
396 | index.add(file)
397 | })
398 |
399 | const searchedIds = index.search(parsed.data.query, { limit: 5 })
400 | const filteredDocuments = documents
401 | .filter(({ id }) => searchedIds[0].result.includes(id))
402 | .map((document) => document.content)
403 | return {
404 | content: [
405 | {
406 | type: 'text',
407 | text:
408 | filteredDocuments.length > 0
409 | ? filteredDocuments.join('\n---\n')
410 | : 'No matches found'
411 | }
412 | ]
413 | }
414 | }
415 |
```