obsidian-mcp-brain 0.2.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/lib/access.mjs +108 -0
- package/lib/fetch.mjs +184 -0
- package/lib/frontmatter.mjs +212 -0
- package/lib/preconditions.mjs +51 -0
- package/lib/sections.mjs +196 -0
- package/lib/utils.mjs +8 -0
- package/lib/vault.mjs +359 -0
- package/lib/wikilinks.mjs +77 -0
- package/obsidian-mcp-brain.mjs +1507 -0
- package/package.json +36 -0
|
@@ -0,0 +1,1507 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* MCP server exposing one or more Obsidian vaults to remote MCP clients over HTTP.
|
|
4
|
+
* - Implements MCP Streamable HTTP transport (2024-11-05 spec)
|
|
5
|
+
* - Provides the OAuth 2.0 discovery endpoints Claude Code requires
|
|
6
|
+
* - Reads and writes vault files directly, with no child process dependency
|
|
7
|
+
*
|
|
8
|
+
* Usage: node obsidian-mcp-brain.mjs
|
|
9
|
+
* Configuration is read from a JSON file — see loadConfig() below for its shape,
|
|
10
|
+
* location, and validation. CONFIG_PATH is the only environment variable read.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import http from 'node:http';
|
|
14
|
+
import { randomUUID } from 'node:crypto';
|
|
15
|
+
import fs from 'node:fs/promises';
|
|
16
|
+
import { readFileSync } from 'node:fs';
|
|
17
|
+
import { spawn } from 'node:child_process';
|
|
18
|
+
import path from 'node:path';
|
|
19
|
+
import os from 'node:os';
|
|
20
|
+
import { normPath, isDenied as _isDenied, checkAccess as _checkAccess } from './lib/access.mjs';
|
|
21
|
+
import { parseTags as fmParseTags, addTags as fmAddTags, removeTags as fmRemoveTags, renameTag as fmRenameTag, setFrontmatterField, removeFrontmatterField } from './lib/frontmatter.mjs';
|
|
22
|
+
import { escRe } from './lib/utils.mjs';
|
|
23
|
+
import { replaceSection, deleteSection, toggleCheckbox } from './lib/sections.mjs';
|
|
24
|
+
import { assertUnmodified, formatMtime } from './lib/preconditions.mjs';
|
|
25
|
+
import { fetchToBuffer } from './lib/fetch.mjs';
|
|
26
|
+
import {
|
|
27
|
+
walkVault as libWalkVault,
|
|
28
|
+
readNote as libReadNote,
|
|
29
|
+
writeNote as libWriteNote,
|
|
30
|
+
deleteNote as libDeleteNote,
|
|
31
|
+
moveNote as libMoveNote,
|
|
32
|
+
searchContent as libSearchContent,
|
|
33
|
+
searchFilename as libSearchFilename,
|
|
34
|
+
writeBinaryFile as libWriteBinaryFile,
|
|
35
|
+
deleteBinaryFile as libDeleteBinaryFile,
|
|
36
|
+
moveBinaryFile as libMoveBinaryFile,
|
|
37
|
+
findBacklinks as libFindBacklinks,
|
|
38
|
+
resolveWikilink as libResolveWikilink,
|
|
39
|
+
} from './lib/vault.mjs';
|
|
40
|
+
|
|
41
|
+
const ts = () => new Date().toISOString();
|
|
42
|
+
const log = (...a) => console.log(ts(), ...a);
|
|
43
|
+
const logErr = (...a) => console.error(ts(), ...a);
|
|
44
|
+
|
|
45
|
+
// ── Configuration (JSON file, not environment variables) ────────────────────
|
|
46
|
+
//
|
|
47
|
+
// CONFIG_PATH is the one setting still read from the environment, because
|
|
48
|
+
// something has to say where to find the file that contains everything else.
|
|
49
|
+
// Shape:
|
|
50
|
+
// {
|
|
51
|
+
// "listenPort": 3002,
|
|
52
|
+
// "mcpBaseUrl": "https://host:4001",
|
|
53
|
+
// "denyPaths": ["private"],
|
|
54
|
+
// "graphifyQueryTimeoutMs": 60000,
|
|
55
|
+
// "fetchMaxBytes": 10485760,
|
|
56
|
+
// "fetchTimeoutMs": 30000,
|
|
57
|
+
// "vaults": { "name": { "path": "/abs/path", "denyPaths": [] } }
|
|
58
|
+
// }
|
|
59
|
+
// mcpBaseUrl and a non-empty vaults map (each with a path) are required;
|
|
60
|
+
// everything else has a default. Invalid or missing config exits the process,
|
|
61
|
+
// matching this file's existing synchronous-startup-validation style.
|
|
62
|
+
|
|
63
|
+
const CONFIG_PATH = process.env.CONFIG_PATH || path.join(os.homedir(), '.config', 'obsidian-mcp.json');
|
|
64
|
+
|
|
65
|
+
function normDenyPaths(list, configPath, fieldLabel) {
|
|
66
|
+
if (list === undefined) return [];
|
|
67
|
+
if (!Array.isArray(list) || list.some(p => typeof p !== 'string')) {
|
|
68
|
+
logErr(`Config file at ${configPath}: "${fieldLabel}" must be an array of strings`);
|
|
69
|
+
process.exit(1);
|
|
70
|
+
}
|
|
71
|
+
return list
|
|
72
|
+
.map(p => p.trim().replace(/^\/+|\/+$/g, ''))
|
|
73
|
+
.filter(Boolean);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function loadConfig(configPath) {
|
|
77
|
+
let raw;
|
|
78
|
+
try {
|
|
79
|
+
raw = readFileSync(configPath, 'utf8');
|
|
80
|
+
} catch (err) {
|
|
81
|
+
logErr(`Could not read config file at ${configPath}: ${err.message}`);
|
|
82
|
+
process.exit(1);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
let config;
|
|
86
|
+
try {
|
|
87
|
+
config = JSON.parse(raw);
|
|
88
|
+
} catch (err) {
|
|
89
|
+
logErr(`Config file at ${configPath} is not valid JSON: ${err.message}`);
|
|
90
|
+
process.exit(1);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (!config.mcpBaseUrl) {
|
|
94
|
+
logErr(`Config file at ${configPath} must set "mcpBaseUrl" to the public HTTPS base URL of this server (e.g. https://hostname:4001)`);
|
|
95
|
+
process.exit(1);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const listenPort = parseInt(config.listenPort ?? 3002, 10);
|
|
99
|
+
if (Number.isNaN(listenPort) || listenPort < 1 || listenPort > 65535) {
|
|
100
|
+
logErr(`Config file at ${configPath}: "listenPort" must be a valid port number (1-65535)`);
|
|
101
|
+
process.exit(1);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const graphifyQueryTimeoutMs = parseInt(config.graphifyQueryTimeoutMs ?? 60000, 10);
|
|
105
|
+
if (Number.isNaN(graphifyQueryTimeoutMs) || graphifyQueryTimeoutMs < 1) {
|
|
106
|
+
logErr(`Config file at ${configPath}: "graphifyQueryTimeoutMs" must be a positive number`);
|
|
107
|
+
process.exit(1);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const fetchMaxBytes = parseInt(config.fetchMaxBytes ?? 10 * 1024 * 1024, 10);
|
|
111
|
+
if (Number.isNaN(fetchMaxBytes) || fetchMaxBytes < 1) {
|
|
112
|
+
logErr(`Config file at ${configPath}: "fetchMaxBytes" must be a positive number`);
|
|
113
|
+
process.exit(1);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const fetchTimeoutMs = parseInt(config.fetchTimeoutMs ?? 30000, 10);
|
|
117
|
+
if (Number.isNaN(fetchTimeoutMs) || fetchTimeoutMs < 1) {
|
|
118
|
+
logErr(`Config file at ${configPath}: "fetchTimeoutMs" must be a positive number`);
|
|
119
|
+
process.exit(1);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const globalDenyPaths = normDenyPaths(config.denyPaths, configPath, 'denyPaths');
|
|
123
|
+
|
|
124
|
+
const rawVaults = config.vaults;
|
|
125
|
+
if (!rawVaults || typeof rawVaults !== 'object' || Array.isArray(rawVaults) || Object.keys(rawVaults).length === 0) {
|
|
126
|
+
logErr(`Config file at ${configPath} must set "vaults" to a non-empty object of the form { "name": { "path": "/abs/path" } }`);
|
|
127
|
+
process.exit(1);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const vaults = {};
|
|
131
|
+
for (const [name, entry] of Object.entries(rawVaults)) {
|
|
132
|
+
if (!entry || typeof entry.path !== 'string' || !entry.path) {
|
|
133
|
+
logErr(`Config file at ${configPath}: vault "${name}" must have a "path" string`);
|
|
134
|
+
process.exit(1);
|
|
135
|
+
}
|
|
136
|
+
vaults[name] = {
|
|
137
|
+
path: entry.path,
|
|
138
|
+
denyPaths: [...globalDenyPaths, ...normDenyPaths(entry.denyPaths, configPath, `vaults.${name}.denyPaths`)],
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return {
|
|
143
|
+
listenPort,
|
|
144
|
+
baseUrl: config.mcpBaseUrl,
|
|
145
|
+
graphifyQueryTimeoutMs,
|
|
146
|
+
fetchMaxBytes,
|
|
147
|
+
fetchTimeoutMs,
|
|
148
|
+
vaults,
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const {
|
|
153
|
+
listenPort: LISTEN_PORT,
|
|
154
|
+
baseUrl: BASE_URL,
|
|
155
|
+
graphifyQueryTimeoutMs: GRAPHIFY_QUERY_TIMEOUT_MS,
|
|
156
|
+
fetchMaxBytes: FETCH_MAX_BYTES,
|
|
157
|
+
fetchTimeoutMs: FETCH_TIMEOUT_MS,
|
|
158
|
+
vaults: VAULTS,
|
|
159
|
+
} = loadConfig(CONFIG_PATH);
|
|
160
|
+
|
|
161
|
+
// ── Path access control ────────────────────────────────────────────────────
|
|
162
|
+
|
|
163
|
+
// Resolves args.vault's effective (global + per-vault) deny list before calling
|
|
164
|
+
// the shared, vault-agnostic checkAccess/isDenied functions. An unrecognised
|
|
165
|
+
// vault name falls back to an empty deny list here — the request fails
|
|
166
|
+
// downstream anyway ("Unknown vault"), so there's nothing extra to protect.
|
|
167
|
+
const checkAccess = (toolName, args) => _checkAccess(VAULTS[args.vault]?.denyPaths ?? [], toolName, args);
|
|
168
|
+
|
|
169
|
+
const toolOk = (res, sid, id, text) => sendSse(res, 200, sid, [{ jsonrpc: '2.0', id, result: { content: [{ type: 'text', text }] } }]);
|
|
170
|
+
const toolErr = (res, sid, id, text) => sendSse(res, 200, sid, [{ jsonrpc: '2.0', id, result: { content: [{ type: 'text', text }], isError: true } }]);
|
|
171
|
+
// A frontmatter field name must be a simple key with no YAML/regex-structural
|
|
172
|
+
// characters — otherwise a caller-supplied field could splice extra lines
|
|
173
|
+
// (including a spurious `---`) into the raw frontmatter block on write.
|
|
174
|
+
const FIELD_NAME_RE = /^[A-Za-z0-9_-]+$/;
|
|
175
|
+
|
|
176
|
+
// Static initialize response — every tool is native, so there's no child
|
|
177
|
+
// capabilities negotiation to wait on.
|
|
178
|
+
const SERVER_CAPS = {
|
|
179
|
+
protocolVersion: '2024-11-05',
|
|
180
|
+
capabilities: { tools: {} },
|
|
181
|
+
serverInfo: { name: 'obsidian-mcp-brain', version: '1.0.0' },
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
// ── Native tools ────────────────────────────────────────────────────
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
const TOOLS = [
|
|
188
|
+
{
|
|
189
|
+
name: 'list-notes',
|
|
190
|
+
description: 'List all notes in the vault, or scoped to a folder. Returns sorted vault-relative paths, each with a tab-separated ISO 8601 last-modified timestamp.',
|
|
191
|
+
inputSchema: {
|
|
192
|
+
type: 'object',
|
|
193
|
+
properties: {
|
|
194
|
+
vault: { type: 'string', description: 'Vault name' },
|
|
195
|
+
path: { type: 'string', description: 'Optional vault-relative folder to scope the listing' },
|
|
196
|
+
},
|
|
197
|
+
required: ['vault'],
|
|
198
|
+
},
|
|
199
|
+
},
|
|
200
|
+
{
|
|
201
|
+
name: 'list-tags',
|
|
202
|
+
description: 'List all unique tags (from YAML frontmatter) used across vault notes. Optionally scope to a subdirectory.',
|
|
203
|
+
inputSchema: {
|
|
204
|
+
type: 'object',
|
|
205
|
+
properties: {
|
|
206
|
+
vault: { type: 'string', description: 'Vault name' },
|
|
207
|
+
path: { type: 'string', description: 'Optional vault-relative path to scope the search' },
|
|
208
|
+
},
|
|
209
|
+
required: ['vault'],
|
|
210
|
+
},
|
|
211
|
+
},
|
|
212
|
+
{
|
|
213
|
+
name: 'search-tags',
|
|
214
|
+
description: 'Find notes that have ALL of the specified tags (YAML frontmatter). Returns vault-relative paths.',
|
|
215
|
+
inputSchema: {
|
|
216
|
+
type: 'object',
|
|
217
|
+
properties: {
|
|
218
|
+
vault: { type: 'string', description: 'Vault name' },
|
|
219
|
+
tags: { type: 'array', items: { type: 'string' }, description: 'Tags that notes must all have' },
|
|
220
|
+
path: { type: 'string', description: 'Optional vault-relative path to scope the search' },
|
|
221
|
+
},
|
|
222
|
+
required: ['vault', 'tags'],
|
|
223
|
+
},
|
|
224
|
+
},
|
|
225
|
+
{
|
|
226
|
+
name: 'new-notes',
|
|
227
|
+
description: 'List notes created in the last 7 days, or since a provided ISO 8601 timestamp. Returns vault-relative paths.',
|
|
228
|
+
inputSchema: {
|
|
229
|
+
type: 'object',
|
|
230
|
+
properties: {
|
|
231
|
+
vault: { type: 'string', description: 'Vault name' },
|
|
232
|
+
since: { type: 'string', description: 'ISO 8601 timestamp; defaults to 7 days ago' },
|
|
233
|
+
path: { type: 'string', description: 'Optional vault-relative path to scope the search' },
|
|
234
|
+
},
|
|
235
|
+
required: ['vault'],
|
|
236
|
+
},
|
|
237
|
+
},
|
|
238
|
+
{
|
|
239
|
+
name: 'changed-notes',
|
|
240
|
+
description: 'List notes modified in the last 7 days, or since a provided ISO 8601 timestamp. Returns vault-relative paths.',
|
|
241
|
+
inputSchema: {
|
|
242
|
+
type: 'object',
|
|
243
|
+
properties: {
|
|
244
|
+
vault: { type: 'string', description: 'Vault name' },
|
|
245
|
+
since: { type: 'string', description: 'ISO 8601 timestamp; defaults to 7 days ago' },
|
|
246
|
+
path: { type: 'string', description: 'Optional vault-relative path to scope the search' },
|
|
247
|
+
},
|
|
248
|
+
required: ['vault'],
|
|
249
|
+
},
|
|
250
|
+
},
|
|
251
|
+
{
|
|
252
|
+
name: 'list-vaults',
|
|
253
|
+
description: 'List all configured Obsidian vaults.',
|
|
254
|
+
inputSchema: { type: 'object', properties: {}, required: [] },
|
|
255
|
+
},
|
|
256
|
+
{
|
|
257
|
+
name: 'read-note',
|
|
258
|
+
description: 'Read the content of a note. Returns the raw markdown, followed by a second content item with the note\'s ISO 8601 last-modified timestamp.',
|
|
259
|
+
inputSchema: {
|
|
260
|
+
type: 'object',
|
|
261
|
+
properties: {
|
|
262
|
+
vault: { type: 'string', description: 'Vault name' },
|
|
263
|
+
filename: { type: 'string', description: 'Filename including .md extension' },
|
|
264
|
+
folder: { type: 'string', description: 'Optional vault-relative folder' },
|
|
265
|
+
},
|
|
266
|
+
required: ['vault', 'filename'],
|
|
267
|
+
},
|
|
268
|
+
},
|
|
269
|
+
{
|
|
270
|
+
name: 'create-note',
|
|
271
|
+
description: 'Create a new note. Fails if the note already exists.',
|
|
272
|
+
inputSchema: {
|
|
273
|
+
type: 'object',
|
|
274
|
+
properties: {
|
|
275
|
+
vault: { type: 'string', description: 'Vault name' },
|
|
276
|
+
filename: { type: 'string', description: 'Filename including .md extension' },
|
|
277
|
+
folder: { type: 'string', description: 'Optional vault-relative folder' },
|
|
278
|
+
content: { type: 'string', description: 'Markdown content' },
|
|
279
|
+
},
|
|
280
|
+
required: ['vault', 'filename'],
|
|
281
|
+
},
|
|
282
|
+
},
|
|
283
|
+
{
|
|
284
|
+
name: 'edit-note',
|
|
285
|
+
description: 'Edit an existing note by appending, prepending, replacing its content, replacing or deleting the content under a heading, or toggling a checkbox.',
|
|
286
|
+
inputSchema: {
|
|
287
|
+
type: 'object',
|
|
288
|
+
properties: {
|
|
289
|
+
vault: { type: 'string', description: 'Vault name' },
|
|
290
|
+
filename: { type: 'string', description: 'Filename including .md extension' },
|
|
291
|
+
folder: { type: 'string', description: 'Optional vault-relative folder' },
|
|
292
|
+
operation: { type: 'string', enum: ['append', 'prepend', 'replace', 'replace-section', 'delete-section', 'toggle-checkbox'], description: 'Edit operation' },
|
|
293
|
+
content: { type: 'string', description: 'Content to apply (append/prepend/replace/replace-section)' },
|
|
294
|
+
heading: { type: 'string', description: 'Exact heading text to match (replace-section/delete-section only)' },
|
|
295
|
+
taskText: { type: 'string', description: 'Exact checkbox text after the [ ]/[x] marker (toggle-checkbox only)' },
|
|
296
|
+
checked: { type: 'boolean', description: 'Explicit checkbox state (toggle-checkbox only); omit to flip the current state' },
|
|
297
|
+
occurrence: { type: 'integer', description: 'Disambiguates when heading/taskText matches more than once (1-based)' },
|
|
298
|
+
expectedMtime: { type: 'string', description: 'Optional ISO 8601 mtime from a prior read-note/list-notes call; the edit is refused if the note has changed since' },
|
|
299
|
+
},
|
|
300
|
+
required: ['vault', 'filename', 'operation'],
|
|
301
|
+
},
|
|
302
|
+
},
|
|
303
|
+
{
|
|
304
|
+
name: 'delete-note',
|
|
305
|
+
description: 'Delete a note, moving it to .trash by default.',
|
|
306
|
+
inputSchema: {
|
|
307
|
+
type: 'object',
|
|
308
|
+
properties: {
|
|
309
|
+
vault: { type: 'string', description: 'Vault name' },
|
|
310
|
+
filename: { type: 'string', description: 'Filename including .md extension' },
|
|
311
|
+
folder: { type: 'string', description: 'Optional vault-relative folder' },
|
|
312
|
+
permanent: { type: 'boolean', description: 'If true, permanently delete instead of trashing' },
|
|
313
|
+
expectedMtime: { type: 'string', description: 'Optional ISO 8601 mtime from a prior read-note/list-notes call; the delete is refused if the note has changed since' },
|
|
314
|
+
},
|
|
315
|
+
required: ['vault', 'filename'],
|
|
316
|
+
},
|
|
317
|
+
},
|
|
318
|
+
{
|
|
319
|
+
name: 'move-note',
|
|
320
|
+
description: 'Move or rename a note, rewriting all vault-wide wikilinks to the old path.',
|
|
321
|
+
inputSchema: {
|
|
322
|
+
type: 'object',
|
|
323
|
+
properties: {
|
|
324
|
+
vault: { type: 'string', description: 'Vault name' },
|
|
325
|
+
filename: { type: 'string', description: 'Source filename including .md extension' },
|
|
326
|
+
folder: { type: 'string', description: 'Optional source vault-relative folder' },
|
|
327
|
+
newFilename: { type: 'string', description: 'Destination filename including .md extension' },
|
|
328
|
+
newFolder: { type: 'string', description: 'Optional destination vault-relative folder' },
|
|
329
|
+
expectedMtime: { type: 'string', description: 'Optional ISO 8601 mtime from a prior read-note/list-notes call; the move is refused if the source note has changed since' },
|
|
330
|
+
},
|
|
331
|
+
required: ['vault', 'filename', 'newFilename'],
|
|
332
|
+
},
|
|
333
|
+
},
|
|
334
|
+
{
|
|
335
|
+
name: 'create-binary-file',
|
|
336
|
+
description: 'Create a new binary file (e.g. an image) from base64-encoded content. Fails if the file already exists.',
|
|
337
|
+
inputSchema: {
|
|
338
|
+
type: 'object',
|
|
339
|
+
properties: {
|
|
340
|
+
vault: { type: 'string', description: 'Vault name' },
|
|
341
|
+
filename: { type: 'string', description: 'Filename including extension' },
|
|
342
|
+
folder: { type: 'string', description: 'Optional vault-relative folder' },
|
|
343
|
+
content: { type: 'string', description: 'Base64-encoded file content' },
|
|
344
|
+
},
|
|
345
|
+
required: ['vault', 'filename', 'content'],
|
|
346
|
+
},
|
|
347
|
+
},
|
|
348
|
+
{
|
|
349
|
+
name: 'fetch-binary-file',
|
|
350
|
+
description: 'Create a new binary file by downloading a URL server-side, so the client only needs to send a URL rather than the full file content. Fails if the file already exists. The URL must be http(s) and resolve to a public address.',
|
|
351
|
+
inputSchema: {
|
|
352
|
+
type: 'object',
|
|
353
|
+
properties: {
|
|
354
|
+
vault: { type: 'string', description: 'Vault name' },
|
|
355
|
+
filename: { type: 'string', description: 'Filename including extension' },
|
|
356
|
+
folder: { type: 'string', description: 'Optional vault-relative folder' },
|
|
357
|
+
url: { type: 'string', description: 'http(s) URL to download' },
|
|
358
|
+
maxBytes: { type: 'integer', description: 'Optional override for the maximum response size in bytes (default from server config)' },
|
|
359
|
+
timeoutMs: { type: 'integer', description: 'Optional override for the request timeout in milliseconds (default from server config)' },
|
|
360
|
+
},
|
|
361
|
+
required: ['vault', 'filename', 'url'],
|
|
362
|
+
},
|
|
363
|
+
},
|
|
364
|
+
{
|
|
365
|
+
name: 'move-binary-file',
|
|
366
|
+
description: 'Move or rename a binary file, rewriting all vault-wide wikilink embeds pointing at the old path.',
|
|
367
|
+
inputSchema: {
|
|
368
|
+
type: 'object',
|
|
369
|
+
properties: {
|
|
370
|
+
vault: { type: 'string', description: 'Vault name' },
|
|
371
|
+
filename: { type: 'string', description: 'Source filename including extension' },
|
|
372
|
+
folder: { type: 'string', description: 'Optional source vault-relative folder' },
|
|
373
|
+
newFilename: { type: 'string', description: 'Destination filename including extension' },
|
|
374
|
+
newFolder: { type: 'string', description: 'Optional destination vault-relative folder' },
|
|
375
|
+
expectedMtime: { type: 'string', description: 'Optional ISO 8601 mtime from a prior read-note/list-notes call; the move is refused if the source file has changed since' },
|
|
376
|
+
},
|
|
377
|
+
required: ['vault', 'filename', 'newFilename'],
|
|
378
|
+
},
|
|
379
|
+
},
|
|
380
|
+
{
|
|
381
|
+
name: 'delete-binary-file',
|
|
382
|
+
description: 'Delete a binary file, moving it to .trash by default.',
|
|
383
|
+
inputSchema: {
|
|
384
|
+
type: 'object',
|
|
385
|
+
properties: {
|
|
386
|
+
vault: { type: 'string', description: 'Vault name' },
|
|
387
|
+
filename: { type: 'string', description: 'Filename including extension' },
|
|
388
|
+
folder: { type: 'string', description: 'Optional vault-relative folder' },
|
|
389
|
+
permanent: { type: 'boolean', description: 'If true, permanently delete instead of trashing' },
|
|
390
|
+
expectedMtime: { type: 'string', description: 'Optional ISO 8601 mtime from a prior read-note/list-notes call; the delete is refused if the file has changed since' },
|
|
391
|
+
},
|
|
392
|
+
required: ['vault', 'filename'],
|
|
393
|
+
},
|
|
394
|
+
},
|
|
395
|
+
{
|
|
396
|
+
name: 'find-backlinks',
|
|
397
|
+
description: 'Find all notes that link to or embed a given note or binary file. Returns sorted vault-relative paths.',
|
|
398
|
+
inputSchema: {
|
|
399
|
+
type: 'object',
|
|
400
|
+
properties: {
|
|
401
|
+
vault: { type: 'string', description: 'Vault name' },
|
|
402
|
+
filename: { type: 'string', description: 'Target filename including extension' },
|
|
403
|
+
folder: { type: 'string', description: 'Optional vault-relative folder of the target' },
|
|
404
|
+
},
|
|
405
|
+
required: ['vault', 'filename'],
|
|
406
|
+
},
|
|
407
|
+
},
|
|
408
|
+
{
|
|
409
|
+
name: 'resolve-wikilink',
|
|
410
|
+
description: 'Resolve a wikilink target string (e.g. the "folder/note" portion of [[folder/note#heading|alias]]) to the vault-relative file(s) it points to. Zero results means it doesn\'t resolve; more than one means it\'s ambiguous.',
|
|
411
|
+
inputSchema: {
|
|
412
|
+
type: 'object',
|
|
413
|
+
properties: {
|
|
414
|
+
vault: { type: 'string', description: 'Vault name' },
|
|
415
|
+
target: { type: 'string', description: 'Raw wikilink target string, without [[ ]], heading, or alias' },
|
|
416
|
+
},
|
|
417
|
+
required: ['vault', 'target'],
|
|
418
|
+
},
|
|
419
|
+
},
|
|
420
|
+
{
|
|
421
|
+
name: 'create-folder',
|
|
422
|
+
description: 'Create a new folder (and any missing parents) in the vault.',
|
|
423
|
+
inputSchema: {
|
|
424
|
+
type: 'object',
|
|
425
|
+
properties: {
|
|
426
|
+
vault: { type: 'string', description: 'Vault name' },
|
|
427
|
+
folder: { type: 'string', description: 'Vault-relative path for the new folder' },
|
|
428
|
+
},
|
|
429
|
+
required: ['vault', 'folder'],
|
|
430
|
+
},
|
|
431
|
+
},
|
|
432
|
+
{
|
|
433
|
+
name: 'search-vault',
|
|
434
|
+
description: 'Search vault notes by content, filename, or both (case-insensitive). Content results include line numbers.',
|
|
435
|
+
inputSchema: {
|
|
436
|
+
type: 'object',
|
|
437
|
+
properties: {
|
|
438
|
+
vault: { type: 'string', description: 'Vault name' },
|
|
439
|
+
query: { type: 'string', description: 'Search query' },
|
|
440
|
+
searchType: { type: 'string', enum: ['content', 'filename', 'both'], description: 'What to search' },
|
|
441
|
+
path: { type: 'string', description: 'Optional vault-relative path to scope the search' },
|
|
442
|
+
},
|
|
443
|
+
required: ['vault', 'query', 'searchType'],
|
|
444
|
+
},
|
|
445
|
+
},
|
|
446
|
+
{
|
|
447
|
+
name: 'add-tags',
|
|
448
|
+
description: 'Add tags to notes in frontmatter and/or inline body content.',
|
|
449
|
+
inputSchema: {
|
|
450
|
+
type: 'object',
|
|
451
|
+
properties: {
|
|
452
|
+
vault: { type: 'string', description: 'Vault name' },
|
|
453
|
+
files: { type: 'array', items: { type: 'string' }, description: 'Vault-relative note paths' },
|
|
454
|
+
tags: { type: 'array', items: { type: 'string' }, description: 'Tags to add' },
|
|
455
|
+
location: { type: 'string', enum: ['frontmatter', 'content', 'both'], description: 'Where to add tags (default: frontmatter)' },
|
|
456
|
+
expectedMtime: { type: 'object', description: 'Optional map of vault-relative path to ISO 8601 mtime; checked for every listed file before any file is written (all-or-nothing)' },
|
|
457
|
+
},
|
|
458
|
+
required: ['vault', 'files', 'tags'],
|
|
459
|
+
},
|
|
460
|
+
},
|
|
461
|
+
{
|
|
462
|
+
name: 'remove-tags',
|
|
463
|
+
description: 'Remove tags from notes in frontmatter and/or inline body content.',
|
|
464
|
+
inputSchema: {
|
|
465
|
+
type: 'object',
|
|
466
|
+
properties: {
|
|
467
|
+
vault: { type: 'string', description: 'Vault name' },
|
|
468
|
+
files: { type: 'array', items: { type: 'string' }, description: 'Vault-relative note paths' },
|
|
469
|
+
tags: { type: 'array', items: { type: 'string' }, description: 'Tags to remove' },
|
|
470
|
+
location: { type: 'string', enum: ['frontmatter', 'content', 'both'], description: 'Where to remove tags (default: frontmatter)' },
|
|
471
|
+
expectedMtime: { type: 'object', description: 'Optional map of vault-relative path to ISO 8601 mtime; checked for every listed file before any file is written (all-or-nothing)' },
|
|
472
|
+
},
|
|
473
|
+
required: ['vault', 'files', 'tags'],
|
|
474
|
+
},
|
|
475
|
+
},
|
|
476
|
+
{
|
|
477
|
+
name: 'rename-tag',
|
|
478
|
+
description: 'Rename a tag throughout the entire vault (frontmatter and inline content).',
|
|
479
|
+
inputSchema: {
|
|
480
|
+
type: 'object',
|
|
481
|
+
properties: {
|
|
482
|
+
vault: { type: 'string', description: 'Vault name' },
|
|
483
|
+
oldTag: { type: 'string', description: 'Tag to rename' },
|
|
484
|
+
newTag: { type: 'string', description: 'New tag name' },
|
|
485
|
+
},
|
|
486
|
+
required: ['vault', 'oldTag', 'newTag'],
|
|
487
|
+
},
|
|
488
|
+
},
|
|
489
|
+
{
|
|
490
|
+
name: 'set-frontmatter-field',
|
|
491
|
+
description: 'Set a single frontmatter field (not tags) to a scalar value, creating the frontmatter block or the field if missing.',
|
|
492
|
+
inputSchema: {
|
|
493
|
+
type: 'object',
|
|
494
|
+
properties: {
|
|
495
|
+
vault: { type: 'string', description: 'Vault name' },
|
|
496
|
+
filename: { type: 'string', description: 'Filename including .md extension' },
|
|
497
|
+
folder: { type: 'string', description: 'Optional vault-relative folder' },
|
|
498
|
+
field: { type: 'string', description: 'Frontmatter key (any field except tags)' },
|
|
499
|
+
value: { description: 'Scalar value to set (string, number, or boolean)' },
|
|
500
|
+
expectedMtime: { type: 'string', description: 'Optional ISO 8601 mtime from a prior read-note/list-notes call; the write is refused if the note has changed since' },
|
|
501
|
+
},
|
|
502
|
+
required: ['vault', 'filename', 'field', 'value'],
|
|
503
|
+
},
|
|
504
|
+
},
|
|
505
|
+
{
|
|
506
|
+
name: 'remove-frontmatter-field',
|
|
507
|
+
description: 'Remove a single frontmatter field (not tags) entirely. No-op if the field or frontmatter block doesn\'t exist.',
|
|
508
|
+
inputSchema: {
|
|
509
|
+
type: 'object',
|
|
510
|
+
properties: {
|
|
511
|
+
vault: { type: 'string', description: 'Vault name' },
|
|
512
|
+
filename: { type: 'string', description: 'Filename including .md extension' },
|
|
513
|
+
folder: { type: 'string', description: 'Optional vault-relative folder' },
|
|
514
|
+
field: { type: 'string', description: 'Frontmatter key (any field except tags)' },
|
|
515
|
+
expectedMtime: { type: 'string', description: 'Optional ISO 8601 mtime from a prior read-note/list-notes call; the write is refused if the note has changed since' },
|
|
516
|
+
},
|
|
517
|
+
required: ['vault', 'filename', 'field'],
|
|
518
|
+
},
|
|
519
|
+
},
|
|
520
|
+
{
|
|
521
|
+
name: 'query-graph',
|
|
522
|
+
description: 'Ask a natural-language question against a vault\'s graphify knowledge graph. Errors clearly if that vault has no graph built.',
|
|
523
|
+
inputSchema: {
|
|
524
|
+
type: 'object',
|
|
525
|
+
properties: {
|
|
526
|
+
vault: { type: 'string', description: 'Vault name' },
|
|
527
|
+
question: { type: 'string', description: 'Natural-language question to ask the graph' },
|
|
528
|
+
},
|
|
529
|
+
required: ['vault', 'question'],
|
|
530
|
+
},
|
|
531
|
+
},
|
|
532
|
+
];
|
|
533
|
+
|
|
534
|
+
// ── SSE streams (GET /mcp per session) ────────────────────────────────────
|
|
535
|
+
|
|
536
|
+
const sseStreams = new Map(); // sessionId → ServerResponse
|
|
537
|
+
|
|
538
|
+
// ── active HTTP sessions ───────────────────────────────────────────────────
|
|
539
|
+
|
|
540
|
+
const sessions = new Set();
|
|
541
|
+
const SESSION_TTL = 60_000; // remove sessions that never open a GET /mcp stream
|
|
542
|
+
|
|
543
|
+
function addSession(sid) {
|
|
544
|
+
if (sessions.size >= 1000) return false;
|
|
545
|
+
sessions.add(sid);
|
|
546
|
+
// If no SSE stream opens within TTL, drop the session
|
|
547
|
+
setTimeout(() => {
|
|
548
|
+
if (!sseStreams.has(sid)) sessions.delete(sid);
|
|
549
|
+
}, SESSION_TTL);
|
|
550
|
+
return true;
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
// ── OAuth static responses ─────────────────────────────────────────────────
|
|
554
|
+
|
|
555
|
+
const OAUTH_RESOURCE = JSON.stringify({
|
|
556
|
+
resource: BASE_URL,
|
|
557
|
+
authorization_servers: [BASE_URL],
|
|
558
|
+
});
|
|
559
|
+
|
|
560
|
+
const OAUTH_SERVER = JSON.stringify({
|
|
561
|
+
issuer: BASE_URL,
|
|
562
|
+
authorization_endpoint: `${BASE_URL}/authorize`,
|
|
563
|
+
token_endpoint: `${BASE_URL}/token`,
|
|
564
|
+
registration_endpoint: `${BASE_URL}/register`,
|
|
565
|
+
grant_types_supported: ['client_credentials', 'authorization_code'],
|
|
566
|
+
token_endpoint_auth_methods_supported: ['none'],
|
|
567
|
+
response_types_supported: ['code', 'token'],
|
|
568
|
+
scopes_supported: [],
|
|
569
|
+
});
|
|
570
|
+
|
|
571
|
+
const TOKEN_RESPONSE = JSON.stringify({
|
|
572
|
+
access_token: 'public',
|
|
573
|
+
token_type: 'Bearer',
|
|
574
|
+
expires_in: 86400,
|
|
575
|
+
scope: '',
|
|
576
|
+
});
|
|
577
|
+
|
|
578
|
+
// ── HTTP helpers ───────────────────────────────────────────────────────────
|
|
579
|
+
|
|
580
|
+
function sendSse(res, statusCode, sessionId, msgs) {
|
|
581
|
+
const headers = {
|
|
582
|
+
'Content-Type': 'text/event-stream',
|
|
583
|
+
'Cache-Control': 'no-cache, no-transform',
|
|
584
|
+
};
|
|
585
|
+
if (sessionId) headers['mcp-session-id'] = sessionId;
|
|
586
|
+
res.writeHead(statusCode, headers);
|
|
587
|
+
for (const m of msgs) {
|
|
588
|
+
res.write(`event: message\ndata: ${JSON.stringify(m)}\n\n`);
|
|
589
|
+
}
|
|
590
|
+
res.end();
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
// ── HTTP server ────────────────────────────────────────────────────────────
|
|
594
|
+
|
|
595
|
+
const server = http.createServer(async (req, res) => {
|
|
596
|
+
const url = req.url?.split('?')[0];
|
|
597
|
+
// Validate session ID as a UUID — all legitimate IDs are created by randomUUID().
|
|
598
|
+
// Non-matching values become '' so sessions.has('') is always false.
|
|
599
|
+
const rawSid = req.headers['mcp-session-id']?.trim() || '';
|
|
600
|
+
const sid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(rawSid)
|
|
601
|
+
? rawSid : '';
|
|
602
|
+
const safeMethod = req.method?.replace(/[^\w-]/g, '?') ?? 'UNKNOWN';
|
|
603
|
+
const safeUrl = (url ?? '').replace(/[^\w/.-]/g, '?');
|
|
604
|
+
const safeSid = sid.slice(0, 8) || 'none';
|
|
605
|
+
log(`${safeMethod} ${safeUrl} sid=${safeSid}`);
|
|
606
|
+
|
|
607
|
+
try {
|
|
608
|
+
await route(req, res, url, sid);
|
|
609
|
+
} catch (err) {
|
|
610
|
+
logErr('unhandled:', err);
|
|
611
|
+
if (!res.headersSent) { res.writeHead(500); res.end(); }
|
|
612
|
+
}
|
|
613
|
+
});
|
|
614
|
+
|
|
615
|
+
const LOOPBACK = new Set(['localhost', '127.0.0.1', '::1']);
|
|
616
|
+
|
|
617
|
+
async function route(req, res, url, sid) {
|
|
618
|
+
// Reject cross-origin browser requests to defend against DNS-rebinding.
|
|
619
|
+
// Direct tool/CLI calls don't send Origin so this only fires for browsers.
|
|
620
|
+
const origin = req.headers['origin'];
|
|
621
|
+
if (origin) {
|
|
622
|
+
let originHost;
|
|
623
|
+
try { originHost = new URL(origin).hostname; } catch { originHost = null; }
|
|
624
|
+
if (!originHost || !LOOPBACK.has(originHost)) {
|
|
625
|
+
res.writeHead(403); return res.end('forbidden origin');
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
// ── OAuth ──────────────────────────────────────────────────────────────
|
|
630
|
+
if (url === '/.well-known/oauth-protected-resource') {
|
|
631
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
632
|
+
return res.end(OAUTH_RESOURCE);
|
|
633
|
+
}
|
|
634
|
+
if (url === '/.well-known/oauth-authorization-server') {
|
|
635
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
636
|
+
return res.end(OAUTH_SERVER);
|
|
637
|
+
}
|
|
638
|
+
if (url === '/authorize' && req.method === 'GET') {
|
|
639
|
+
handleAuthorize(req, res);
|
|
640
|
+
return;
|
|
641
|
+
}
|
|
642
|
+
if (url === '/token' && req.method === 'POST') {
|
|
643
|
+
req.resume(); // drain and discard body
|
|
644
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
645
|
+
return res.end(TOKEN_RESPONSE);
|
|
646
|
+
}
|
|
647
|
+
if (url === '/register' && req.method === 'POST') {
|
|
648
|
+
req.setEncoding('utf8');
|
|
649
|
+
let body = '';
|
|
650
|
+
for await (const chunk of req) {
|
|
651
|
+
if (body.length + chunk.length > 65536) { req.destroy(); res.writeHead(413); return res.end('request too large'); } // registration payloads are small
|
|
652
|
+
body += chunk;
|
|
653
|
+
}
|
|
654
|
+
let redirect_uris = [];
|
|
655
|
+
try {
|
|
656
|
+
const parsed = JSON.parse(body).redirect_uris;
|
|
657
|
+
if (Array.isArray(parsed)) {
|
|
658
|
+
redirect_uris = parsed.filter(u => {
|
|
659
|
+
try { return LOOPBACK.has(new URL(u).hostname); } catch { return false; }
|
|
660
|
+
});
|
|
661
|
+
}
|
|
662
|
+
} catch {}
|
|
663
|
+
res.writeHead(201, { 'Content-Type': 'application/json' });
|
|
664
|
+
return res.end(JSON.stringify({
|
|
665
|
+
client_id: 'public-client',
|
|
666
|
+
client_id_issued_at: Math.floor(Date.now() / 1000),
|
|
667
|
+
redirect_uris,
|
|
668
|
+
grant_types: ['client_credentials', 'authorization_code'],
|
|
669
|
+
token_endpoint_auth_method: 'none',
|
|
670
|
+
}));
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
if (url !== '/mcp') { res.writeHead(404); return res.end('not found'); }
|
|
674
|
+
|
|
675
|
+
// ── GET /mcp — notification SSE stream ────────────────────────────────
|
|
676
|
+
if (req.method === 'GET') {
|
|
677
|
+
if (!sid || !sessions.has(sid)) {
|
|
678
|
+
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
679
|
+
return res.end(JSON.stringify({ error: 'session not found' }));
|
|
680
|
+
}
|
|
681
|
+
// End any previous SSE response for this session (e.g. reconnect after network blip).
|
|
682
|
+
const prev = sseStreams.get(sid);
|
|
683
|
+
if (prev && !prev.writableEnded) {
|
|
684
|
+
try { prev.end(); } catch {}
|
|
685
|
+
}
|
|
686
|
+
res.writeHead(200, {
|
|
687
|
+
'Content-Type': 'text/event-stream',
|
|
688
|
+
'Cache-Control': 'no-cache, no-transform',
|
|
689
|
+
'Connection': 'keep-alive',
|
|
690
|
+
'mcp-session-id': sid,
|
|
691
|
+
});
|
|
692
|
+
res.flushHeaders();
|
|
693
|
+
sseStreams.set(sid, res);
|
|
694
|
+
// On close: remove the stream but keep the session alive so that POST
|
|
695
|
+
// requests (e.g. from a shim that reconnects the SSE stream) still work.
|
|
696
|
+
req.on('close', () => {
|
|
697
|
+
if (sseStreams.get(sid) === res) sseStreams.delete(sid);
|
|
698
|
+
});
|
|
699
|
+
return;
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
// ── POST /mcp ──────────────────────────────────────────────────────────
|
|
703
|
+
if (req.method !== 'POST') {
|
|
704
|
+
res.writeHead(405);
|
|
705
|
+
return res.end('method not allowed');
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
const ct = req.headers['content-type'] || '';
|
|
709
|
+
if (!ct.includes('application/json')) {
|
|
710
|
+
res.writeHead(415); return res.end('content-type must be application/json');
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
req.setEncoding('utf8');
|
|
714
|
+
let body = '';
|
|
715
|
+
for await (const chunk of req) {
|
|
716
|
+
if (body.length + chunk.length > 10 * 1024 * 1024) { // generous ceiling for large tool-call payloads (e.g. base64 binary content)
|
|
717
|
+
logErr(`request body too large at ${body.length + chunk.length} bytes (limit=10MB)`);
|
|
718
|
+
req.destroy(); res.writeHead(413); return res.end('request too large');
|
|
719
|
+
}
|
|
720
|
+
body += chunk;
|
|
721
|
+
}
|
|
722
|
+
if (body.length > 10_000) log(` → request body: ${body.length} bytes`);
|
|
723
|
+
|
|
724
|
+
let msg;
|
|
725
|
+
try { msg = JSON.parse(body); } catch {
|
|
726
|
+
res.writeHead(400); return res.end('invalid json');
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
// Clamp msg.id to valid JSON-RPC types (string | number | null) before echoing in responses.
|
|
730
|
+
const msgId = (typeof msg.id === 'string' || typeof msg.id === 'number' || msg.id === null)
|
|
731
|
+
? msg.id : null;
|
|
732
|
+
|
|
733
|
+
// Log the JSON-RPC method (and tool name for tools/call) so requests are traceable.
|
|
734
|
+
const toolName = msg.method === 'tools/call' ? ` (${msg.params?.name ?? '?'})` : '';
|
|
735
|
+
log(` → ${msg.method ?? '?'}${toolName} sid=${sid.slice(0, 8) || 'none'}`);
|
|
736
|
+
|
|
737
|
+
// initialize → new session, no forwarding needed
|
|
738
|
+
if (msg.method === 'initialize') {
|
|
739
|
+
const newSid = randomUUID();
|
|
740
|
+
if (!addSession(newSid)) {
|
|
741
|
+
res.writeHead(503, { 'Content-Type': 'application/json' });
|
|
742
|
+
return res.end(JSON.stringify({ jsonrpc: '2.0', id: msgId, error: { code: -32603, message: 'session table full' } }));
|
|
743
|
+
}
|
|
744
|
+
return sendSse(res, 200, newSid, [{
|
|
745
|
+
jsonrpc: '2.0',
|
|
746
|
+
id: msgId,
|
|
747
|
+
result: SERVER_CAPS,
|
|
748
|
+
}]);
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
// All other requests need a valid session — 404 signals the shim to re-initialize.
|
|
752
|
+
if (!sid || !sessions.has(sid)) {
|
|
753
|
+
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
754
|
+
return res.end(JSON.stringify({ error: 'session not found' }));
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
// notifications (no id member per JSON-RPC 2.0) → 202, don't forward
|
|
758
|
+
// Note: id:null is technically a malformed request, not a notification, but
|
|
759
|
+
// no legitimate client sends one, so it's treated the same way.
|
|
760
|
+
if (msg.id === undefined || msg.id === null) {
|
|
761
|
+
res.writeHead(202);
|
|
762
|
+
return res.end();
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
// base-protocol liveness check — capability-independent, so it's answered
|
|
766
|
+
// unconditionally rather than falling through to the unknown-method error below.
|
|
767
|
+
if (msg.method === 'ping') {
|
|
768
|
+
return sendSse(res, 200, sid, [{ jsonrpc: '2.0', id: msgId, result: {} }]);
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
// static responses for methods this server doesn't support server-side
|
|
772
|
+
if (msg.method === 'resources/list') {
|
|
773
|
+
return sendSse(res, 200, sid, [{ jsonrpc: '2.0', id: msgId, result: { resources: [] } }]);
|
|
774
|
+
}
|
|
775
|
+
if (msg.method === 'prompts/list') {
|
|
776
|
+
return sendSse(res, 200, sid, [{ jsonrpc: '2.0', id: msgId, result: { prompts: [] } }]);
|
|
777
|
+
}
|
|
778
|
+
if (msg.method === 'tools/list') {
|
|
779
|
+
return sendSse(res, 200, sid, [{ jsonrpc: '2.0', id: msgId, result: { tools: TOOLS } }]);
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
// list-vaults is answered directly from the configured vaults map
|
|
783
|
+
if (msg.method === 'tools/call' && msg.params?.name === 'list-vaults') {
|
|
784
|
+
const names = Object.keys(VAULTS).sort();
|
|
785
|
+
return sendSse(res, 200, sid, [{ jsonrpc: '2.0', id: msgId, result: {
|
|
786
|
+
content: [{ type: 'text', text: `Available vaults:\n${names.map(n => ` - ${n}`).join('\n')}` }],
|
|
787
|
+
} }]);
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
// path access control for tool calls
|
|
791
|
+
if (msg.method === 'tools/call') {
|
|
792
|
+
const denied = checkAccess(msg.params?.name, msg.params?.arguments ?? {});
|
|
793
|
+
if (denied) {
|
|
794
|
+
const safeName = String(msg.params?.name ?? '').replace(/[\r\n]/g, '?');
|
|
795
|
+
log(`DENY ${safeName}: ${denied}`);
|
|
796
|
+
return sendSse(res, 200, sid, [{
|
|
797
|
+
jsonrpc: '2.0',
|
|
798
|
+
id: msgId,
|
|
799
|
+
result: { content: [{ type: 'text', text: denied }], isError: true },
|
|
800
|
+
}]);
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
// ── native tool handlers ──────────────────────────────────────────
|
|
805
|
+
if (msg.method === 'tools/call' && msg.params?.name === 'list-notes') {
|
|
806
|
+
const args = msg.params.arguments ?? {};
|
|
807
|
+
const vault = VAULTS[args.vault];
|
|
808
|
+
if (!vault) {
|
|
809
|
+
return sendSse(res, 200, sid, [{ jsonrpc: '2.0', id: msgId, result: {
|
|
810
|
+
content: [{ type: 'text', text: `Unknown vault: ${args.vault}` }], isError: true,
|
|
811
|
+
} }]);
|
|
812
|
+
}
|
|
813
|
+
const relScope = args.path ? normPath(args.path) : null;
|
|
814
|
+
if (relScope && _isDenied(vault.denyPaths, relScope)) {
|
|
815
|
+
return sendSse(res, 200, sid, [{ jsonrpc: '2.0', id: msgId, result: {
|
|
816
|
+
content: [{ type: 'text', text: 'Access denied' }], isError: true,
|
|
817
|
+
} }]);
|
|
818
|
+
}
|
|
819
|
+
const scopePath = relScope ? path.join(vault.path, relScope) : vault.path;
|
|
820
|
+
try {
|
|
821
|
+
const notes = [];
|
|
822
|
+
await libWalkVault(scopePath, async (filePath) => {
|
|
823
|
+
const rel = path.relative(vault.path, filePath);
|
|
824
|
+
if (_isDenied(vault.denyPaths, rel)) return;
|
|
825
|
+
notes.push(rel);
|
|
826
|
+
});
|
|
827
|
+
notes.sort();
|
|
828
|
+
const lines = [];
|
|
829
|
+
for (const rel of notes) {
|
|
830
|
+
const stat = await fs.stat(path.join(vault.path, rel));
|
|
831
|
+
lines.push(`${rel}\t${formatMtime(stat)}`);
|
|
832
|
+
}
|
|
833
|
+
return sendSse(res, 200, sid, [{ jsonrpc: '2.0', id: msgId, result: {
|
|
834
|
+
content: [{ type: 'text', text: lines.length ? lines.join('\n') : 'No notes found' }],
|
|
835
|
+
} }]);
|
|
836
|
+
} catch (err) {
|
|
837
|
+
return sendSse(res, 200, sid, [{ jsonrpc: '2.0', id: msgId, result: {
|
|
838
|
+
content: [{ type: 'text', text: `Error listing notes: ${err.message.replaceAll(vault.path + '/', '')}` }], isError: true,
|
|
839
|
+
} }]);
|
|
840
|
+
}
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
if (msg.method === 'tools/call' && msg.params?.name === 'list-tags') {
|
|
844
|
+
const args = msg.params.arguments ?? {};
|
|
845
|
+
const vault = VAULTS[args.vault];
|
|
846
|
+
if (!vault) {
|
|
847
|
+
return sendSse(res, 200, sid, [{ jsonrpc: '2.0', id: msgId, result: {
|
|
848
|
+
content: [{ type: 'text', text: `Unknown vault: ${args.vault}` }], isError: true,
|
|
849
|
+
} }]);
|
|
850
|
+
}
|
|
851
|
+
const relScope = args.path ? normPath(args.path) : null;
|
|
852
|
+
if (relScope && _isDenied(vault.denyPaths, relScope)) {
|
|
853
|
+
return sendSse(res, 200, sid, [{ jsonrpc: '2.0', id: msgId, result: {
|
|
854
|
+
content: [{ type: 'text', text: 'Access denied' }], isError: true,
|
|
855
|
+
} }]);
|
|
856
|
+
}
|
|
857
|
+
const scopePath = relScope ? path.join(vault.path, relScope) : vault.path;
|
|
858
|
+
try {
|
|
859
|
+
const tags = new Set();
|
|
860
|
+
await libWalkVault(scopePath, async (filePath) => {
|
|
861
|
+
const rel = path.relative(vault.path, filePath);
|
|
862
|
+
if (_isDenied(vault.denyPaths, rel)) return;
|
|
863
|
+
const content = await fs.readFile(filePath, 'utf8');
|
|
864
|
+
for (const tag of fmParseTags(content)) tags.add(tag);
|
|
865
|
+
});
|
|
866
|
+
const sorted = [...tags].sort();
|
|
867
|
+
return sendSse(res, 200, sid, [{ jsonrpc: '2.0', id: msgId, result: {
|
|
868
|
+
content: [{ type: 'text', text: sorted.length ? sorted.join('\n') : 'No tags found' }],
|
|
869
|
+
} }]);
|
|
870
|
+
} catch (err) {
|
|
871
|
+
return sendSse(res, 200, sid, [{ jsonrpc: '2.0', id: msgId, result: {
|
|
872
|
+
content: [{ type: 'text', text: `Error listing tags: ${err.message.replaceAll(vault.path + '/', '')}` }], isError: true,
|
|
873
|
+
} }]);
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
if (msg.method === 'tools/call' && msg.params?.name === 'search-tags') {
|
|
878
|
+
const args = msg.params.arguments ?? {};
|
|
879
|
+
const vault = VAULTS[args.vault];
|
|
880
|
+
if (!vault) {
|
|
881
|
+
return sendSse(res, 200, sid, [{ jsonrpc: '2.0', id: msgId, result: {
|
|
882
|
+
content: [{ type: 'text', text: `Unknown vault: ${args.vault}` }], isError: true,
|
|
883
|
+
} }]);
|
|
884
|
+
}
|
|
885
|
+
const queryTags = Array.isArray(args.tags) ? args.tags.map(t => String(t).toLowerCase()) : [];
|
|
886
|
+
if (!queryTags.length) {
|
|
887
|
+
return sendSse(res, 200, sid, [{ jsonrpc: '2.0', id: msgId, result: {
|
|
888
|
+
content: [{ type: 'text', text: 'No tags specified' }], isError: true,
|
|
889
|
+
} }]);
|
|
890
|
+
}
|
|
891
|
+
const relScope = args.path ? normPath(args.path) : null;
|
|
892
|
+
if (relScope && _isDenied(vault.denyPaths, relScope)) {
|
|
893
|
+
return sendSse(res, 200, sid, [{ jsonrpc: '2.0', id: msgId, result: {
|
|
894
|
+
content: [{ type: 'text', text: 'Access denied' }], isError: true,
|
|
895
|
+
} }]);
|
|
896
|
+
}
|
|
897
|
+
const scopePath = relScope ? path.join(vault.path, relScope) : vault.path;
|
|
898
|
+
try {
|
|
899
|
+
const matches = [];
|
|
900
|
+
await libWalkVault(scopePath, async (filePath) => {
|
|
901
|
+
const rel = path.relative(vault.path, filePath);
|
|
902
|
+
if (_isDenied(vault.denyPaths, rel)) return;
|
|
903
|
+
const content = await fs.readFile(filePath, 'utf8');
|
|
904
|
+
const noteTags = fmParseTags(content).map(t => t.toLowerCase());
|
|
905
|
+
if (queryTags.every(t => noteTags.includes(t))) matches.push(rel);
|
|
906
|
+
});
|
|
907
|
+
matches.sort();
|
|
908
|
+
return sendSse(res, 200, sid, [{ jsonrpc: '2.0', id: msgId, result: {
|
|
909
|
+
content: [{ type: 'text', text: matches.length ? matches.join('\n') : 'No notes found' }],
|
|
910
|
+
} }]);
|
|
911
|
+
} catch (err) {
|
|
912
|
+
return sendSse(res, 200, sid, [{ jsonrpc: '2.0', id: msgId, result: {
|
|
913
|
+
content: [{ type: 'text', text: `Error searching tags: ${err.message.replaceAll(vault.path + '/', '')}` }], isError: true,
|
|
914
|
+
} }]);
|
|
915
|
+
}
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
if (msg.method === 'tools/call' && (msg.params?.name === 'new-notes' || msg.params?.name === 'changed-notes')) {
|
|
919
|
+
const toolName = msg.params.name;
|
|
920
|
+
const args = msg.params.arguments ?? {};
|
|
921
|
+
const vault = VAULTS[args.vault];
|
|
922
|
+
if (!vault) {
|
|
923
|
+
return sendSse(res, 200, sid, [{ jsonrpc: '2.0', id: msgId, result: {
|
|
924
|
+
content: [{ type: 'text', text: `Unknown vault: ${args.vault}` }], isError: true,
|
|
925
|
+
} }]);
|
|
926
|
+
}
|
|
927
|
+
let cutoffMs;
|
|
928
|
+
if (args.since !== undefined) {
|
|
929
|
+
cutoffMs = new Date(args.since).getTime();
|
|
930
|
+
if (Number.isNaN(cutoffMs)) {
|
|
931
|
+
return sendSse(res, 200, sid, [{ jsonrpc: '2.0', id: msgId, result: {
|
|
932
|
+
content: [{ type: 'text', text: `Invalid since timestamp: ${args.since}` }], isError: true,
|
|
933
|
+
} }]);
|
|
934
|
+
}
|
|
935
|
+
} else {
|
|
936
|
+
cutoffMs = Date.now() - 7 * 24 * 60 * 60 * 1000;
|
|
937
|
+
}
|
|
938
|
+
const relScope = args.path ? normPath(args.path) : null;
|
|
939
|
+
if (relScope && _isDenied(vault.denyPaths, relScope)) {
|
|
940
|
+
return sendSse(res, 200, sid, [{ jsonrpc: '2.0', id: msgId, result: {
|
|
941
|
+
content: [{ type: 'text', text: 'Access denied' }], isError: true,
|
|
942
|
+
} }]);
|
|
943
|
+
}
|
|
944
|
+
const scopePath = relScope ? path.join(vault.path, relScope) : vault.path;
|
|
945
|
+
try {
|
|
946
|
+
const matches = [];
|
|
947
|
+
await libWalkVault(scopePath, async (filePath) => {
|
|
948
|
+
const rel = path.relative(vault.path, filePath);
|
|
949
|
+
if (_isDenied(vault.denyPaths, rel)) return;
|
|
950
|
+
const stat = await fs.stat(filePath);
|
|
951
|
+
const timeMs = toolName === 'new-notes' ? stat.birthtimeMs : stat.mtimeMs;
|
|
952
|
+
if (timeMs >= cutoffMs) matches.push(rel);
|
|
953
|
+
});
|
|
954
|
+
matches.sort();
|
|
955
|
+
return sendSse(res, 200, sid, [{ jsonrpc: '2.0', id: msgId, result: {
|
|
956
|
+
content: [{ type: 'text', text: matches.length ? matches.join('\n') : 'No notes found' }],
|
|
957
|
+
} }]);
|
|
958
|
+
} catch (err) {
|
|
959
|
+
return sendSse(res, 200, sid, [{ jsonrpc: '2.0', id: msgId, result: {
|
|
960
|
+
content: [{ type: 'text', text: `Error: ${err.message.replaceAll(vault.path + '/', '')}` }], isError: true,
|
|
961
|
+
} }]);
|
|
962
|
+
}
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
// ── Phase 2 native handlers ──────────────────────────────────────
|
|
966
|
+
|
|
967
|
+
if (msg.method === 'tools/call' && msg.params?.name === 'read-note') {
|
|
968
|
+
const args = msg.params.arguments ?? {};
|
|
969
|
+
const vault = VAULTS[args.vault];
|
|
970
|
+
if (!vault) return toolErr(res, sid, msgId, `Unknown vault: ${args.vault}`);
|
|
971
|
+
const relPath = normPath(args.folder, args.filename);
|
|
972
|
+
if (!relPath) return toolErr(res, sid, msgId, 'filename is required');
|
|
973
|
+
if (_isDenied(vault.denyPaths, relPath)) return toolErr(res, sid, msgId, 'Access denied');
|
|
974
|
+
try {
|
|
975
|
+
const absPath = path.join(vault.path, relPath);
|
|
976
|
+
const content = await libReadNote(absPath);
|
|
977
|
+
const stat = await fs.stat(absPath);
|
|
978
|
+
return sendSse(res, 200, sid, [{ jsonrpc: '2.0', id: msgId, result: {
|
|
979
|
+
content: [
|
|
980
|
+
{ type: 'text', text: content },
|
|
981
|
+
{ type: 'text', text: `Last-Modified: ${formatMtime(stat)}` },
|
|
982
|
+
],
|
|
983
|
+
} }]);
|
|
984
|
+
} catch (err) {
|
|
985
|
+
return toolErr(res, sid, msgId, err.message.replaceAll(vault.path + '/', ''));
|
|
986
|
+
}
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
if (msg.method === 'tools/call' && msg.params?.name === 'create-note') {
|
|
990
|
+
const args = msg.params.arguments ?? {};
|
|
991
|
+
const vault = VAULTS[args.vault];
|
|
992
|
+
if (!vault) return toolErr(res, sid, msgId, `Unknown vault: ${args.vault}`);
|
|
993
|
+
const relPath = normPath(args.folder, args.filename);
|
|
994
|
+
if (!relPath) return toolErr(res, sid, msgId, 'filename is required');
|
|
995
|
+
if (_isDenied(vault.denyPaths, relPath)) return toolErr(res, sid, msgId, 'Access denied');
|
|
996
|
+
const absPath = path.join(vault.path, relPath);
|
|
997
|
+
try {
|
|
998
|
+
await fs.access(absPath);
|
|
999
|
+
return toolErr(res, sid, msgId, `Note already exists: ${relPath}`);
|
|
1000
|
+
} catch (err) {
|
|
1001
|
+
if (err.code !== 'ENOENT') return toolErr(res, sid, msgId, err.message.replaceAll(vault.path + '/', ''));
|
|
1002
|
+
}
|
|
1003
|
+
try {
|
|
1004
|
+
await libWriteNote(absPath, args.content ?? '');
|
|
1005
|
+
return toolOk(res, sid, msgId, `Created: ${relPath}`);
|
|
1006
|
+
} catch (err) {
|
|
1007
|
+
return toolErr(res, sid, msgId, err.message.replaceAll(vault.path + '/', ''));
|
|
1008
|
+
}
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
if (msg.method === 'tools/call' && msg.params?.name === 'edit-note') {
|
|
1012
|
+
const args = msg.params.arguments ?? {};
|
|
1013
|
+
const vault = VAULTS[args.vault];
|
|
1014
|
+
if (!vault) return toolErr(res, sid, msgId, `Unknown vault: ${args.vault}`);
|
|
1015
|
+
const relPath = normPath(args.folder, args.filename);
|
|
1016
|
+
if (!relPath) return toolErr(res, sid, msgId, 'filename is required');
|
|
1017
|
+
if (_isDenied(vault.denyPaths, relPath)) return toolErr(res, sid, msgId, 'Access denied');
|
|
1018
|
+
const op = args.operation;
|
|
1019
|
+
if (!['append', 'prepend', 'replace', 'replace-section', 'delete-section', 'toggle-checkbox'].includes(op))
|
|
1020
|
+
return toolErr(res, sid, msgId, `Invalid operation: ${op}`);
|
|
1021
|
+
if ((op === 'replace-section' || op === 'delete-section') && !args.heading)
|
|
1022
|
+
return toolErr(res, sid, msgId, `heading is required for ${op}`);
|
|
1023
|
+
if (op === 'toggle-checkbox' && !args.taskText)
|
|
1024
|
+
return toolErr(res, sid, msgId, 'taskText is required for toggle-checkbox');
|
|
1025
|
+
const absPath = path.join(vault.path, relPath);
|
|
1026
|
+
try {
|
|
1027
|
+
if (args.expectedMtime !== undefined) await assertUnmodified(absPath, args.expectedMtime);
|
|
1028
|
+
if (op === 'replace') {
|
|
1029
|
+
await libWriteNote(absPath, args.content ?? '');
|
|
1030
|
+
} else if (op === 'replace-section') {
|
|
1031
|
+
const existing = await libReadNote(absPath);
|
|
1032
|
+
const updated = replaceSection(existing, args.heading, args.content ?? '', args.occurrence);
|
|
1033
|
+
await libWriteNote(absPath, updated);
|
|
1034
|
+
} else if (op === 'delete-section') {
|
|
1035
|
+
const existing = await libReadNote(absPath);
|
|
1036
|
+
const updated = deleteSection(existing, args.heading, args.occurrence);
|
|
1037
|
+
await libWriteNote(absPath, updated);
|
|
1038
|
+
} else if (op === 'toggle-checkbox') {
|
|
1039
|
+
const existing = await libReadNote(absPath);
|
|
1040
|
+
const updated = toggleCheckbox(existing, args.taskText, args.checked, args.occurrence);
|
|
1041
|
+
await libWriteNote(absPath, updated);
|
|
1042
|
+
} else {
|
|
1043
|
+
const existing = await libReadNote(absPath);
|
|
1044
|
+
const newContent = args.content ?? '';
|
|
1045
|
+
const updated = op === 'append' ? existing + newContent : newContent + existing;
|
|
1046
|
+
await libWriteNote(absPath, updated);
|
|
1047
|
+
}
|
|
1048
|
+
return toolOk(res, sid, msgId, `Edited (${op}): ${relPath}`);
|
|
1049
|
+
} catch (err) {
|
|
1050
|
+
return toolErr(res, sid, msgId, err.message.replaceAll(vault.path + '/', ''));
|
|
1051
|
+
}
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
if (msg.method === 'tools/call' && msg.params?.name === 'delete-note') {
|
|
1055
|
+
const args = msg.params.arguments ?? {};
|
|
1056
|
+
const vault = VAULTS[args.vault];
|
|
1057
|
+
if (!vault) return toolErr(res, sid, msgId, `Unknown vault: ${args.vault}`);
|
|
1058
|
+
const relPath = normPath(args.folder, args.filename);
|
|
1059
|
+
if (!relPath) return toolErr(res, sid, msgId, 'filename is required');
|
|
1060
|
+
if (_isDenied(vault.denyPaths, relPath)) return toolErr(res, sid, msgId, 'Access denied');
|
|
1061
|
+
const absPath = path.join(vault.path, relPath);
|
|
1062
|
+
try {
|
|
1063
|
+
if (args.expectedMtime !== undefined) await assertUnmodified(absPath, args.expectedMtime);
|
|
1064
|
+
const permanent = args.permanent === true;
|
|
1065
|
+
await libDeleteNote(absPath, permanent, vault.path);
|
|
1066
|
+
return toolOk(res, sid, msgId, permanent ? `Deleted: ${relPath}` : `Moved to trash: ${relPath}`);
|
|
1067
|
+
} catch (err) {
|
|
1068
|
+
return toolErr(res, sid, msgId, err.message.replaceAll(vault.path + '/', ''));
|
|
1069
|
+
}
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
if (msg.method === 'tools/call' && msg.params?.name === 'move-note') {
|
|
1073
|
+
const args = msg.params.arguments ?? {};
|
|
1074
|
+
const vault = VAULTS[args.vault];
|
|
1075
|
+
if (!vault) return toolErr(res, sid, msgId, `Unknown vault: ${args.vault}`);
|
|
1076
|
+
const srcRel = normPath(args.folder, args.filename);
|
|
1077
|
+
const dstRel = normPath(args.newFolder, args.newFilename);
|
|
1078
|
+
if (!srcRel) return toolErr(res, sid, msgId, 'filename is required');
|
|
1079
|
+
if (!dstRel) return toolErr(res, sid, msgId, 'newFilename is required');
|
|
1080
|
+
if (_isDenied(vault.denyPaths, srcRel)) return toolErr(res, sid, msgId, 'Access denied: source is restricted');
|
|
1081
|
+
if (_isDenied(vault.denyPaths, dstRel)) return toolErr(res, sid, msgId, 'Access denied: destination is restricted');
|
|
1082
|
+
try {
|
|
1083
|
+
if (args.expectedMtime !== undefined) await assertUnmodified(path.join(vault.path, srcRel), args.expectedMtime);
|
|
1084
|
+
await libMoveNote(vault.path, path.join(vault.path, srcRel), path.join(vault.path, dstRel), vault.denyPaths);
|
|
1085
|
+
return toolOk(res, sid, msgId, `Moved: ${srcRel} → ${dstRel}`);
|
|
1086
|
+
} catch (err) {
|
|
1087
|
+
return toolErr(res, sid, msgId, err.message.replaceAll(vault.path + '/', ''));
|
|
1088
|
+
}
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
if (msg.method === 'tools/call' && msg.params?.name === 'create-binary-file') {
|
|
1092
|
+
const args = msg.params.arguments ?? {};
|
|
1093
|
+
const vault = VAULTS[args.vault];
|
|
1094
|
+
if (!vault) return toolErr(res, sid, msgId, `Unknown vault: ${args.vault}`);
|
|
1095
|
+
const relPath = normPath(args.folder, args.filename);
|
|
1096
|
+
if (!relPath) return toolErr(res, sid, msgId, 'filename is required');
|
|
1097
|
+
if (relPath.toLowerCase().endsWith('.md')) return toolErr(res, sid, msgId, 'Use create-note for .md files');
|
|
1098
|
+
if (_isDenied(vault.denyPaths, relPath)) return toolErr(res, sid, msgId, 'Access denied');
|
|
1099
|
+
if (typeof args.content !== 'string') return toolErr(res, sid, msgId, 'content must be a base64-encoded string');
|
|
1100
|
+
const b64Body = args.content.replace(/\s/g, '');
|
|
1101
|
+
if (!/^[A-Za-z0-9+/]*={0,2}$/.test(b64Body) || b64Body.length % 4 !== 0) {
|
|
1102
|
+
return toolErr(res, sid, msgId, 'content is not valid base64');
|
|
1103
|
+
}
|
|
1104
|
+
const absPath = path.join(vault.path, relPath);
|
|
1105
|
+
try {
|
|
1106
|
+
await fs.access(absPath);
|
|
1107
|
+
return toolErr(res, sid, msgId, `File already exists: ${relPath}`);
|
|
1108
|
+
} catch (err) {
|
|
1109
|
+
if (err.code !== 'ENOENT') return toolErr(res, sid, msgId, err.message.replaceAll(vault.path + '/', ''));
|
|
1110
|
+
}
|
|
1111
|
+
try {
|
|
1112
|
+
const buffer = Buffer.from(args.content, 'base64');
|
|
1113
|
+
await libWriteBinaryFile(absPath, buffer);
|
|
1114
|
+
return toolOk(res, sid, msgId, `Created: ${relPath}`);
|
|
1115
|
+
} catch (err) {
|
|
1116
|
+
return toolErr(res, sid, msgId, err.message.replaceAll(vault.path + '/', ''));
|
|
1117
|
+
}
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
if (msg.method === 'tools/call' && msg.params?.name === 'fetch-binary-file') {
|
|
1121
|
+
const args = msg.params.arguments ?? {};
|
|
1122
|
+
const vault = VAULTS[args.vault];
|
|
1123
|
+
if (!vault) return toolErr(res, sid, msgId, `Unknown vault: ${args.vault}`);
|
|
1124
|
+
const relPath = normPath(args.folder, args.filename);
|
|
1125
|
+
if (!relPath) return toolErr(res, sid, msgId, 'filename is required');
|
|
1126
|
+
if (relPath.toLowerCase().endsWith('.md')) return toolErr(res, sid, msgId, 'Use create-note for .md files');
|
|
1127
|
+
if (_isDenied(vault.denyPaths, relPath)) return toolErr(res, sid, msgId, 'Access denied');
|
|
1128
|
+
if (!args.url) return toolErr(res, sid, msgId, 'url is required');
|
|
1129
|
+
// Number(), not parseInt() — parseInt silently truncates a decimal or trailing
|
|
1130
|
+
// garbage ("1.5"/"100abc" -> 100) instead of rejecting it; these are caller
|
|
1131
|
+
// input, unlike the config file's own already-parseInt-style numeric fields.
|
|
1132
|
+
const maxBytes = args.maxBytes !== undefined ? Number(args.maxBytes) : FETCH_MAX_BYTES;
|
|
1133
|
+
if (!Number.isInteger(maxBytes) || maxBytes < 1) return toolErr(res, sid, msgId, 'maxBytes must be a positive integer');
|
|
1134
|
+
const timeoutMs = args.timeoutMs !== undefined ? Number(args.timeoutMs) : FETCH_TIMEOUT_MS;
|
|
1135
|
+
if (!Number.isInteger(timeoutMs) || timeoutMs < 1) return toolErr(res, sid, msgId, 'timeoutMs must be a positive integer');
|
|
1136
|
+
const absPath = path.join(vault.path, relPath);
|
|
1137
|
+
// Destination existence is confirmed before any network call, so a colliding
|
|
1138
|
+
// path never causes an outbound request; fetchToBuffer validates the URL (and
|
|
1139
|
+
// every redirect hop) before the request that follows it.
|
|
1140
|
+
try {
|
|
1141
|
+
await fs.access(absPath);
|
|
1142
|
+
return toolErr(res, sid, msgId, `File already exists: ${relPath}`);
|
|
1143
|
+
} catch (err) {
|
|
1144
|
+
if (err.code !== 'ENOENT') return toolErr(res, sid, msgId, err.message.replaceAll(vault.path + '/', ''));
|
|
1145
|
+
}
|
|
1146
|
+
try {
|
|
1147
|
+
const buffer = await fetchToBuffer(args.url, { maxBytes, timeoutMs });
|
|
1148
|
+
await libWriteBinaryFile(absPath, buffer);
|
|
1149
|
+
return toolOk(res, sid, msgId, `Created: ${relPath} (${buffer.length} bytes)`);
|
|
1150
|
+
} catch (err) {
|
|
1151
|
+
return toolErr(res, sid, msgId, err.message.replaceAll(vault.path + '/', ''));
|
|
1152
|
+
}
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1155
|
+
if (msg.method === 'tools/call' && msg.params?.name === 'delete-binary-file') {
|
|
1156
|
+
const args = msg.params.arguments ?? {};
|
|
1157
|
+
const vault = VAULTS[args.vault];
|
|
1158
|
+
if (!vault) return toolErr(res, sid, msgId, `Unknown vault: ${args.vault}`);
|
|
1159
|
+
const relPath = normPath(args.folder, args.filename);
|
|
1160
|
+
if (!relPath) return toolErr(res, sid, msgId, 'filename is required');
|
|
1161
|
+
if (relPath.toLowerCase().endsWith('.md')) return toolErr(res, sid, msgId, 'Use delete-note for .md files');
|
|
1162
|
+
if (_isDenied(vault.denyPaths, relPath)) return toolErr(res, sid, msgId, 'Access denied');
|
|
1163
|
+
const absPath = path.join(vault.path, relPath);
|
|
1164
|
+
try {
|
|
1165
|
+
if (args.expectedMtime !== undefined) await assertUnmodified(absPath, args.expectedMtime);
|
|
1166
|
+
const permanent = args.permanent === true;
|
|
1167
|
+
await libDeleteBinaryFile(absPath, permanent, vault.path);
|
|
1168
|
+
return toolOk(res, sid, msgId, permanent ? `Deleted: ${relPath}` : `Moved to trash: ${relPath}`);
|
|
1169
|
+
} catch (err) {
|
|
1170
|
+
return toolErr(res, sid, msgId, err.message.replaceAll(vault.path + '/', ''));
|
|
1171
|
+
}
|
|
1172
|
+
}
|
|
1173
|
+
|
|
1174
|
+
if (msg.method === 'tools/call' && msg.params?.name === 'move-binary-file') {
|
|
1175
|
+
const args = msg.params.arguments ?? {};
|
|
1176
|
+
const vault = VAULTS[args.vault];
|
|
1177
|
+
if (!vault) return toolErr(res, sid, msgId, `Unknown vault: ${args.vault}`);
|
|
1178
|
+
const srcRel = normPath(args.folder, args.filename);
|
|
1179
|
+
const dstRel = normPath(args.newFolder, args.newFilename);
|
|
1180
|
+
if (!srcRel) return toolErr(res, sid, msgId, 'filename is required');
|
|
1181
|
+
if (!dstRel) return toolErr(res, sid, msgId, 'newFilename is required');
|
|
1182
|
+
if (srcRel.toLowerCase().endsWith('.md') || dstRel.toLowerCase().endsWith('.md'))
|
|
1183
|
+
return toolErr(res, sid, msgId, 'Use move-note for .md files');
|
|
1184
|
+
if (_isDenied(vault.denyPaths, srcRel)) return toolErr(res, sid, msgId, 'Access denied: source is restricted');
|
|
1185
|
+
if (_isDenied(vault.denyPaths, dstRel)) return toolErr(res, sid, msgId, 'Access denied: destination is restricted');
|
|
1186
|
+
try {
|
|
1187
|
+
if (args.expectedMtime !== undefined) await assertUnmodified(path.join(vault.path, srcRel), args.expectedMtime);
|
|
1188
|
+
await libMoveBinaryFile(vault.path, path.join(vault.path, srcRel), path.join(vault.path, dstRel), vault.denyPaths);
|
|
1189
|
+
return toolOk(res, sid, msgId, `Moved: ${srcRel} → ${dstRel}`);
|
|
1190
|
+
} catch (err) {
|
|
1191
|
+
return toolErr(res, sid, msgId, err.message.replaceAll(vault.path + '/', ''));
|
|
1192
|
+
}
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1195
|
+
if (msg.method === 'tools/call' && msg.params?.name === 'find-backlinks') {
|
|
1196
|
+
const args = msg.params.arguments ?? {};
|
|
1197
|
+
const vault = VAULTS[args.vault];
|
|
1198
|
+
if (!vault) return toolErr(res, sid, msgId, `Unknown vault: ${args.vault}`);
|
|
1199
|
+
const relPath = normPath(args.folder, args.filename);
|
|
1200
|
+
if (!relPath) return toolErr(res, sid, msgId, 'filename is required');
|
|
1201
|
+
if (_isDenied(vault.denyPaths, relPath)) return toolErr(res, sid, msgId, 'Access denied');
|
|
1202
|
+
try {
|
|
1203
|
+
const results = await libFindBacklinks(vault.path, relPath, vault.denyPaths);
|
|
1204
|
+
return toolOk(res, sid, msgId, results.length ? results.join('\n') : 'No backlinks found');
|
|
1205
|
+
} catch (err) {
|
|
1206
|
+
return toolErr(res, sid, msgId, err.message.replaceAll(vault.path + '/', ''));
|
|
1207
|
+
}
|
|
1208
|
+
}
|
|
1209
|
+
|
|
1210
|
+
if (msg.method === 'tools/call' && msg.params?.name === 'resolve-wikilink') {
|
|
1211
|
+
const args = msg.params.arguments ?? {};
|
|
1212
|
+
const vault = VAULTS[args.vault];
|
|
1213
|
+
if (!vault) return toolErr(res, sid, msgId, `Unknown vault: ${args.vault}`);
|
|
1214
|
+
if (!args.target) return toolErr(res, sid, msgId, 'target is required');
|
|
1215
|
+
if (_isDenied(vault.denyPaths, normPath(args.target))) return toolErr(res, sid, msgId, 'Access denied');
|
|
1216
|
+
try {
|
|
1217
|
+
const results = await libResolveWikilink(vault.path, args.target, vault.denyPaths);
|
|
1218
|
+
return toolOk(res, sid, msgId, results.length ? results.join('\n') : `No file resolves wikilink target: ${args.target}`);
|
|
1219
|
+
} catch (err) {
|
|
1220
|
+
return toolErr(res, sid, msgId, err.message.replaceAll(vault.path + '/', ''));
|
|
1221
|
+
}
|
|
1222
|
+
}
|
|
1223
|
+
|
|
1224
|
+
if (msg.method === 'tools/call' && msg.params?.name === 'query-graph') {
|
|
1225
|
+
const args = msg.params.arguments ?? {};
|
|
1226
|
+
const vault = VAULTS[args.vault];
|
|
1227
|
+
if (!vault) return toolErr(res, sid, msgId, `Unknown vault: ${args.vault}`);
|
|
1228
|
+
if (!args.question) return toolErr(res, sid, msgId, 'question is required');
|
|
1229
|
+
try {
|
|
1230
|
+
await fs.access(path.join(vault.path, 'graphify-out', 'graph.json'));
|
|
1231
|
+
} catch {
|
|
1232
|
+
return toolErr(res, sid, msgId, 'graphify-out/graph.json not found. Run graphify --obsidian against this vault.');
|
|
1233
|
+
}
|
|
1234
|
+
try {
|
|
1235
|
+
const output = await runGraphifyQuery(vault.path, args.question);
|
|
1236
|
+
return toolOk(res, sid, msgId, output);
|
|
1237
|
+
} catch (err) {
|
|
1238
|
+
return toolErr(res, sid, msgId, err.message.replaceAll(vault.path + '/', ''));
|
|
1239
|
+
}
|
|
1240
|
+
}
|
|
1241
|
+
|
|
1242
|
+
if (msg.method === 'tools/call' && msg.params?.name === 'create-folder') {
|
|
1243
|
+
const args = msg.params.arguments ?? {};
|
|
1244
|
+
const vault = VAULTS[args.vault];
|
|
1245
|
+
if (!vault) return toolErr(res, sid, msgId, `Unknown vault: ${args.vault}`);
|
|
1246
|
+
const relPath = normPath(args.folder);
|
|
1247
|
+
if (!relPath) return toolErr(res, sid, msgId, 'folder is required');
|
|
1248
|
+
if (_isDenied(vault.denyPaths, relPath)) return toolErr(res, sid, msgId, 'Access denied');
|
|
1249
|
+
try {
|
|
1250
|
+
await fs.mkdir(path.join(vault.path, relPath), { recursive: true });
|
|
1251
|
+
return toolOk(res, sid, msgId, `Created directory: ${relPath}`);
|
|
1252
|
+
} catch (err) {
|
|
1253
|
+
return toolErr(res, sid, msgId, err.message.replaceAll(vault.path + '/', ''));
|
|
1254
|
+
}
|
|
1255
|
+
}
|
|
1256
|
+
|
|
1257
|
+
if (msg.method === 'tools/call' && msg.params?.name === 'search-vault') {
|
|
1258
|
+
const args = msg.params.arguments ?? {};
|
|
1259
|
+
const vault = VAULTS[args.vault];
|
|
1260
|
+
if (!vault) return toolErr(res, sid, msgId, `Unknown vault: ${args.vault}`);
|
|
1261
|
+
if (!args.query) return toolErr(res, sid, msgId, 'query is required');
|
|
1262
|
+
const sType = args.searchType || 'content';
|
|
1263
|
+
if (!['content', 'filename', 'both'].includes(sType))
|
|
1264
|
+
return toolErr(res, sid, msgId, `Invalid searchType: ${sType}`);
|
|
1265
|
+
const relScope = args.path ? normPath(args.path) : null;
|
|
1266
|
+
if (relScope && _isDenied(vault.denyPaths, relScope)) return toolErr(res, sid, msgId, 'Access denied');
|
|
1267
|
+
const scopeDir = relScope ? path.join(vault.path, relScope) : vault.path;
|
|
1268
|
+
try {
|
|
1269
|
+
const lines = [];
|
|
1270
|
+
if (sType === 'content' || sType === 'both') {
|
|
1271
|
+
const results = await libSearchContent(vault.path, args.query, scopeDir, vault.denyPaths);
|
|
1272
|
+
for (const { path: p, matches } of results)
|
|
1273
|
+
for (const { line, text } of matches)
|
|
1274
|
+
lines.push(`${p}:${line}: ${text.trim()}`);
|
|
1275
|
+
}
|
|
1276
|
+
if (sType === 'filename' || sType === 'both') {
|
|
1277
|
+
const results = await libSearchFilename(vault.path, args.query, scopeDir, vault.denyPaths);
|
|
1278
|
+
for (const p of results) lines.push(p);
|
|
1279
|
+
}
|
|
1280
|
+
return toolOk(res, sid, msgId, lines.length ? lines.join('\n') : 'No results found');
|
|
1281
|
+
} catch (err) {
|
|
1282
|
+
return toolErr(res, sid, msgId, err.message.replaceAll(vault.path + '/', ''));
|
|
1283
|
+
}
|
|
1284
|
+
}
|
|
1285
|
+
|
|
1286
|
+
if (msg.method === 'tools/call' && (msg.params?.name === 'add-tags' || msg.params?.name === 'remove-tags')) {
|
|
1287
|
+
const isAdd = msg.params.name === 'add-tags';
|
|
1288
|
+
const args = msg.params.arguments ?? {};
|
|
1289
|
+
const vault = VAULTS[args.vault];
|
|
1290
|
+
if (!vault) return toolErr(res, sid, msgId, `Unknown vault: ${args.vault}`);
|
|
1291
|
+
const files = Array.isArray(args.files) ? args.files : [];
|
|
1292
|
+
const tags = Array.isArray(args.tags) ? args.tags : [];
|
|
1293
|
+
if (!files.length) return toolErr(res, sid, msgId, 'files array is required');
|
|
1294
|
+
if (!tags.length) return toolErr(res, sid, msgId, 'tags array is required');
|
|
1295
|
+
const location = args.location || 'frontmatter';
|
|
1296
|
+
if (args.expectedMtime !== undefined) {
|
|
1297
|
+
if (typeof args.expectedMtime !== 'object' || args.expectedMtime === null || Array.isArray(args.expectedMtime))
|
|
1298
|
+
return toolErr(res, sid, msgId, 'expectedMtime must be an object mapping vault-relative path to ISO 8601 mtime');
|
|
1299
|
+
// Validate every non-denied file's precondition before any file is written,
|
|
1300
|
+
// so a batch either applies wholly or not at all. This creates a window between
|
|
1301
|
+
// validating file N and writing it where drift can occur (proportional to batch
|
|
1302
|
+
// size), which is intentional and consistent with the compare-and-swap design
|
|
1303
|
+
// (not a lock). See assertUnmodified() JSDoc: we guard against realistic conflicts
|
|
1304
|
+
// (edits seconds/minutes old), not same-instant races.
|
|
1305
|
+
for (const file of files) {
|
|
1306
|
+
const relPath = normPath(file);
|
|
1307
|
+
if (_isDenied(vault.denyPaths, relPath)) continue; // will be skipped below regardless
|
|
1308
|
+
const expected = args.expectedMtime[file] ?? args.expectedMtime[relPath];
|
|
1309
|
+
if (expected === undefined) return toolErr(res, sid, msgId, `expectedMtime missing for ${relPath}`);
|
|
1310
|
+
try {
|
|
1311
|
+
await assertUnmodified(path.join(vault.path, relPath), expected);
|
|
1312
|
+
} catch (err) {
|
|
1313
|
+
return toolErr(res, sid, msgId, err.message.replaceAll(vault.path + '/', ''));
|
|
1314
|
+
}
|
|
1315
|
+
}
|
|
1316
|
+
}
|
|
1317
|
+
const updated = [], skipped = [];
|
|
1318
|
+
for (const file of files) {
|
|
1319
|
+
const relPath = normPath(file);
|
|
1320
|
+
if (_isDenied(vault.denyPaths, relPath)) { skipped.push(`${relPath} (access denied)`); continue; }
|
|
1321
|
+
try {
|
|
1322
|
+
let content = await libReadNote(path.join(vault.path, relPath));
|
|
1323
|
+
const original = content;
|
|
1324
|
+
if (location === 'frontmatter' || location === 'both') {
|
|
1325
|
+
content = isAdd ? fmAddTags(content, tags) : fmRemoveTags(content, tags);
|
|
1326
|
+
}
|
|
1327
|
+
if (location === 'content' || location === 'both') {
|
|
1328
|
+
if (isAdd) {
|
|
1329
|
+
const body = content.trimEnd();
|
|
1330
|
+
const boundary = `(?![a-zA-Z0-9_/\\-])`;
|
|
1331
|
+
const toAdd = tags.filter(t => !new RegExp(`(?:^|[ \\t])#${escRe(t)}${boundary}`, 'm').test(body));
|
|
1332
|
+
if (toAdd.length) content = body + '\n' + toAdd.map(t => `#${t}`).join(' ') + '\n';
|
|
1333
|
+
} else {
|
|
1334
|
+
for (const tag of tags) {
|
|
1335
|
+
const esc = escRe(tag);
|
|
1336
|
+
const boundary = `(?![a-zA-Z0-9_/\\-])`;
|
|
1337
|
+
// Remove at start-of-line (consuming trailing whitespace so no blank gap)
|
|
1338
|
+
content = content.replace(new RegExp(`^#${esc}${boundary}[ \\t]*`, 'gm'), '');
|
|
1339
|
+
// Remove inline (preceded by whitespace — drop the space too)
|
|
1340
|
+
content = content.replace(new RegExp(`[ \\t]#${esc}${boundary}`, 'gm'), '');
|
|
1341
|
+
}
|
|
1342
|
+
}
|
|
1343
|
+
}
|
|
1344
|
+
if (content !== original) {
|
|
1345
|
+
await libWriteNote(path.join(vault.path, relPath), content);
|
|
1346
|
+
updated.push(relPath);
|
|
1347
|
+
}
|
|
1348
|
+
} catch (err) {
|
|
1349
|
+
skipped.push(`${relPath} (${err.message.replaceAll(vault.path + '/', '')})`);
|
|
1350
|
+
}
|
|
1351
|
+
}
|
|
1352
|
+
const parts = [];
|
|
1353
|
+
if (updated.length) parts.push(`Updated ${updated.length} note(s): ${updated.join(', ')}`);
|
|
1354
|
+
if (skipped.length) parts.push(`Skipped: ${skipped.join(', ')}`);
|
|
1355
|
+
return toolOk(res, sid, msgId, parts.join('\n') || 'No changes needed');
|
|
1356
|
+
}
|
|
1357
|
+
|
|
1358
|
+
if (msg.method === 'tools/call' && msg.params?.name === 'set-frontmatter-field') {
|
|
1359
|
+
const args = msg.params.arguments ?? {};
|
|
1360
|
+
const vault = VAULTS[args.vault];
|
|
1361
|
+
if (!vault) return toolErr(res, sid, msgId, `Unknown vault: ${args.vault}`);
|
|
1362
|
+
const relPath = normPath(args.folder, args.filename);
|
|
1363
|
+
if (!relPath) return toolErr(res, sid, msgId, 'filename is required');
|
|
1364
|
+
if (_isDenied(vault.denyPaths, relPath)) return toolErr(res, sid, msgId, 'Access denied');
|
|
1365
|
+
if (!args.field) return toolErr(res, sid, msgId, 'field is required');
|
|
1366
|
+
if (args.field === 'tags') return toolErr(res, sid, msgId, 'Use add-tags/remove-tags/rename-tag for the tags field');
|
|
1367
|
+
if (!FIELD_NAME_RE.test(args.field)) return toolErr(res, sid, msgId, 'field must be a simple key (letters, digits, _, -)');
|
|
1368
|
+
if (!['string', 'number', 'boolean'].includes(typeof args.value))
|
|
1369
|
+
return toolErr(res, sid, msgId, 'value must be a string, number, or boolean');
|
|
1370
|
+
try {
|
|
1371
|
+
const absPath = path.join(vault.path, relPath);
|
|
1372
|
+
if (args.expectedMtime !== undefined) await assertUnmodified(absPath, args.expectedMtime);
|
|
1373
|
+
const content = await libReadNote(absPath);
|
|
1374
|
+
const updated = setFrontmatterField(content, args.field, args.value);
|
|
1375
|
+
await libWriteNote(absPath, updated);
|
|
1376
|
+
return toolOk(res, sid, msgId, `Set ${args.field} on ${relPath}`);
|
|
1377
|
+
} catch (err) {
|
|
1378
|
+
return toolErr(res, sid, msgId, err.message.replaceAll(vault.path + '/', ''));
|
|
1379
|
+
}
|
|
1380
|
+
}
|
|
1381
|
+
|
|
1382
|
+
if (msg.method === 'tools/call' && msg.params?.name === 'remove-frontmatter-field') {
|
|
1383
|
+
const args = msg.params.arguments ?? {};
|
|
1384
|
+
const vault = VAULTS[args.vault];
|
|
1385
|
+
if (!vault) return toolErr(res, sid, msgId, `Unknown vault: ${args.vault}`);
|
|
1386
|
+
const relPath = normPath(args.folder, args.filename);
|
|
1387
|
+
if (!relPath) return toolErr(res, sid, msgId, 'filename is required');
|
|
1388
|
+
if (_isDenied(vault.denyPaths, relPath)) return toolErr(res, sid, msgId, 'Access denied');
|
|
1389
|
+
if (!args.field) return toolErr(res, sid, msgId, 'field is required');
|
|
1390
|
+
if (args.field === 'tags') return toolErr(res, sid, msgId, 'Use add-tags/remove-tags/rename-tag for the tags field');
|
|
1391
|
+
if (!FIELD_NAME_RE.test(args.field)) return toolErr(res, sid, msgId, 'field must be a simple key (letters, digits, _, -)');
|
|
1392
|
+
try {
|
|
1393
|
+
const absPath = path.join(vault.path, relPath);
|
|
1394
|
+
if (args.expectedMtime !== undefined) await assertUnmodified(absPath, args.expectedMtime);
|
|
1395
|
+
const content = await libReadNote(absPath);
|
|
1396
|
+
const updated = removeFrontmatterField(content, args.field);
|
|
1397
|
+
if (updated === content) return toolOk(res, sid, msgId, 'No changes needed');
|
|
1398
|
+
await libWriteNote(absPath, updated);
|
|
1399
|
+
return toolOk(res, sid, msgId, `Removed ${args.field} from ${relPath}`);
|
|
1400
|
+
} catch (err) {
|
|
1401
|
+
return toolErr(res, sid, msgId, err.message.replaceAll(vault.path + '/', ''));
|
|
1402
|
+
}
|
|
1403
|
+
}
|
|
1404
|
+
|
|
1405
|
+
if (msg.method === 'tools/call' && msg.params?.name === 'rename-tag') {
|
|
1406
|
+
const args = msg.params.arguments ?? {};
|
|
1407
|
+
const vault = VAULTS[args.vault];
|
|
1408
|
+
if (!vault) return toolErr(res, sid, msgId, `Unknown vault: ${args.vault}`);
|
|
1409
|
+
if (!args.oldTag) return toolErr(res, sid, msgId, 'oldTag is required');
|
|
1410
|
+
if (!args.newTag) return toolErr(res, sid, msgId, 'newTag is required');
|
|
1411
|
+
try {
|
|
1412
|
+
let count = 0;
|
|
1413
|
+
await libWalkVault(vault.path, async filePath => {
|
|
1414
|
+
const rel = path.relative(vault.path, filePath);
|
|
1415
|
+
if (_isDenied(vault.denyPaths, rel)) return;
|
|
1416
|
+
let content;
|
|
1417
|
+
try { content = await libReadNote(filePath); } catch { return; }
|
|
1418
|
+
const updated = fmRenameTag(content, args.oldTag, args.newTag);
|
|
1419
|
+
if (updated !== content) {
|
|
1420
|
+
try { await libWriteNote(filePath, updated); count++; } catch {}
|
|
1421
|
+
}
|
|
1422
|
+
});
|
|
1423
|
+
return toolOk(res, sid, msgId, `Renamed tag '${args.oldTag}' → '${args.newTag}' in ${count} note(s)`);
|
|
1424
|
+
} catch (err) {
|
|
1425
|
+
return toolErr(res, sid, msgId, err.message.replaceAll(vault.path + '/', ''));
|
|
1426
|
+
}
|
|
1427
|
+
}
|
|
1428
|
+
|
|
1429
|
+
// Every named tool has its own handler above and returns before reaching here.
|
|
1430
|
+
if (msg.method === 'tools/call') {
|
|
1431
|
+
return toolErr(res, sid, msgId, `Unknown tool: ${msg.params?.name}`);
|
|
1432
|
+
}
|
|
1433
|
+
return sendSse(res, 200, sid, [{
|
|
1434
|
+
jsonrpc: '2.0',
|
|
1435
|
+
id: msgId,
|
|
1436
|
+
error: { code: -32601, message: `Method not found: ${msg.method}` },
|
|
1437
|
+
}]);
|
|
1438
|
+
}
|
|
1439
|
+
|
|
1440
|
+
// Runs `graphify query "<question>"` in the given vault's directory. Array-form spawn
|
|
1441
|
+
// args (no shell) so the question text can never be interpreted as shell syntax or reach
|
|
1442
|
+
// graphify as anything other than a single literal argument — graphify's own CLI reads
|
|
1443
|
+
// sys.argv[2] unconditionally as the question (it's a hand-rolled argv parser, not
|
|
1444
|
+
// argparse, with no '--' end-of-options handling), so there's no separate flag-smuggling
|
|
1445
|
+
// risk to guard against here; adding a '--' would instead shift the question to the wrong
|
|
1446
|
+
// argv position and break every real query. Killed (SIGTERM, then SIGKILL after a short
|
|
1447
|
+
// grace period if still alive) if it runs longer than GRAPHIFY_QUERY_TIMEOUT_MS, so a hung
|
|
1448
|
+
// query can't hold a request open forever or leak an orphaned process if it ignores SIGTERM.
|
|
1449
|
+
function runGraphifyQuery(vaultPath, question) {
|
|
1450
|
+
return new Promise((resolve, reject) => {
|
|
1451
|
+
const child = spawn('graphify', ['query', question], { cwd: vaultPath });
|
|
1452
|
+
let stdout = '';
|
|
1453
|
+
let stderr = '';
|
|
1454
|
+
|
|
1455
|
+
const timer = setTimeout(() => {
|
|
1456
|
+
child.kill('SIGTERM');
|
|
1457
|
+
setTimeout(() => { if (!child.killed) child.kill('SIGKILL'); }, 2000);
|
|
1458
|
+
reject(new Error(`graphify query timed out after ${GRAPHIFY_QUERY_TIMEOUT_MS}ms`));
|
|
1459
|
+
}, GRAPHIFY_QUERY_TIMEOUT_MS);
|
|
1460
|
+
|
|
1461
|
+
child.stdout.on('data', chunk => { stdout += chunk; });
|
|
1462
|
+
child.stderr.on('data', chunk => { stderr += chunk; });
|
|
1463
|
+
|
|
1464
|
+
child.on('error', err => {
|
|
1465
|
+
clearTimeout(timer);
|
|
1466
|
+
reject(new Error(err.code === 'ENOENT' ? 'graphify command not found on PATH' : err.message));
|
|
1467
|
+
});
|
|
1468
|
+
|
|
1469
|
+
child.on('exit', code => {
|
|
1470
|
+
clearTimeout(timer);
|
|
1471
|
+
if (code === 0) resolve(stdout.trim());
|
|
1472
|
+
else reject(new Error(`graphify query exited with code ${code}${stderr.trim() ? `: ${stderr.trim()}` : ''}`));
|
|
1473
|
+
});
|
|
1474
|
+
});
|
|
1475
|
+
}
|
|
1476
|
+
|
|
1477
|
+
function handleAuthorize(req, res) {
|
|
1478
|
+
req.resume(); // GET requests have no body, but drain anyway for keep-alive hygiene
|
|
1479
|
+
const qs = new URL(req.url, BASE_URL).searchParams;
|
|
1480
|
+
const redirectUri = qs.get('redirect_uri');
|
|
1481
|
+
const state = qs.get('state');
|
|
1482
|
+
if (!redirectUri) { res.writeHead(400); return res.end('missing redirect_uri'); }
|
|
1483
|
+
|
|
1484
|
+
let dest;
|
|
1485
|
+
try { dest = new URL(redirectUri); } catch {
|
|
1486
|
+
res.writeHead(400); return res.end('invalid redirect_uri');
|
|
1487
|
+
}
|
|
1488
|
+
|
|
1489
|
+
// Only redirect to loopback — the only legitimate client is Claude Code,
|
|
1490
|
+
// which always uses a local callback server.
|
|
1491
|
+
if (!LOOPBACK.has(dest.hostname)) {
|
|
1492
|
+
res.writeHead(400); return res.end('redirect_uri must target localhost');
|
|
1493
|
+
}
|
|
1494
|
+
|
|
1495
|
+
// PKCE is not enforced; code is a fixed placeholder since token exchange is also a no-op.
|
|
1496
|
+
dest.searchParams.set('code', 'public-auth-code');
|
|
1497
|
+
if (state) dest.searchParams.set('state', state.slice(0, 512));
|
|
1498
|
+
|
|
1499
|
+
res.writeHead(302, { Location: dest.toString() });
|
|
1500
|
+
res.end();
|
|
1501
|
+
}
|
|
1502
|
+
|
|
1503
|
+
// ── start ──────────────────────────────────────────────────────────────────
|
|
1504
|
+
|
|
1505
|
+
server.listen(LISTEN_PORT, '127.0.0.1', () => {
|
|
1506
|
+
log(`obsidian-mcp server listening on 127.0.0.1:${LISTEN_PORT}`);
|
|
1507
|
+
});
|