@tac0de/obsidian-mcp 0.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/CONTRIBUTING.md +17 -0
- package/LICENSE +21 -0
- package/README.md +48 -0
- package/SECURITY.md +16 -0
- package/dist/errors.js +25 -0
- package/dist/schemas.js +73 -0
- package/dist/server.js +114 -0
- package/dist/vault.js +231 -0
- package/package.json +62 -0
package/CONTRIBUTING.md
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# Contributing
|
|
2
|
+
|
|
3
|
+
## Development
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
npm ci
|
|
7
|
+
npm run typecheck
|
|
8
|
+
npm run test
|
|
9
|
+
npm run build
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
## Guardrails
|
|
13
|
+
|
|
14
|
+
- Keep tool behavior deterministic.
|
|
15
|
+
- Preserve read-only scope for `0.1.x`.
|
|
16
|
+
- Do not introduce network side effects in tool handlers.
|
|
17
|
+
- Include tests for new edge cases and error codes.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 tac0de
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# obsidian-mcp
|
|
2
|
+
|
|
3
|
+
Deterministic read-only MCP server for Obsidian vaults.
|
|
4
|
+
|
|
5
|
+
## Scope (v0.1)
|
|
6
|
+
|
|
7
|
+
- Transport: `stdio` only
|
|
8
|
+
- Runtime: Node.js 20+
|
|
9
|
+
- Tools:
|
|
10
|
+
- `vault.list_notes(input:{folder?,glob?,limit?}) -> {notes[],total}`
|
|
11
|
+
- `vault.read_note(input:{path,maxBytes?}) -> {path,content,bytes,sha256,lineCount}`
|
|
12
|
+
- `vault.search_notes(input:{query,caseSensitive?,limit?}) -> {matches[],total}`
|
|
13
|
+
- `vault.get_metadata(input:{path}) -> {path,title?,tags[],frontmatter}`
|
|
14
|
+
- Read-only contract: no write/update/delete tools.
|
|
15
|
+
|
|
16
|
+
## Security defaults
|
|
17
|
+
|
|
18
|
+
- Vault boundary enforced by `OBSIDIAN_VAULT_ROOT`
|
|
19
|
+
- Path traversal blocked
|
|
20
|
+
- Symlink escape blocked
|
|
21
|
+
- Max file size bounded by `MAX_FILE_BYTES` (default: `262144`)
|
|
22
|
+
- No network calls in tool handlers
|
|
23
|
+
|
|
24
|
+
## Quick start
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
npm ci
|
|
28
|
+
OBSIDIAN_VAULT_ROOT="/absolute/path/to/vault" npm run dev
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Build and test:
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
npm run typecheck
|
|
35
|
+
npm run test
|
|
36
|
+
npm run build
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## Determinism guarantees
|
|
40
|
+
|
|
41
|
+
- Sorted path output
|
|
42
|
+
- Sorted match output
|
|
43
|
+
- Stable SHA-256 for note content
|
|
44
|
+
- Fixed validation and error code prefixes for boundary violations
|
|
45
|
+
|
|
46
|
+
## License
|
|
47
|
+
|
|
48
|
+
MIT
|
package/SECURITY.md
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# Security Policy
|
|
2
|
+
|
|
3
|
+
## Supported versions
|
|
4
|
+
|
|
5
|
+
- `0.1.x`
|
|
6
|
+
|
|
7
|
+
## Reporting
|
|
8
|
+
|
|
9
|
+
Report security issues privately via GitHub Security Advisories for this repository.
|
|
10
|
+
|
|
11
|
+
## Security model
|
|
12
|
+
|
|
13
|
+
- Read-only vault access in v0.1
|
|
14
|
+
- Root-constrained path resolution
|
|
15
|
+
- Path traversal and symlink escape rejection
|
|
16
|
+
- No outbound network operations in tool handlers
|
package/dist/errors.js
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { ErrorCode, McpError } from '@modelcontextprotocol/sdk/types.js';
|
|
2
|
+
export class VaultError extends Error {
|
|
3
|
+
code;
|
|
4
|
+
retryable;
|
|
5
|
+
constructor(code, message, retryable = false) {
|
|
6
|
+
super(message);
|
|
7
|
+
this.name = 'VaultError';
|
|
8
|
+
this.code = code;
|
|
9
|
+
this.retryable = retryable;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
export function toMcpError(error) {
|
|
13
|
+
if (error instanceof VaultError) {
|
|
14
|
+
const category = error.code === 'E_FILE_NOT_FOUND'
|
|
15
|
+
? ErrorCode.InvalidParams
|
|
16
|
+
: error.code === 'E_MAX_BYTES_EXCEEDED'
|
|
17
|
+
? ErrorCode.InvalidParams
|
|
18
|
+
: ErrorCode.InvalidParams;
|
|
19
|
+
return new McpError(category, `${error.code}: ${error.message}`);
|
|
20
|
+
}
|
|
21
|
+
if (error instanceof Error) {
|
|
22
|
+
return new McpError(ErrorCode.InternalError, `E_INTERNAL: ${error.message}`);
|
|
23
|
+
}
|
|
24
|
+
return new McpError(ErrorCode.InternalError, 'E_INTERNAL: Unknown error');
|
|
25
|
+
}
|
package/dist/schemas.js
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import * as z from 'zod';
|
|
2
|
+
import { zodToJsonSchema } from 'zod-to-json-schema';
|
|
3
|
+
const toJsonSchema = zodToJsonSchema;
|
|
4
|
+
const listNotesInputSchema = z.object({
|
|
5
|
+
folder: z.string().optional(),
|
|
6
|
+
glob: z.string().optional(),
|
|
7
|
+
limit: z.number().int().min(1).max(1000).optional()
|
|
8
|
+
});
|
|
9
|
+
const listNotesOutputSchema = z.object({
|
|
10
|
+
notes: z.array(z.string()),
|
|
11
|
+
total: z.number().int().nonnegative()
|
|
12
|
+
});
|
|
13
|
+
const readNoteInputSchema = z.object({
|
|
14
|
+
path: z.string().min(1),
|
|
15
|
+
maxBytes: z.number().int().min(1).max(10_000_000).optional()
|
|
16
|
+
});
|
|
17
|
+
const readNoteOutputSchema = z.object({
|
|
18
|
+
path: z.string(),
|
|
19
|
+
content: z.string(),
|
|
20
|
+
bytes: z.number().int().nonnegative(),
|
|
21
|
+
sha256: z.string().regex(/^[a-f0-9]{64}$/),
|
|
22
|
+
lineCount: z.number().int().nonnegative()
|
|
23
|
+
});
|
|
24
|
+
const searchNotesInputSchema = z.object({
|
|
25
|
+
query: z.string(),
|
|
26
|
+
caseSensitive: z.boolean().optional(),
|
|
27
|
+
limit: z.number().int().min(1).max(500).optional()
|
|
28
|
+
});
|
|
29
|
+
const searchNotesOutputSchema = z.object({
|
|
30
|
+
matches: z.array(z.object({
|
|
31
|
+
path: z.string(),
|
|
32
|
+
lineNumber: z.number().int().positive(),
|
|
33
|
+
line: z.string()
|
|
34
|
+
})),
|
|
35
|
+
total: z.number().int().nonnegative()
|
|
36
|
+
});
|
|
37
|
+
const getMetadataInputSchema = z.object({
|
|
38
|
+
path: z.string().min(1)
|
|
39
|
+
});
|
|
40
|
+
const getMetadataOutputSchema = z.object({
|
|
41
|
+
path: z.string(),
|
|
42
|
+
title: z.string().optional(),
|
|
43
|
+
tags: z.array(z.string()),
|
|
44
|
+
frontmatter: z.record(z.string(), z.unknown())
|
|
45
|
+
});
|
|
46
|
+
export const toolSchemas = {
|
|
47
|
+
listNotesInputSchema,
|
|
48
|
+
listNotesOutputSchema,
|
|
49
|
+
readNoteInputSchema,
|
|
50
|
+
readNoteOutputSchema,
|
|
51
|
+
searchNotesInputSchema,
|
|
52
|
+
searchNotesOutputSchema,
|
|
53
|
+
getMetadataInputSchema,
|
|
54
|
+
getMetadataOutputSchema
|
|
55
|
+
};
|
|
56
|
+
export const toolJsonSchemas = {
|
|
57
|
+
'vault.list_notes': {
|
|
58
|
+
input: toJsonSchema(listNotesInputSchema, 'vault.list_notes.input'),
|
|
59
|
+
output: toJsonSchema(listNotesOutputSchema, 'vault.list_notes.output')
|
|
60
|
+
},
|
|
61
|
+
'vault.read_note': {
|
|
62
|
+
input: toJsonSchema(readNoteInputSchema, 'vault.read_note.input'),
|
|
63
|
+
output: toJsonSchema(readNoteOutputSchema, 'vault.read_note.output')
|
|
64
|
+
},
|
|
65
|
+
'vault.search_notes': {
|
|
66
|
+
input: toJsonSchema(searchNotesInputSchema, 'vault.search_notes.input'),
|
|
67
|
+
output: toJsonSchema(searchNotesOutputSchema, 'vault.search_notes.output')
|
|
68
|
+
},
|
|
69
|
+
'vault.get_metadata': {
|
|
70
|
+
input: toJsonSchema(getMetadataInputSchema, 'vault.get_metadata.input'),
|
|
71
|
+
output: toJsonSchema(getMetadataOutputSchema, 'vault.get_metadata.output')
|
|
72
|
+
}
|
|
73
|
+
};
|
package/dist/server.js
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
3
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
4
|
+
import { toolSchemas } from './schemas.js';
|
|
5
|
+
import { toMcpError } from './errors.js';
|
|
6
|
+
import { VaultReader } from './vault.js';
|
|
7
|
+
const SERVER_NAME = 'obsidian-mcp';
|
|
8
|
+
const SERVER_VERSION = '0.1.0';
|
|
9
|
+
const DEFAULT_MAX_FILE_BYTES = 262_144;
|
|
10
|
+
function getEnv(name) {
|
|
11
|
+
const value = process.env[name]?.trim();
|
|
12
|
+
if (!value) {
|
|
13
|
+
throw new Error(`${name} is required`);
|
|
14
|
+
}
|
|
15
|
+
return value;
|
|
16
|
+
}
|
|
17
|
+
function getMaxFileBytes() {
|
|
18
|
+
const raw = process.env.MAX_FILE_BYTES;
|
|
19
|
+
if (!raw) {
|
|
20
|
+
return DEFAULT_MAX_FILE_BYTES;
|
|
21
|
+
}
|
|
22
|
+
const parsed = Number(raw);
|
|
23
|
+
if (!Number.isFinite(parsed) || parsed <= 0) {
|
|
24
|
+
throw new Error('MAX_FILE_BYTES must be a positive number');
|
|
25
|
+
}
|
|
26
|
+
return Math.floor(parsed);
|
|
27
|
+
}
|
|
28
|
+
function toToolResult(payload) {
|
|
29
|
+
return {
|
|
30
|
+
content: [
|
|
31
|
+
{
|
|
32
|
+
type: 'text',
|
|
33
|
+
text: JSON.stringify(payload, null, 2)
|
|
34
|
+
}
|
|
35
|
+
],
|
|
36
|
+
structuredContent: payload
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
export async function createServer() {
|
|
40
|
+
const vaultRoot = getEnv('OBSIDIAN_VAULT_ROOT');
|
|
41
|
+
const maxFileBytes = getMaxFileBytes();
|
|
42
|
+
const reader = await VaultReader.create(vaultRoot, maxFileBytes);
|
|
43
|
+
const server = new McpServer({
|
|
44
|
+
name: SERVER_NAME,
|
|
45
|
+
version: SERVER_VERSION
|
|
46
|
+
});
|
|
47
|
+
server.registerTool('vault.list_notes', {
|
|
48
|
+
description: 'List notes in the vault using deterministic ordering.',
|
|
49
|
+
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
50
|
+
inputSchema: toolSchemas.listNotesInputSchema.shape,
|
|
51
|
+
outputSchema: toolSchemas.listNotesOutputSchema.shape
|
|
52
|
+
}, async (input) => {
|
|
53
|
+
try {
|
|
54
|
+
const output = await reader.listNotes(input);
|
|
55
|
+
return toToolResult(output);
|
|
56
|
+
}
|
|
57
|
+
catch (error) {
|
|
58
|
+
throw toMcpError(error);
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
server.registerTool('vault.read_note', {
|
|
62
|
+
description: 'Read one note from the vault and return stable hash metadata.',
|
|
63
|
+
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
64
|
+
inputSchema: toolSchemas.readNoteInputSchema.shape,
|
|
65
|
+
outputSchema: toolSchemas.readNoteOutputSchema.shape
|
|
66
|
+
}, async (input) => {
|
|
67
|
+
try {
|
|
68
|
+
const output = await reader.readNote(input);
|
|
69
|
+
return toToolResult(output);
|
|
70
|
+
}
|
|
71
|
+
catch (error) {
|
|
72
|
+
throw toMcpError(error);
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
server.registerTool('vault.search_notes', {
|
|
76
|
+
description: 'Search note contents in deterministic order.',
|
|
77
|
+
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
78
|
+
inputSchema: toolSchemas.searchNotesInputSchema.shape,
|
|
79
|
+
outputSchema: toolSchemas.searchNotesOutputSchema.shape
|
|
80
|
+
}, async (input) => {
|
|
81
|
+
try {
|
|
82
|
+
const output = await reader.searchNotes(input);
|
|
83
|
+
return toToolResult(output);
|
|
84
|
+
}
|
|
85
|
+
catch (error) {
|
|
86
|
+
throw toMcpError(error);
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
server.registerTool('vault.get_metadata', {
|
|
90
|
+
description: 'Return frontmatter and metadata from a note.',
|
|
91
|
+
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
92
|
+
inputSchema: toolSchemas.getMetadataInputSchema.shape,
|
|
93
|
+
outputSchema: toolSchemas.getMetadataOutputSchema.shape
|
|
94
|
+
}, async (input) => {
|
|
95
|
+
try {
|
|
96
|
+
const output = await reader.getMetadata(input);
|
|
97
|
+
return toToolResult(output);
|
|
98
|
+
}
|
|
99
|
+
catch (error) {
|
|
100
|
+
throw toMcpError(error);
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
return server;
|
|
104
|
+
}
|
|
105
|
+
async function main() {
|
|
106
|
+
const server = await createServer();
|
|
107
|
+
const transport = new StdioServerTransport();
|
|
108
|
+
await server.connect(transport);
|
|
109
|
+
console.error(`${SERVER_NAME} ${SERVER_VERSION} running on stdio`);
|
|
110
|
+
}
|
|
111
|
+
main().catch((error) => {
|
|
112
|
+
console.error('FATAL', error);
|
|
113
|
+
process.exit(1);
|
|
114
|
+
});
|
package/dist/vault.js
ADDED
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { promises as fs } from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import fg from 'fast-glob';
|
|
5
|
+
import matter from 'gray-matter';
|
|
6
|
+
import { VaultError } from './errors.js';
|
|
7
|
+
export class VaultReader {
|
|
8
|
+
rootPath;
|
|
9
|
+
maxFileBytes;
|
|
10
|
+
constructor(rootPath, maxFileBytes) {
|
|
11
|
+
this.rootPath = rootPath;
|
|
12
|
+
this.maxFileBytes = maxFileBytes;
|
|
13
|
+
}
|
|
14
|
+
static async create(rootPath, maxFileBytes) {
|
|
15
|
+
const resolved = path.resolve(rootPath);
|
|
16
|
+
let stat;
|
|
17
|
+
try {
|
|
18
|
+
stat = await fs.stat(resolved);
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
throw new VaultError('E_FILE_NOT_FOUND', `Vault root does not exist: ${rootPath}`);
|
|
22
|
+
}
|
|
23
|
+
if (!stat.isDirectory()) {
|
|
24
|
+
throw new VaultError('E_INVALID_FOLDER', `Vault root is not a directory: ${rootPath}`);
|
|
25
|
+
}
|
|
26
|
+
const realRoot = await fs.realpath(resolved);
|
|
27
|
+
return new VaultReader(realRoot, maxFileBytes);
|
|
28
|
+
}
|
|
29
|
+
async listNotes(input) {
|
|
30
|
+
const limit = clampLimit(input.limit, 100, 1, 1000);
|
|
31
|
+
const folder = input.folder ?? '.';
|
|
32
|
+
const globPattern = input.glob?.trim() || '**/*.md';
|
|
33
|
+
const folderAbs = await this.resolveExistingDirectory(folder);
|
|
34
|
+
const notesInFolder = await fg([globPattern], {
|
|
35
|
+
cwd: folderAbs,
|
|
36
|
+
onlyFiles: true,
|
|
37
|
+
absolute: false,
|
|
38
|
+
dot: false,
|
|
39
|
+
followSymbolicLinks: false,
|
|
40
|
+
unique: true,
|
|
41
|
+
suppressErrors: false
|
|
42
|
+
});
|
|
43
|
+
const mapped = notesInFolder
|
|
44
|
+
.map((relativeInFolder) => {
|
|
45
|
+
const fullPath = path.resolve(folderAbs, relativeInFolder);
|
|
46
|
+
const relFromRoot = path.relative(this.rootPath, fullPath);
|
|
47
|
+
return toPosixPath(relFromRoot);
|
|
48
|
+
})
|
|
49
|
+
.filter((value) => value.length > 0)
|
|
50
|
+
.sort();
|
|
51
|
+
const notes = mapped.slice(0, limit);
|
|
52
|
+
return {
|
|
53
|
+
notes,
|
|
54
|
+
total: mapped.length
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
async readNote(input) {
|
|
58
|
+
const maxBytes = input.maxBytes ?? this.maxFileBytes;
|
|
59
|
+
const absolute = await this.resolveExistingFile(input.path);
|
|
60
|
+
const stats = await fs.stat(absolute);
|
|
61
|
+
if (stats.size > maxBytes) {
|
|
62
|
+
throw new VaultError('E_MAX_BYTES_EXCEEDED', `File exceeds maxBytes (${stats.size} > ${maxBytes})`);
|
|
63
|
+
}
|
|
64
|
+
const buffer = await fs.readFile(absolute);
|
|
65
|
+
const content = buffer.toString('utf8');
|
|
66
|
+
return {
|
|
67
|
+
path: toPosixPath(path.relative(this.rootPath, absolute)),
|
|
68
|
+
content,
|
|
69
|
+
bytes: buffer.byteLength,
|
|
70
|
+
sha256: createHash('sha256').update(buffer).digest('hex'),
|
|
71
|
+
lineCount: content === '' ? 0 : content.split(/\r?\n/).length
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
async searchNotes(input) {
|
|
75
|
+
const query = input.query.trim();
|
|
76
|
+
if (!query) {
|
|
77
|
+
throw new VaultError('E_EMPTY_QUERY', 'Query must not be empty');
|
|
78
|
+
}
|
|
79
|
+
const limit = clampLimit(input.limit, 50, 1, 500);
|
|
80
|
+
const caseSensitive = input.caseSensitive ?? false;
|
|
81
|
+
const needle = caseSensitive ? query : query.toLowerCase();
|
|
82
|
+
const { notes } = await this.listNotes({ glob: '**/*.md', limit: 5000 });
|
|
83
|
+
const matches = [];
|
|
84
|
+
for (const notePath of notes) {
|
|
85
|
+
const absolute = await this.resolveExistingFile(notePath);
|
|
86
|
+
const stats = await fs.stat(absolute);
|
|
87
|
+
if (stats.size > this.maxFileBytes) {
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
const text = await fs.readFile(absolute, 'utf8');
|
|
91
|
+
const lines = text.split(/\r?\n/);
|
|
92
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
93
|
+
const line = lines[index];
|
|
94
|
+
const hay = caseSensitive ? line : line.toLowerCase();
|
|
95
|
+
if (hay.includes(needle)) {
|
|
96
|
+
matches.push({
|
|
97
|
+
path: notePath,
|
|
98
|
+
lineNumber: index + 1,
|
|
99
|
+
line: line.trimEnd()
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
const sorted = matches.sort((a, b) => {
|
|
105
|
+
if (a.path === b.path) {
|
|
106
|
+
return a.lineNumber - b.lineNumber;
|
|
107
|
+
}
|
|
108
|
+
return a.path.localeCompare(b.path);
|
|
109
|
+
});
|
|
110
|
+
return {
|
|
111
|
+
matches: sorted.slice(0, limit),
|
|
112
|
+
total: sorted.length
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
async getMetadata(input) {
|
|
116
|
+
const absolute = await this.resolveExistingFile(input.path);
|
|
117
|
+
const stats = await fs.stat(absolute);
|
|
118
|
+
if (stats.size > this.maxFileBytes) {
|
|
119
|
+
throw new VaultError('E_MAX_BYTES_EXCEEDED', `File exceeds maxBytes (${stats.size} > ${this.maxFileBytes})`);
|
|
120
|
+
}
|
|
121
|
+
const text = await fs.readFile(absolute, 'utf8');
|
|
122
|
+
const parsed = matter(text);
|
|
123
|
+
const normalizedFrontmatter = normalizeValue(parsed.data);
|
|
124
|
+
const titleFromFrontmatter = typeof normalizedFrontmatter.title === 'string' ? normalizedFrontmatter.title : undefined;
|
|
125
|
+
const titleFromHeading = extractHeading(text);
|
|
126
|
+
return {
|
|
127
|
+
path: toPosixPath(path.relative(this.rootPath, absolute)),
|
|
128
|
+
title: titleFromFrontmatter ?? titleFromHeading,
|
|
129
|
+
tags: normalizeTags(normalizedFrontmatter.tags),
|
|
130
|
+
frontmatter: normalizedFrontmatter
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
async resolveExistingDirectory(relativePath) {
|
|
134
|
+
const safe = await this.resolveInsideRoot(relativePath || '.');
|
|
135
|
+
let stat;
|
|
136
|
+
try {
|
|
137
|
+
stat = await fs.stat(safe);
|
|
138
|
+
}
|
|
139
|
+
catch {
|
|
140
|
+
throw new VaultError('E_FILE_NOT_FOUND', `Directory not found: ${relativePath}`);
|
|
141
|
+
}
|
|
142
|
+
if (!stat.isDirectory()) {
|
|
143
|
+
throw new VaultError('E_INVALID_FOLDER', `Not a directory: ${relativePath}`);
|
|
144
|
+
}
|
|
145
|
+
return safe;
|
|
146
|
+
}
|
|
147
|
+
async resolveExistingFile(relativePath) {
|
|
148
|
+
const safe = await this.resolveInsideRoot(relativePath);
|
|
149
|
+
let stat;
|
|
150
|
+
try {
|
|
151
|
+
stat = await fs.stat(safe);
|
|
152
|
+
}
|
|
153
|
+
catch {
|
|
154
|
+
throw new VaultError('E_FILE_NOT_FOUND', `File not found: ${relativePath}`);
|
|
155
|
+
}
|
|
156
|
+
if (!stat.isFile()) {
|
|
157
|
+
throw new VaultError('E_INVALID_PATH', `Not a file: ${relativePath}`);
|
|
158
|
+
}
|
|
159
|
+
return safe;
|
|
160
|
+
}
|
|
161
|
+
async resolveInsideRoot(relativePath) {
|
|
162
|
+
if (!relativePath || typeof relativePath !== 'string') {
|
|
163
|
+
throw new VaultError('E_INVALID_PATH', 'Path is required');
|
|
164
|
+
}
|
|
165
|
+
if (path.isAbsolute(relativePath)) {
|
|
166
|
+
throw new VaultError('E_PATH_TRAVERSAL', 'Absolute paths are not allowed');
|
|
167
|
+
}
|
|
168
|
+
const normalized = path.normalize(relativePath);
|
|
169
|
+
const candidate = path.resolve(this.rootPath, normalized);
|
|
170
|
+
const rootWithSep = this.rootPath.endsWith(path.sep) ? this.rootPath : `${this.rootPath}${path.sep}`;
|
|
171
|
+
if (!(candidate === this.rootPath || candidate.startsWith(rootWithSep))) {
|
|
172
|
+
throw new VaultError('E_PATH_TRAVERSAL', 'Path traversal is not allowed');
|
|
173
|
+
}
|
|
174
|
+
try {
|
|
175
|
+
const real = await fs.realpath(candidate);
|
|
176
|
+
if (!(real === this.rootPath || real.startsWith(rootWithSep))) {
|
|
177
|
+
throw new VaultError('E_PATH_TRAVERSAL', 'Symlink path traversal is not allowed');
|
|
178
|
+
}
|
|
179
|
+
return real;
|
|
180
|
+
}
|
|
181
|
+
catch {
|
|
182
|
+
return candidate;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
function clampLimit(value, fallback, min, max) {
|
|
187
|
+
const chosen = value ?? fallback;
|
|
188
|
+
if (!Number.isFinite(chosen)) {
|
|
189
|
+
return fallback;
|
|
190
|
+
}
|
|
191
|
+
const integer = Math.floor(chosen);
|
|
192
|
+
return Math.max(min, Math.min(max, integer));
|
|
193
|
+
}
|
|
194
|
+
function toPosixPath(value) {
|
|
195
|
+
return value.split(path.sep).join('/');
|
|
196
|
+
}
|
|
197
|
+
function extractHeading(content) {
|
|
198
|
+
const match = content.match(/^#\s+(.+)$/m);
|
|
199
|
+
return match ? match[1].trim() : undefined;
|
|
200
|
+
}
|
|
201
|
+
function normalizeTags(value) {
|
|
202
|
+
if (Array.isArray(value)) {
|
|
203
|
+
return value
|
|
204
|
+
.map((entry) => (typeof entry === 'string' ? entry.trim() : ''))
|
|
205
|
+
.filter((entry) => entry.length > 0)
|
|
206
|
+
.sort();
|
|
207
|
+
}
|
|
208
|
+
if (typeof value === 'string') {
|
|
209
|
+
return value
|
|
210
|
+
.split(',')
|
|
211
|
+
.map((entry) => entry.trim())
|
|
212
|
+
.filter((entry) => entry.length > 0)
|
|
213
|
+
.sort();
|
|
214
|
+
}
|
|
215
|
+
return [];
|
|
216
|
+
}
|
|
217
|
+
function normalizeValue(value) {
|
|
218
|
+
if (Array.isArray(value)) {
|
|
219
|
+
return value.map((entry) => normalizeValue(entry));
|
|
220
|
+
}
|
|
221
|
+
if (value && typeof value === 'object') {
|
|
222
|
+
const record = value;
|
|
223
|
+
const keys = Object.keys(record).sort();
|
|
224
|
+
const next = {};
|
|
225
|
+
for (const key of keys) {
|
|
226
|
+
next[key] = normalizeValue(record[key]);
|
|
227
|
+
}
|
|
228
|
+
return next;
|
|
229
|
+
}
|
|
230
|
+
return value;
|
|
231
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@tac0de/obsidian-mcp",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Deterministic read-only MCP server for Obsidian vaults",
|
|
5
|
+
"main": "dist/server.js",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"test": "vitest run",
|
|
8
|
+
"build": "tsc -p tsconfig.json",
|
|
9
|
+
"start": "node dist/server.js",
|
|
10
|
+
"dev": "tsx src/server.ts",
|
|
11
|
+
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
12
|
+
"prepublishOnly": "npm run typecheck && npm run test && npm run build"
|
|
13
|
+
},
|
|
14
|
+
"keywords": [
|
|
15
|
+
"mcp",
|
|
16
|
+
"obsidian",
|
|
17
|
+
"vault",
|
|
18
|
+
"deterministic",
|
|
19
|
+
"read-only"
|
|
20
|
+
],
|
|
21
|
+
"author": "",
|
|
22
|
+
"license": "MIT",
|
|
23
|
+
"type": "module",
|
|
24
|
+
"private": false,
|
|
25
|
+
"engines": {
|
|
26
|
+
"node": ">=20"
|
|
27
|
+
},
|
|
28
|
+
"dependencies": {
|
|
29
|
+
"@modelcontextprotocol/sdk": "^1.27.1",
|
|
30
|
+
"fast-glob": "^3.3.3",
|
|
31
|
+
"gray-matter": "^4.0.3",
|
|
32
|
+
"zod": "^4.3.6",
|
|
33
|
+
"zod-to-json-schema": "^3.25.1"
|
|
34
|
+
},
|
|
35
|
+
"devDependencies": {
|
|
36
|
+
"@types/node": "^25.3.0",
|
|
37
|
+
"tsx": "^4.21.0",
|
|
38
|
+
"typescript": "^5.9.3",
|
|
39
|
+
"vitest": "^4.0.18"
|
|
40
|
+
},
|
|
41
|
+
"bin": {
|
|
42
|
+
"obsidian-mcp": "dist/server.js"
|
|
43
|
+
},
|
|
44
|
+
"publishConfig": {
|
|
45
|
+
"access": "public"
|
|
46
|
+
},
|
|
47
|
+
"repository": {
|
|
48
|
+
"type": "git",
|
|
49
|
+
"url": "git+https://github.com/tac0de/obsidian-mcp.git"
|
|
50
|
+
},
|
|
51
|
+
"homepage": "https://github.com/tac0de/obsidian-mcp#readme",
|
|
52
|
+
"bugs": {
|
|
53
|
+
"url": "https://github.com/tac0de/obsidian-mcp/issues"
|
|
54
|
+
},
|
|
55
|
+
"files": [
|
|
56
|
+
"dist",
|
|
57
|
+
"README.md",
|
|
58
|
+
"LICENSE",
|
|
59
|
+
"SECURITY.md",
|
|
60
|
+
"CONTRIBUTING.md"
|
|
61
|
+
]
|
|
62
|
+
}
|