-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathserver.js
More file actions
executable file
·356 lines (323 loc) · 14 KB
/
Copy pathserver.js
File metadata and controls
executable file
·356 lines (323 loc) · 14 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
/**
* obsidian-mcp — MCP server wrapping the Obsidian CLI.
*
* Pure factory. Construct with deps; do not import for side effects. The
* runtime entrypoint lives at `bin/server.js`.
*
* Exposes a generic `obsidian` pass-through tool plus a typed-tool registry
* (see `lib/tool-registry.js`) that registers convenience tools as data
* entries. Adding a new verb = one entry, not a hand-written `server.tool`
* block.
*/
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
import {
text,
errorResult,
jsonResult,
parseArgs,
extractLeadingVault,
} from "./lib/helpers.js";
import {
TYPED_TOOL_ENTRIES,
registerTypedTools,
} from "./lib/tool-registry.js";
import { registerVaultResources } from "./lib/resources.js";
/**
* Build a wired MCP server. Inject the CLI adapter, prompt content,
* version string, and vault state; no side effects at module load.
*
* @param {object} opts
* @param {{exec: Function, getVault: Function, setVault: Function, isObsidianRunning: Function}} opts.cli
* The Obsidian CLI adapter (see `lib/obsidian-cli.js`) plus an injected
* `isObsidianRunning` callable.
* @param {Record<string,string>} opts.prompts - Prompt content keyed by slug
* (`obsidian-cli`, `obsidian-markdown`, `obsidian-bases`, `obsidian-canvas`).
* @param {object|null} [opts.manifest] - Optional VerbManifest (see
* `lib/manifest.js`). When supplied, the generic `obsidian` pass-through
* tool gates calls through `manifest.validate(args)` and fires
* `manifest.refresh()` after successful reload-class verbs. Typed
* convenience tools bypass the manifest entirely (Zod handles their args).
* @param {string} opts.version - Server version string surfaced via MCP.
* @param {Set<string>} [opts.knownVaults] - Vault names Obsidian knows about.
* @param {string|null} [opts.runtimeVault] - Initial runtime vault (or null
* to prompt-on-first-use when knownVaults is non-empty).
* @returns {McpServer}
*/
export function createServer({
cli,
prompts,
manifest = null,
version,
knownVaults = new Set(),
runtimeVault = null,
} = {}) {
// Verbs whose successful execution invalidates the cached help output
// (the CLI restarts or a plugin reloads, so verb/flag shapes may shift).
// First positional token only — leading `vault=NAME` is stripped first.
const RELOAD_VERBS = new Set(["restart", "reload", "plugin:reload"]);
// Seed the adapter's vault state from the runtime vault. The adapter is
// the single source of truth for "what vault are we currently routed to";
// the factory just hands it the initial value.
if (runtimeVault) cli.setVault(runtimeVault);
else cli.setVault("");
function vaultPromptResponse() {
const list = [...knownVaults].sort().map((v) => ` - ${v}`).join("\n");
return text(
`No vault selected. Available vaults:\n${list}\n\n` +
`Ask the user which vault to use, then either:\n` +
` - retry through the generic \`obsidian\` tool with \`vault=NAME\` as the first token (e.g. \`vault=tyee read file="My Note"\`), or\n` +
` - retry any convenience tool — the server will cache the vault from the first \`vault=\` override and reuse it for subsequent calls.\n\n` +
`If the user named a vault in conversation (e.g. "save this in tyee"), prepend \`vault=tyee\` automatically.`
);
}
/**
* Run CLI, return MCP result. Accepts a command string or an args array.
* With `{ json: true }` (typed-tool opt-in, see #29) a successful stdout is
* parsed and returned as structured content, degrading to text on parse
* failure.
*/
async function runTool(input, { json = false } = {}) {
if (!(await cli.isObsidianRunning())) {
return errorResult(
"Obsidian.app is not running. Open Obsidian (it can stay backgrounded/minimized — no need to switch to it) and retry — no Claude Desktop restart needed.",
"OBSIDIAN_NOT_RUNNING"
);
}
// Cache caller-supplied vault override so subsequent convenience-tool calls
// route to the same vault without the caller having to repeat it.
const parsed = Array.isArray(input) ? input : parseArgs(input);
const callerVault = extractLeadingVault(parsed);
if (callerVault) {
if (knownVaults.size > 0 && !knownVaults.has(callerVault)) {
return errorResult(
`Unknown vault '${callerVault}'. Known vaults: ${[...knownVaults].sort().join(", ")}.`,
"VAULT_NOT_FOUND"
);
}
cli.setVault(callerVault);
} else if (!cli.getVault() && knownVaults.size > 0) {
return vaultPromptResponse();
}
const { stdout, stderr, error } = await cli.exec(input);
if (error) {
return errorResult(error.message, error.type);
}
if (json) return jsonResult(stdout);
const parts = [];
if (stdout) parts.push(stdout);
if (stderr) parts.push(`[stderr] ${stderr}`);
return text(parts.join("\n") || "(no output)");
}
const server = new McpServer({
name: "obsidian-mcp",
version,
capabilities: { tools: {} },
});
// ---- Generic pass-through tool ------------------------------------------
server.tool(
"obsidian",
`Run any Obsidian CLI command. Pass the full command string exactly as you
would on the terminal (minus the leading 'obsidian' binary name). Leading
\`vault=NAME\` overrides the active vault and is cached for subsequent calls.
Intent -> verb cheatsheet. Use the canonical verb on the right; the convenience
tools (\`obsidian_*\`) wrap the same verbs with typed args.
PUT
put new note from template -> templater:create-from-template template=… file=…
create plain note -> create path=… content=…
append to today's daily -> daily:append content=…
GET
read note -> read path=… (or file=…)
search content -> search:context query=… [path=… limit=…]
list properties / read one -> properties [file=…] | property:read name=… file=…
list backlinks -> backlinks file=…
MOVE/RENAME
move or rename note -> move file=… to=… (or path=…)
DELETE
delete note -> delete path=…
DISCOVER
list files -> files [folder=… ext=…]
list tags with counts -> tags counts [sort=name|count]
list tasks -> tasks [daily todo done path=…]
recently opened -> recents
CLI reference -> help [verb]
If you don't see the intent here, the CLI's \`help\` verb is the source of truth.`,
{ command: z.string().describe("CLI command and arguments") },
async ({ command }) => {
const parsed = parseArgs(command);
// Pre-call manifest validation (pass-through only — typed tools rely on
// Zod schemas). On `{ok:false, hint}` we short-circuit with the hint;
// on `{ok:false}` with no hint or `{ok:true}` we fall through to exec.
if (manifest && typeof manifest.validate === "function") {
try {
const v = await manifest.validate(parsed);
if (v && v.ok === false && v.hint) {
return { isError: true, content: [{ type: "text", text: v.hint }] };
}
} catch {
// Manifest failures must not break the pass-through. Fall through
// to exec and let the CLI surface any real error.
}
}
const result = await runTool(command);
// Reload detection — only on success, and only when the first non-vault
// token names a verb that mutates the CLI's verb/flag surface. Exactly
// one refresh per matching call.
if (!result?.isError && manifest && typeof manifest.refresh === "function") {
const first = parsed[0]?.startsWith("vault=") ? parsed[1] : parsed[0];
if (first && RELOAD_VERBS.has(first)) {
try { await manifest.refresh(); } catch { /* swallow — best-effort */ }
}
}
return result;
},
);
// ---- Typed convenience tools (registry-driven) -------------------------
//
// Every typed tool is one entry in `TYPED_TOOL_ENTRIES`. The loop wires
// each entry's Zod schema + build() into a `server.tool` registration.
// `obsidian_help` uses a custom handler (ctx.helpHandler) because it
// depends on the injected manifest + prompts map.
const helpHandler = makeHelpHandler({ manifest, prompts });
registerTypedTools(server, runTool, TYPED_TOOL_ENTRIES, { helpHandler });
// ---- MCP Resources (read-only vault metadata) --------------------------
//
// obsidian://vault, /files, /tags — lazy, cheap, read through the same CLI
// adapter the tools use (see lib/resources.js).
registerVaultResources(server, cli);
// ---- MCP Prompts -----------------------------------------------------------
const promptMeta = {
"obsidian-cli": {
title: "Obsidian CLI Reference",
description: "CLI usage patterns, parameter syntax, and command examples for the Obsidian CLI. Adapted from kepano/obsidian-skills (MIT License, https://github.com/kepano/obsidian-skills).",
},
"obsidian-markdown": {
title: "Obsidian Flavored Markdown Reference",
description: "Wikilinks, embeds, callouts, properties, tags, and other Obsidian-specific markdown syntax. Adapted from kepano/obsidian-skills (MIT License, https://github.com/kepano/obsidian-skills).",
},
"obsidian-bases": {
title: "Obsidian Bases Reference",
description: "Bases syntax for database-like views: filters, formulas, view types, and summaries. Adapted from kepano/obsidian-skills (MIT License, https://github.com/kepano/obsidian-skills).",
},
"obsidian-canvas": {
title: "JSON Canvas Reference",
description: "JSON Canvas format for visual canvases: node types, edges, groups, and layout. Adapted from kepano/obsidian-skills (MIT License, https://github.com/kepano/obsidian-skills).",
},
};
for (const [name, content] of Object.entries(prompts)) {
const meta = promptMeta[name];
if (!meta) continue;
server.registerPrompt(
name,
{ title: meta.title, description: meta.description },
() => ({
messages: [{ role: "user", content: { type: "text", text: content } }],
})
);
}
return server;
}
// ---------------------------------------------------------------------------
// obsidian_help handler — closes over the injected manifest + prompts.
// Reserved-doc-slug-wins routing lives here so the registry entry stays
// pure data.
// ---------------------------------------------------------------------------
function makeHelpHandler({ manifest, prompts }) {
return async ({ topic }) => {
if (!topic) {
if (manifest) {
const index = await manifest.all();
return text(renderVerbIndex(index));
}
return text(renderDocSlugList(prompts));
}
// Reserved doc slugs (cli/markdown/bases/canvas) are a curated namespace
// and win over a same-named live verb: the doc is what the tool advertises,
// and resolving it from the static prompts map means docs stay reachable
// even when Obsidian is down. The shadowed verb (only `bases` today) still
// appears in the no-arg index. Verb lookup serves every other topic.
const promptKey = `obsidian-${topic}`;
if (prompts && Object.prototype.hasOwnProperty.call(prompts, promptKey)) {
return text(prompts[promptKey]);
}
if (manifest) {
const verb = await manifest.forVerb(topic);
if (verb) return text(renderVerbHelp(verb));
}
return text(
`No help found for '${topic}'. Try obsidian_help() with no arguments to browse the verb index, or pass a doc slug: cli, markdown, bases, canvas.`,
);
};
}
/**
* Render a category-grouped verb index (the output of `manifest.all()`) into
* the plain text block returned by `obsidian_help()` with no args. Empty
* categories are skipped so the surface stays scannable.
*
* @param {Record<string, string[]>} index
* @returns {string}
*/
function renderVerbIndex(index) {
const lines = ["Obsidian CLI verbs (live from `obsidian help`):", ""];
let any = false;
for (const [category, verbs] of Object.entries(index)) {
if (!verbs || verbs.length === 0) continue;
any = true;
lines.push(`${category}:`);
for (const verb of verbs) {
lines.push(` ${verb}`);
}
lines.push("");
}
if (!any) {
lines.push("(no verbs reported by the CLI)");
}
lines.push("Reference docs: pass topic=cli|markdown|bases|canvas for Kepano-derived guides.");
lines.push("Verb detail: pass topic=<verb> (e.g. \"read\", \"daily:append\") for flag-level help.");
return lines.join("\n");
}
/**
* Render the help block for a single verb (the output of `manifest.forVerb`).
* Mirrors the CLI's own help formatting closely enough that callers can copy
* the example tokens verbatim.
*
* @param {{name: string, description: string, flags: Array<{name: string, valueShape: string|null, description: string}>}} verb
* @returns {string}
*/
function renderVerbHelp(verb) {
const lines = [`${verb.name} — ${verb.description}`.trimEnd()];
if (!verb.flags || verb.flags.length === 0) {
lines.push("");
lines.push("(no flags)");
return lines.join("\n");
}
lines.push("");
lines.push("Flags:");
for (const flag of verb.flags) {
const left = flag.valueShape ? `${flag.name}=${flag.valueShape}` : flag.name;
const desc = flag.description ? ` - ${flag.description}` : "";
lines.push(` ${left}${desc}`);
}
return lines.join("\n");
}
/**
* Render the bare list of available reference-doc slugs. Used as a fallback
* for `obsidian_help()` when no manifest is wired (no live verb index to
* show, so we at least advertise the static docs).
*
* @param {Record<string,string>} prompts
* @returns {string}
*/
function renderDocSlugList(prompts) {
const slugs = Object.keys(prompts || {})
.filter((k) => k.startsWith("obsidian-"))
.map((k) => k.slice("obsidian-".length))
.sort();
if (slugs.length === 0) {
return "No reference docs are loaded.";
}
return [
"Available reference docs (pass as topic=):",
...slugs.map((s) => ` ${s}`),
].join("\n");
}