obsidian-mcp-server 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/publish.yml +35 -0
- package/README.md +171 -0
- package/build/index.js +11 -0
- package/build/obsidian.js +197 -0
- package/build/server.js +211 -0
- package/build/tools.js +381 -0
- package/build/types.js +20 -0
- package/package.json +55 -0
- package/src/index.ts +12 -0
- package/src/obsidian.ts +235 -0
- package/src/server.ts +277 -0
- package/src/tools.ts +429 -0
- package/src/types.ts +99 -0
- package/tsconfig.json +17 -0
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
name: Publish Package to npm
|
|
2
|
+
on:
|
|
3
|
+
push:
|
|
4
|
+
tags:
|
|
5
|
+
- 'v*'
|
|
6
|
+
|
|
7
|
+
jobs:
|
|
8
|
+
build-and-publish:
|
|
9
|
+
runs-on: ubuntu-latest
|
|
10
|
+
permissions:
|
|
11
|
+
contents: read
|
|
12
|
+
packages: write
|
|
13
|
+
steps:
|
|
14
|
+
- uses: actions/checkout@v4
|
|
15
|
+
|
|
16
|
+
- name: Setup Node.js
|
|
17
|
+
uses: actions/setup-node@v4
|
|
18
|
+
with:
|
|
19
|
+
node-version: '18.x'
|
|
20
|
+
registry-url: 'https://registry.npmjs.org'
|
|
21
|
+
scope: '@cyanheads'
|
|
22
|
+
|
|
23
|
+
- name: Install dependencies
|
|
24
|
+
run: npm ci
|
|
25
|
+
|
|
26
|
+
- name: Build
|
|
27
|
+
run: npm run build
|
|
28
|
+
|
|
29
|
+
- name: Publish to npm
|
|
30
|
+
# Requires NPM_TOKEN secret to be set in repository settings
|
|
31
|
+
# Generate token at https://www.npmjs.com/settings/[username]/tokens
|
|
32
|
+
# and add to repository secrets with read/write packages permission
|
|
33
|
+
run: npm publish --access public
|
|
34
|
+
env:
|
|
35
|
+
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
package/README.md
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
# Obsidian MCP Server
|
|
2
|
+
|
|
3
|
+
[](https://www.typescriptlang.org/)
|
|
4
|
+
[](https://modelcontextprotocol.io/)
|
|
5
|
+
[]()
|
|
6
|
+
[](https://opensource.org/licenses/Apache-2.0)
|
|
7
|
+
[]()
|
|
8
|
+
[](https://github.com/cyanheads/obsidian-mcp-server)
|
|
9
|
+
|
|
10
|
+
A Model Context Protocol server designed for LLMs to interact with Obsidian vaults. Built with TypeScript and featuring secure API communication, efficient file operations, and comprehensive search capabilities, it enables AI assistants to seamlessly manage knowledge bases through a clean, flexible tool interface.
|
|
11
|
+
|
|
12
|
+
The Model Context Protocol (MCP) enables AI models to interact with external tools and resources through a standardized interface. This server implements MCP to provide LLMs with secure, token-aware access to Obsidian vaults.
|
|
13
|
+
|
|
14
|
+
Requires the Local REST API plugin in Obsidian.
|
|
15
|
+
|
|
16
|
+
## Features
|
|
17
|
+
|
|
18
|
+
### File Operations
|
|
19
|
+
- Path-based file/directory management with atomic updates
|
|
20
|
+
- Content read/write operations with validation
|
|
21
|
+
- Resource monitoring and cleanup
|
|
22
|
+
|
|
23
|
+
### Search System
|
|
24
|
+
- Full-text and JsonLogic-based complex search
|
|
25
|
+
- Configurable context boundaries and token limits
|
|
26
|
+
- Optimized query processing
|
|
27
|
+
|
|
28
|
+
### Security & Performance
|
|
29
|
+
- API key authentication and rate limiting
|
|
30
|
+
- SSL verification options
|
|
31
|
+
- Resource management and health monitoring
|
|
32
|
+
|
|
33
|
+
## Installation
|
|
34
|
+
|
|
35
|
+
1. Install Node.js (LTS recommended)
|
|
36
|
+
2. Enable Local REST API plugin in Obsidian
|
|
37
|
+
3. Clone and build:
|
|
38
|
+
```bash
|
|
39
|
+
git clone git@github.com:cyanheads/obsidian-mcp-server.git
|
|
40
|
+
cd obsidian-mcp-server
|
|
41
|
+
npm install
|
|
42
|
+
npm run build
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Or install from npm:
|
|
46
|
+
```bash
|
|
47
|
+
npm install obsidian-mcp-server
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## Configuration
|
|
51
|
+
|
|
52
|
+
Add to your MCP client settings:
|
|
53
|
+
|
|
54
|
+
```json
|
|
55
|
+
{
|
|
56
|
+
"mcpServers": {
|
|
57
|
+
"obsidian": {
|
|
58
|
+
"command": "node",
|
|
59
|
+
"args": ["/path/to/obsidian-mcp-server/build/index.js"],
|
|
60
|
+
"env": {
|
|
61
|
+
"OBSIDIAN_API_KEY": "your-api-key-here",
|
|
62
|
+
"NODE_ENV": "production"
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Environment configuration:
|
|
70
|
+
- `OBSIDIAN_VERIFY_SSL`: Enable SSL verification (default: false)
|
|
71
|
+
- `RATE_LIMIT_WINDOW_MS`: Rate limit window in ms (default: 15 minutes)
|
|
72
|
+
- `RATE_LIMIT_MAX_REQUESTS`: Max requests per window (default: 200)
|
|
73
|
+
- `MAX_TOKENS`: Maximum tokens per response (default: 20000)
|
|
74
|
+
- `TOOL_TIMEOUT_MS`: Tool execution timeout (default: 60000)
|
|
75
|
+
|
|
76
|
+
## Tools
|
|
77
|
+
|
|
78
|
+
### File Management
|
|
79
|
+
```typescript
|
|
80
|
+
// List vault contents
|
|
81
|
+
obsidian_list_files_in_vault: {}
|
|
82
|
+
|
|
83
|
+
// List directory contents
|
|
84
|
+
obsidian_list_files_in_dir: {
|
|
85
|
+
dirpath: string // Path relative to vault root
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Get file contents
|
|
89
|
+
obsidian_get_file_contents: {
|
|
90
|
+
filepath: string // Path relative to vault root
|
|
91
|
+
}
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
### Search Operations
|
|
95
|
+
```typescript
|
|
96
|
+
// Text search with context
|
|
97
|
+
obsidian_find_in_file: {
|
|
98
|
+
query: string,
|
|
99
|
+
contextLength?: number // Default: 10
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Advanced search with JsonLogic
|
|
103
|
+
obsidian_complex_search: {
|
|
104
|
+
query: JsonLogicQuery // Example: {"glob": ["*.md", {"var": "path"}]}
|
|
105
|
+
}
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
### Content Modification
|
|
109
|
+
```typescript
|
|
110
|
+
// Append to file
|
|
111
|
+
obsidian_append_content: {
|
|
112
|
+
filepath: string, // Path relative to vault root
|
|
113
|
+
content: string // Content to append
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Update file content
|
|
117
|
+
obsidian_patch_content: {
|
|
118
|
+
filepath: string, // Path relative to vault root
|
|
119
|
+
content: string // New content (replaces existing)
|
|
120
|
+
}
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
## Best Practices
|
|
124
|
+
|
|
125
|
+
### File Operations
|
|
126
|
+
- Use atomic operations
|
|
127
|
+
- Validate content before modifications
|
|
128
|
+
- Implement proper error handling
|
|
129
|
+
- Monitor operation performance
|
|
130
|
+
|
|
131
|
+
### Search Implementation
|
|
132
|
+
- Optimize query specificity
|
|
133
|
+
- Control context boundaries
|
|
134
|
+
- Handle large result sets
|
|
135
|
+
- Consider token limits
|
|
136
|
+
|
|
137
|
+
### Error Prevention
|
|
138
|
+
- Validate inputs thoroughly
|
|
139
|
+
- Handle API errors gracefully
|
|
140
|
+
- Monitor error patterns
|
|
141
|
+
- Check rate limits
|
|
142
|
+
|
|
143
|
+
## Contributing
|
|
144
|
+
|
|
145
|
+
1. Fork the repository
|
|
146
|
+
2. Create a feature branch
|
|
147
|
+
3. Submit a Pull Request
|
|
148
|
+
|
|
149
|
+
For bugs and features, create an issue at [https://github.com/cyanheads/obsidian-mcp-server/issues](https://github.com/cyanheads/obsidian-mcp-server/issues).
|
|
150
|
+
|
|
151
|
+
## Publishing
|
|
152
|
+
|
|
153
|
+
The package is automatically published to npm when version tags are pushed:
|
|
154
|
+
|
|
155
|
+
```bash
|
|
156
|
+
# Update version in package.json
|
|
157
|
+
npm version patch # or minor, or major
|
|
158
|
+
git push --follow-tags
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
This will trigger the GitHub Action to build and publish the package.
|
|
162
|
+
|
|
163
|
+
## License
|
|
164
|
+
|
|
165
|
+
Apache License 2.0
|
|
166
|
+
|
|
167
|
+
---
|
|
168
|
+
|
|
169
|
+
<div align="center">
|
|
170
|
+
Built with the Model Context Protocol
|
|
171
|
+
</div>
|
package/build/index.js
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { run } from "./server.js";
|
|
3
|
+
// Main entry point
|
|
4
|
+
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
5
|
+
run().catch((error) => {
|
|
6
|
+
console.error("Failed to start server:", error);
|
|
7
|
+
process.exit(1);
|
|
8
|
+
});
|
|
9
|
+
}
|
|
10
|
+
export { run };
|
|
11
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import axios from "axios";
|
|
2
|
+
import { ObsidianError, DEFAULT_OBSIDIAN_CONFIG } from "./types.js";
|
|
3
|
+
import { Agent } from "node:https";
|
|
4
|
+
import { readFileSync } from "fs";
|
|
5
|
+
import { fileURLToPath } from 'url';
|
|
6
|
+
import { dirname, join } from "path";
|
|
7
|
+
// Get package version for user agent
|
|
8
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
9
|
+
const __dirname = dirname(__filename);
|
|
10
|
+
const packagePath = join(__dirname, '..', '..', 'package.json');
|
|
11
|
+
const VERSION = (() => {
|
|
12
|
+
try {
|
|
13
|
+
const pkg = JSON.parse(readFileSync(packagePath, 'utf-8'));
|
|
14
|
+
return pkg.version;
|
|
15
|
+
}
|
|
16
|
+
catch (error) {
|
|
17
|
+
console.warn('Could not read package.json version:', error);
|
|
18
|
+
return '1.1.0'; // Fallback version
|
|
19
|
+
}
|
|
20
|
+
})();
|
|
21
|
+
export class ObsidianClient {
|
|
22
|
+
client;
|
|
23
|
+
config;
|
|
24
|
+
constructor(config) {
|
|
25
|
+
if (!config.apiKey) {
|
|
26
|
+
throw new ObsidianError("API key is required", 401);
|
|
27
|
+
}
|
|
28
|
+
// Combine defaults with provided config
|
|
29
|
+
this.config = {
|
|
30
|
+
...DEFAULT_OBSIDIAN_CONFIG,
|
|
31
|
+
verifySSL: config.verifySSL ?? process.env.NODE_ENV === 'production', // Enable SSL verification in production by default
|
|
32
|
+
apiKey: config.apiKey,
|
|
33
|
+
timeout: config.timeout ?? 5000,
|
|
34
|
+
maxContentLength: config.maxContentLength ?? 50 * 1024 * 1024, // 50MB
|
|
35
|
+
maxBodyLength: config.maxBodyLength ?? 50 * 1024 * 1024 // 50MB
|
|
36
|
+
};
|
|
37
|
+
// Configure HTTPS agent
|
|
38
|
+
const httpsAgent = new Agent({
|
|
39
|
+
rejectUnauthorized: this.config.verifySSL
|
|
40
|
+
});
|
|
41
|
+
const axiosConfig = {
|
|
42
|
+
baseURL: this.getBaseUrl(),
|
|
43
|
+
headers: {
|
|
44
|
+
...this.getHeaders(),
|
|
45
|
+
// Security headers
|
|
46
|
+
'X-Content-Type-Options': 'nosniff',
|
|
47
|
+
'X-Frame-Options': 'DENY',
|
|
48
|
+
'X-XSS-Protection': '1; mode=block',
|
|
49
|
+
'Strict-Transport-Security': 'max-age=31536000; includeSubDomains'
|
|
50
|
+
},
|
|
51
|
+
validateStatus: (status) => status >= 200 && status < 300,
|
|
52
|
+
timeout: this.config.timeout,
|
|
53
|
+
maxRedirects: 5,
|
|
54
|
+
maxContentLength: this.config.maxContentLength,
|
|
55
|
+
maxBodyLength: this.config.maxBodyLength,
|
|
56
|
+
httpsAgent,
|
|
57
|
+
// Additional security configurations
|
|
58
|
+
xsrfCookieName: 'XSRF-TOKEN',
|
|
59
|
+
xsrfHeaderName: 'X-XSRF-TOKEN',
|
|
60
|
+
withCredentials: true,
|
|
61
|
+
decompress: true
|
|
62
|
+
};
|
|
63
|
+
if (!this.config.verifySSL) {
|
|
64
|
+
console.warn("WARNING: SSL verification is disabled. This is not recommended for production use.", process.env.NODE_ENV === 'production' ? "Consider enabling SSL verification." : "This is acceptable for local development only.");
|
|
65
|
+
}
|
|
66
|
+
this.client = axios.create(axiosConfig);
|
|
67
|
+
}
|
|
68
|
+
getBaseUrl() {
|
|
69
|
+
return `${this.config.protocol}://${this.config.host}:${this.config.port}`;
|
|
70
|
+
}
|
|
71
|
+
getHeaders() {
|
|
72
|
+
const headers = {
|
|
73
|
+
Authorization: `Bearer ${this.config.apiKey}`,
|
|
74
|
+
'Accept': 'application/json',
|
|
75
|
+
'User-Agent': `obsidian-mcp-server/${VERSION}`
|
|
76
|
+
};
|
|
77
|
+
// Sanitize headers
|
|
78
|
+
return Object.fromEntries(Object.entries(headers).map(([key, value]) => [
|
|
79
|
+
key,
|
|
80
|
+
this.sanitizeHeader(value)
|
|
81
|
+
]));
|
|
82
|
+
}
|
|
83
|
+
sanitizeHeader(value) {
|
|
84
|
+
// Remove any potentially harmful characters from header values
|
|
85
|
+
return value.replace(/[^\w\s\-\._~:/?#\[\]@!$&'()*+,;=]/g, '');
|
|
86
|
+
}
|
|
87
|
+
validateFilePath(filepath) {
|
|
88
|
+
// Prevent path traversal attacks
|
|
89
|
+
const normalizedPath = filepath.replace(/\\/g, '/');
|
|
90
|
+
if (normalizedPath.includes('../') || normalizedPath.includes('..\\')) {
|
|
91
|
+
throw new ObsidianError('Invalid file path: Path traversal not allowed', 400);
|
|
92
|
+
}
|
|
93
|
+
// Additional path validations
|
|
94
|
+
if (normalizedPath.startsWith('/') || /^[a-zA-Z]:/.test(normalizedPath)) {
|
|
95
|
+
throw new ObsidianError('Invalid file path: Absolute paths not allowed', 400);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
async safeRequest(operation) {
|
|
99
|
+
try {
|
|
100
|
+
return await operation();
|
|
101
|
+
}
|
|
102
|
+
catch (error) {
|
|
103
|
+
if (axios.isAxiosError(error)) {
|
|
104
|
+
const axiosError = error;
|
|
105
|
+
const response = axiosError.response;
|
|
106
|
+
const errorData = response?.data;
|
|
107
|
+
const code = errorData?.errorCode ?? response?.status ?? 500;
|
|
108
|
+
const message = errorData?.message ?? axiosError.message ?? "Unknown error";
|
|
109
|
+
throw new ObsidianError(message, code, errorData);
|
|
110
|
+
}
|
|
111
|
+
throw error;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
async listFilesInVault() {
|
|
115
|
+
return this.safeRequest(async () => {
|
|
116
|
+
const requestId = crypto.randomUUID();
|
|
117
|
+
console.debug(`[${requestId}] Listing vault files`);
|
|
118
|
+
const response = await this.client.get("/vault/");
|
|
119
|
+
return response.data.files;
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
async listFilesInDir(dirpath) {
|
|
123
|
+
this.validateFilePath(dirpath);
|
|
124
|
+
return this.safeRequest(async () => {
|
|
125
|
+
const requestId = crypto.randomUUID();
|
|
126
|
+
console.debug(`[${requestId}] Listing files in directory: ${dirpath}`);
|
|
127
|
+
const response = await this.client.get(`/vault/${dirpath}/`);
|
|
128
|
+
return response.data.files;
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
async getFileContents(filepath) {
|
|
132
|
+
this.validateFilePath(filepath);
|
|
133
|
+
return this.safeRequest(async () => {
|
|
134
|
+
const requestId = crypto.randomUUID();
|
|
135
|
+
console.debug(`[${requestId}] Getting file contents: ${filepath}`);
|
|
136
|
+
const response = await this.client.get(`/vault/${filepath}`);
|
|
137
|
+
return response.data;
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
async search(query, contextLength = 100) {
|
|
141
|
+
return this.safeRequest(async () => {
|
|
142
|
+
const requestId = crypto.randomUUID();
|
|
143
|
+
console.debug(`[${requestId}] Performing simple search: ${query}`);
|
|
144
|
+
const response = await this.client.post("/search/simple/", undefined, {
|
|
145
|
+
params: {
|
|
146
|
+
query,
|
|
147
|
+
contextLength
|
|
148
|
+
}
|
|
149
|
+
});
|
|
150
|
+
return response.data;
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
async appendContent(filepath, content) {
|
|
154
|
+
this.validateFilePath(filepath);
|
|
155
|
+
if (!content || typeof content !== 'string') {
|
|
156
|
+
throw new ObsidianError('Invalid content: Content must be a non-empty string', 400);
|
|
157
|
+
}
|
|
158
|
+
return this.safeRequest(async () => {
|
|
159
|
+
const requestId = crypto.randomUUID();
|
|
160
|
+
console.debug(`[${requestId}] Appending content to: ${filepath}`);
|
|
161
|
+
await this.client.post(`/vault/${filepath}`, content, {
|
|
162
|
+
headers: {
|
|
163
|
+
"Content-Type": "text/markdown"
|
|
164
|
+
}
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
async updateContent(filepath, content) {
|
|
169
|
+
this.validateFilePath(filepath);
|
|
170
|
+
if (!content || typeof content !== 'string') {
|
|
171
|
+
throw new ObsidianError('Invalid content: Content must be a non-empty string', 400);
|
|
172
|
+
}
|
|
173
|
+
return this.safeRequest(async () => {
|
|
174
|
+
const requestId = crypto.randomUUID();
|
|
175
|
+
console.debug(`[${requestId}] Updating content in: ${filepath}`);
|
|
176
|
+
await this.client.put(`/vault/${filepath}`, content, {
|
|
177
|
+
headers: {
|
|
178
|
+
"Content-Type": "text/markdown"
|
|
179
|
+
}
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
async searchJson(query) {
|
|
184
|
+
return this.safeRequest(async () => {
|
|
185
|
+
const requestId = crypto.randomUUID();
|
|
186
|
+
console.debug(`[${requestId}] Performing complex search with query:`, JSON.stringify(query));
|
|
187
|
+
const response = await this.client.post("/search/", query, {
|
|
188
|
+
headers: {
|
|
189
|
+
"Content-Type": "application/vnd.olrapi.jsonlogic+json",
|
|
190
|
+
"Accept": "application/json"
|
|
191
|
+
}
|
|
192
|
+
});
|
|
193
|
+
return response.data;
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
//# sourceMappingURL=obsidian.js.map
|
package/build/server.js
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import { config } from "dotenv";
|
|
2
|
+
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
3
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
4
|
+
import { ListToolsRequestSchema, CallToolRequestSchema } from "@modelcontextprotocol/sdk/types.js";
|
|
5
|
+
import { ObsidianClient } from "./obsidian.js";
|
|
6
|
+
import { ObsidianError, DEFAULT_RATE_LIMIT_CONFIG } from "./types.js";
|
|
7
|
+
import { ListFilesInVaultToolHandler, ListFilesInDirToolHandler, GetFileContentsToolHandler, FindInFileToolHandler, AppendContentToolHandler, PatchContentToolHandler, ComplexSearchToolHandler } from "./tools.js";
|
|
8
|
+
// Load environment variables
|
|
9
|
+
config();
|
|
10
|
+
const API_KEY = process.env.OBSIDIAN_API_KEY;
|
|
11
|
+
if (!API_KEY) {
|
|
12
|
+
throw new Error("OBSIDIAN_API_KEY environment variable is required");
|
|
13
|
+
}
|
|
14
|
+
// Get rate limit config from environment or use defaults
|
|
15
|
+
const rateLimitConfig = {
|
|
16
|
+
windowMs: parseInt(process.env.RATE_LIMIT_WINDOW_MS ?? String(DEFAULT_RATE_LIMIT_CONFIG.windowMs)),
|
|
17
|
+
maxRequests: parseInt(process.env.RATE_LIMIT_MAX_REQUESTS ?? String(DEFAULT_RATE_LIMIT_CONFIG.maxRequests))
|
|
18
|
+
};
|
|
19
|
+
// Request tracking for rate limiting
|
|
20
|
+
const requestCounts = new Map();
|
|
21
|
+
function checkRateLimit(toolName) {
|
|
22
|
+
const now = Date.now();
|
|
23
|
+
const requestInfo = requestCounts.get(toolName);
|
|
24
|
+
if (!requestInfo || now > requestInfo.resetTime) {
|
|
25
|
+
// Reset counter for new window
|
|
26
|
+
requestCounts.set(toolName, {
|
|
27
|
+
count: 1,
|
|
28
|
+
resetTime: now + rateLimitConfig.windowMs
|
|
29
|
+
});
|
|
30
|
+
return true;
|
|
31
|
+
}
|
|
32
|
+
if (requestInfo.count >= rateLimitConfig.maxRequests) {
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
35
|
+
requestInfo.count++;
|
|
36
|
+
return true;
|
|
37
|
+
}
|
|
38
|
+
// Clean up expired rate limit entries periodically
|
|
39
|
+
const cleanupInterval = setInterval(() => {
|
|
40
|
+
const now = Date.now();
|
|
41
|
+
for (const [tool, info] of requestCounts.entries()) {
|
|
42
|
+
if (now > info.resetTime) {
|
|
43
|
+
requestCounts.delete(tool);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}, 60000); // Clean up every minute
|
|
47
|
+
// Initialize Obsidian client
|
|
48
|
+
const client = new ObsidianClient({
|
|
49
|
+
apiKey: API_KEY,
|
|
50
|
+
verifySSL: process.env.NODE_ENV === 'production' // Enable SSL verification in production
|
|
51
|
+
});
|
|
52
|
+
const toolHandlers = new Map();
|
|
53
|
+
const handlers = [
|
|
54
|
+
new ListFilesInVaultToolHandler(client),
|
|
55
|
+
new ListFilesInDirToolHandler(client),
|
|
56
|
+
new GetFileContentsToolHandler(client),
|
|
57
|
+
new FindInFileToolHandler(client),
|
|
58
|
+
new AppendContentToolHandler(client),
|
|
59
|
+
new PatchContentToolHandler(client),
|
|
60
|
+
new ComplexSearchToolHandler(client)
|
|
61
|
+
];
|
|
62
|
+
handlers.forEach(handler => toolHandlers.set(handler.name, handler));
|
|
63
|
+
// Create MCP server
|
|
64
|
+
const server = new Server({
|
|
65
|
+
name: "obsidian-mcp-server",
|
|
66
|
+
version: process.env.npm_package_version ?? "1.1.0" // Use version from package.json
|
|
67
|
+
}, {
|
|
68
|
+
capabilities: {
|
|
69
|
+
tools: {},
|
|
70
|
+
resources: {}
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
// Set up request handlers
|
|
74
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
75
|
+
const tools = [];
|
|
76
|
+
for (const handler of toolHandlers.values()) {
|
|
77
|
+
tools.push(handler.getToolDescription());
|
|
78
|
+
}
|
|
79
|
+
return { tools };
|
|
80
|
+
});
|
|
81
|
+
// Add validation helper
|
|
82
|
+
function validateToolArguments(args, schema) {
|
|
83
|
+
if (typeof args !== 'object' || args === null) {
|
|
84
|
+
return { valid: false, errors: ['Arguments must be an object'] };
|
|
85
|
+
}
|
|
86
|
+
const errors = [];
|
|
87
|
+
const required = schema.required || [];
|
|
88
|
+
// Check required fields
|
|
89
|
+
for (const field of required) {
|
|
90
|
+
if (!(field in args)) {
|
|
91
|
+
errors.push(`Missing required field: ${field}`);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
// Check field types
|
|
95
|
+
const properties = schema.properties || {};
|
|
96
|
+
for (const [key, value] of Object.entries(args)) {
|
|
97
|
+
const propSchema = properties[key];
|
|
98
|
+
if (!propSchema) {
|
|
99
|
+
errors.push(`Unknown field: ${key}`);
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
// Skip validation for undefined optional fields
|
|
103
|
+
if (value === undefined && !required.includes(key)) {
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
// Type validation
|
|
107
|
+
if (propSchema.type === 'string' && typeof value !== 'string') {
|
|
108
|
+
errors.push(`Field ${key} must be a string`);
|
|
109
|
+
}
|
|
110
|
+
else if (propSchema.type === 'number' && typeof value !== 'number') {
|
|
111
|
+
errors.push(`Field ${key} must be a number`);
|
|
112
|
+
}
|
|
113
|
+
else if (propSchema.type === 'boolean' && typeof value !== 'boolean') {
|
|
114
|
+
errors.push(`Field ${key} must be a boolean`);
|
|
115
|
+
}
|
|
116
|
+
else if (propSchema.type === 'array' && !Array.isArray(value)) {
|
|
117
|
+
errors.push(`Field ${key} must be an array`);
|
|
118
|
+
}
|
|
119
|
+
// Enum validation
|
|
120
|
+
if (propSchema.enum && value !== undefined && !propSchema.enum.includes(value)) {
|
|
121
|
+
errors.push(`Field ${key} must be one of: ${propSchema.enum.join(', ')}`);
|
|
122
|
+
}
|
|
123
|
+
// Format validation for paths
|
|
124
|
+
if (propSchema.format === 'path' && typeof value === 'string') {
|
|
125
|
+
// Prevent path traversal
|
|
126
|
+
if (value.includes('../') || value.includes('..\\')) {
|
|
127
|
+
errors.push(`Field ${key} contains invalid path traversal`);
|
|
128
|
+
}
|
|
129
|
+
// Prevent absolute paths
|
|
130
|
+
if (value.startsWith('/') || /^[a-zA-Z]:/.test(value)) {
|
|
131
|
+
errors.push(`Field ${key} must be a relative path`);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return { valid: errors.length === 0, errors };
|
|
136
|
+
}
|
|
137
|
+
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
138
|
+
const { name, arguments: args } = request.params;
|
|
139
|
+
const handler = toolHandlers.get(name);
|
|
140
|
+
if (!handler) {
|
|
141
|
+
throw new ObsidianError(`Unknown tool: ${name}`, 404);
|
|
142
|
+
}
|
|
143
|
+
// Check rate limit
|
|
144
|
+
if (!checkRateLimit(name)) {
|
|
145
|
+
throw new ObsidianError(`Rate limit exceeded for tool: ${name}. Please try again later.`, 429);
|
|
146
|
+
}
|
|
147
|
+
// Add timeout handling
|
|
148
|
+
const timeoutMs = parseInt(process.env.TOOL_TIMEOUT_MS ?? '60000'); // 60 second default timeout
|
|
149
|
+
const timeoutPromise = new Promise((_, reject) => {
|
|
150
|
+
setTimeout(() => {
|
|
151
|
+
reject(new ObsidianError(`Tool execution timed out after ${timeoutMs}ms`, 408));
|
|
152
|
+
}, timeoutMs);
|
|
153
|
+
});
|
|
154
|
+
try {
|
|
155
|
+
// Validate arguments against tool's schema
|
|
156
|
+
const toolDescription = handler.getToolDescription();
|
|
157
|
+
const validationResult = validateToolArguments(args, toolDescription.inputSchema);
|
|
158
|
+
if (!validationResult.valid) {
|
|
159
|
+
throw new ObsidianError(`Invalid tool arguments: ${validationResult.errors.join(', ')}`, 400);
|
|
160
|
+
}
|
|
161
|
+
// Race between tool execution and timeout
|
|
162
|
+
const content = await Promise.race([
|
|
163
|
+
handler.runTool(args),
|
|
164
|
+
timeoutPromise
|
|
165
|
+
]);
|
|
166
|
+
return { content };
|
|
167
|
+
}
|
|
168
|
+
catch (error) {
|
|
169
|
+
if (error instanceof ObsidianError) {
|
|
170
|
+
// Check if the operation actually succeeded despite the error
|
|
171
|
+
if (error.code === 204) {
|
|
172
|
+
return {
|
|
173
|
+
content: [{
|
|
174
|
+
type: "text",
|
|
175
|
+
text: "Operation completed successfully"
|
|
176
|
+
}]
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
throw error;
|
|
180
|
+
}
|
|
181
|
+
// Enhanced error logging
|
|
182
|
+
console.error("Tool execution error:", {
|
|
183
|
+
name: error instanceof Error ? error.name : 'Unknown',
|
|
184
|
+
message: error instanceof Error ? error.message : String(error),
|
|
185
|
+
stack: error instanceof Error ? error.stack : undefined,
|
|
186
|
+
toolName: name,
|
|
187
|
+
args
|
|
188
|
+
});
|
|
189
|
+
if (error instanceof Error) {
|
|
190
|
+
throw new ObsidianError(`Tool '${name}' execution failed: ${error.message}`, 500, { originalError: error.stack });
|
|
191
|
+
}
|
|
192
|
+
throw new ObsidianError("Tool execution failed with unknown error", 500, { error });
|
|
193
|
+
}
|
|
194
|
+
});
|
|
195
|
+
// Error handler
|
|
196
|
+
server.onerror = (error) => {
|
|
197
|
+
console.error("[MCP Error]", error);
|
|
198
|
+
};
|
|
199
|
+
// Handle shutdown
|
|
200
|
+
process.on("SIGINT", async () => {
|
|
201
|
+
clearInterval(cleanupInterval); // Clean up rate limit interval
|
|
202
|
+
await server.close();
|
|
203
|
+
process.exit(0);
|
|
204
|
+
});
|
|
205
|
+
// Export the run function
|
|
206
|
+
export async function run() {
|
|
207
|
+
const transport = new StdioServerTransport();
|
|
208
|
+
await server.connect(transport);
|
|
209
|
+
console.error("Obsidian MCP server running on stdio");
|
|
210
|
+
}
|
|
211
|
+
//# sourceMappingURL=server.js.map
|