UNPKG

obsidian-mcp-server

Version:

MCP server for Obsidian vaults — read, write, search, and surgically edit notes, tags, and frontmatter via the Local REST API plugin. STDIO or Streamable HTTP.

100 lines 6.12 kB
#!/usr/bin/env node /** * @fileoverview obsidian-mcp-server entry point. Initializes the Obsidian * Local REST API service at module load so the Omnisearch probe can run * before tools are constructed — `obsidian_search_notes` is built via a * factory that takes Omnisearch reachability as input, so the `omnisearch` * mode appears in the tool schema only when the plugin is actually reachable. * @module index */ import { createApp, disabledTool } from '@cyanheads/mcp-ts-core'; import { requestContextService } from '@cyanheads/mcp-ts-core/utils'; import { getServerConfig } from './config/server-config.js'; import { allPromptDefinitions } from './mcp-server/prompts/definitions/index.js'; import { allResourceDefinitions } from './mcp-server/resources/definitions/index.js'; import { buildSearchNotesTool, commandToolDefinitions, readToolDefinitions, writeToolDefinitions, } from './mcp-server/tools/definitions/index.js'; import { getObsidianService, initObsidianService } from './services/obsidian/obsidian-service.js'; import { PathPolicy } from './services/obsidian/path-policy.js'; const config = getServerConfig(); const policy = new PathPolicy(config); /** * Init the service at module load (rather than inside `setup()`) so the * Omnisearch probe can run before tool construction. `setup()` runs after * tools are passed into `createApp()`, which is too late to influence the * search-notes schema. */ initObsidianService(config); const obsidian = getObsidianService(); const omnisearchReachable = await obsidian.probeOmnisearch(); const searchNotesTool = buildSearchNotesTool({ omnisearchReachable }); /** * Build the server-level `instructions` string sent on every `initialize`. * Provides baseline orientation about the server and then layers in * deployment-specific lines (read-only mode, scoped paths, command-palette * toggle, Omnisearch availability) when those flags are active. */ function buildInstructions() { const sections = [ 'Use the `obsidian_*` tools to access the Obsidian vault via the Local REST API plugin: search, read, write, and patch notes, including targeted edits to headings, blocks, and YAML frontmatter. Notes are addressed by vault-relative path including the file extension (e.g. `Folder/Note.md`); tags support hierarchical `parent/child` notation, and counts roll up to parents.', ]; if (config.readOnly) { sections.push('Read-only mode is active (OBSIDIAN_READ_ONLY=true): every write tool rejects every path with `path_forbidden` / `read_only_mode`.'); } else if (!policy.isUnrestricted) { const { readPaths, writePaths } = policy.describe(); const render = (scope) => typeof scope === 'string' ? scope : scope.map((p) => `'${p}'`).join(', '); sections.push(`Vault path policy is enforced. Readable: ${render(readPaths)}. Writable: ${render(writePaths)}. Paths outside scope reject with \`path_forbidden\` — error data carries the active scope so you can self-correct.`); } if (config.enableCommands && !config.readOnly) { sections.push('Command-palette tools (`obsidian_list_commands`, `obsidian_execute_command`) are enabled and can fire any Obsidian command. Commands are opaque and may be destructive — prefer dedicated tools when one fits.'); } if (omnisearchReachable) { sections.push('`obsidian_search_notes` includes an `omnisearch` mode: BM25-ranked, typo-tolerant, with PDF/OCR coverage (via the Text Extractor plugin). Results cap at 50 upstream — narrow the query (quoted phrases, `-exclusion`, `path:` / `ext:` filters) to surface more.'); } return sections.join('\n\n'); } const writeTools = config.readOnly ? writeToolDefinitions.map((def) => disabledTool(def, { reason: 'Disabled by OBSIDIAN_READ_ONLY=true.', hint: 'Unset OBSIDIAN_READ_ONLY (or set it to false) to enable write tools.', })) : writeToolDefinitions; const commandTools = config.enableCommands && !config.readOnly ? commandToolDefinitions : commandToolDefinitions.map((def) => disabledTool(def, { reason: config.readOnly ? 'Disabled by OBSIDIAN_READ_ONLY=true (commands can mutate).' : 'Disabled by default — Obsidian commands are opaque and can be destructive.', hint: config.readOnly ? 'Unset OBSIDIAN_READ_ONLY to allow commands; OBSIDIAN_ENABLE_COMMANDS=true is also required.' : 'Set OBSIDIAN_ENABLE_COMMANDS=true to enable obsidian_list_commands and obsidian_execute_command.', })); const tools = [...readToolDefinitions, searchNotesTool, ...writeTools, ...commandTools]; const { services } = await createApp({ tools, resources: allResourceDefinitions, prompts: allPromptDefinitions, instructions: buildInstructions(), }); /** * Startup banner — emitted after createApp() returns so the framework's * `logger.initialize()` has run; calls inside `setup()` happen pre-init and * are dropped. Operators check this against their config to verify the active * path policy. The active scope on `path_forbidden` errors echoes the same * data so the LLM (or operator) can self-correct without scrolling the log. */ const bannerCtx = requestContextService.createRequestContext({ operation: 'startup', ...policy.describe(), enableCommands: config.enableCommands && !config.readOnly, omnisearchUrl: obsidian.omnisearchUrl, omnisearchReachable, }); services.logger.info('Path policy', bannerCtx); services.logger.info(omnisearchReachable ? `Omnisearch reachable at ${obsidian.omnisearchUrl} — \`omnisearch\` mode enabled on \`obsidian_search_notes\`.` : `Omnisearch not reachable at ${obsidian.omnisearchUrl} — \`omnisearch\` mode omitted from \`obsidian_search_notes\`. Set OBSIDIAN_OMNISEARCH_URL or enable the Omnisearch plugin's HTTP server to use it.`, bannerCtx); if (policy.readOnlyShadowsWritePaths) { services.logger.warning('OBSIDIAN_WRITE_PATHS is set but ignored because OBSIDIAN_READ_ONLY=true. Unset one of the two to remove the conflict.', bannerCtx); } //# sourceMappingURL=index.js.map