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.
107 lines • 5.52 kB
JavaScript
/**
* @fileoverview obsidian_open_in_ui — open a file in the Obsidian app UI.
* Defaults to `failIfMissing: true` because Obsidian silently creates files on
* open otherwise; opt out for an "open or create" flow.
* @module mcp-server/tools/definitions/obsidian-open-in-ui.tool
*/
import { tool, z } from '@cyanheads/mcp-ts-core';
import { JsonRpcErrorCode, McpError } from '@cyanheads/mcp-ts-core/errors';
import { getObsidianService } from '../../../services/obsidian/obsidian-service.js';
import { withCaseFallback } from './_shared/suggest-paths.js';
export const obsidianOpenInUi = tool('obsidian_open_in_ui', {
description: 'Open a file in the Obsidian app UI. By default fails when the path does not exist; the `failIfMissing` flag controls the open-or-create behavior.',
annotations: { openWorldHint: true },
input: z.object({
path: z.string().min(1).describe('Vault-relative path of the file to open.'),
failIfMissing: z
.boolean()
.default(true)
.describe('When true (default), fails if the file does not exist. When false, allows Obsidian to create the file on open.'),
newLeaf: z
.boolean()
.default(false)
.describe('Open in a new leaf (split pane) instead of the active one.'),
}),
output: z.object({
path: z.string().describe('Resolved vault-relative path that was opened.'),
opened: z.boolean().describe('True when the open call succeeded.'),
createdIfMissing: z
.boolean()
.describe('True when the file did not exist before the call and was created by Obsidian.'),
}),
auth: ['tool:obsidian_open_in_ui:write'],
errors: [
{
reason: 'path_forbidden',
code: JsonRpcErrorCode.Forbidden,
when: 'The target path is outside OBSIDIAN_READ_PATHS (and OBSIDIAN_WRITE_PATHS, since write paths imply read access).',
recovery: 'Use a path inside the configured read scope. The error data echoes the active scope.',
},
{
reason: 'note_missing',
code: JsonRpcErrorCode.NotFound,
when: '`failIfMissing: true` (default) and the path does not exist in the vault. Pass `failIfMissing: false` to allow Obsidian to create the file on open.',
recovery: 'Verify the path with obsidian_list_notes or obsidian_search_notes first — a typo would otherwise materialize as an empty file. If creation is intended, retry with failIfMissing: false.',
},
{
reason: 'ambiguous_path',
code: JsonRpcErrorCode.Conflict,
when: 'The parent directory contains multiple files whose names differ only in case (case-sensitive filesystems only).',
recovery: 'Retry with one of the exact paths listed in `matches` on the error data.',
},
],
async handler(input, ctx) {
const svc = getObsidianService();
const target = { type: 'path', path: input.path };
if (!input.failIfMissing) {
// Caller opted into "open or create" — skip the existence probe and let
// Obsidian create the file on open.
await svc.openInUi(ctx, input.path, { newLeaf: input.newLeaf });
return { path: input.path, opened: true, createdIfMissing: true };
}
let resolvedPath = input.path;
try {
const { resolvedPath: rp } = await withCaseFallback(ctx, svc, target, (t) => svc.getNoteJson(ctx, t));
resolvedPath = rp ?? input.path;
}
catch (err) {
// Match on `data.reason` rather than the JSON-RPC code so the handler text
// doesn't trip `error-contract-prefer-fail` on a comparison literal. The
// service tags path 404s with `reason: 'note_missing'` in its error data.
const reason = err instanceof McpError ? err.data?.reason : undefined;
if (reason !== 'note_missing')
throw err;
const suggestions = (err instanceof McpError && err.data?.suggestions) || [];
const hintParts = [];
if (suggestions.length > 0) {
hintParts.push(`Did you mean: ${suggestions.map((s) => `"${s}"`).join(', ')}?`);
}
// Lead with verification so a typo doesn't get materialized as an empty
// file by following the recovery hint blindly. Creation stays as the
// explicit opt-in second path.
hintParts.push('Verify the path with obsidian_list_notes or obsidian_search_notes — or, if creation is intended, retry with failIfMissing: false.');
throw ctx.fail('note_missing', `Cannot open '${input.path}' — file does not exist.`, {
path: input.path,
...(suggestions.length > 0 ? { suggestions } : {}),
recovery: { hint: hintParts.join(' ') },
}, { cause: err });
}
await svc.openInUi(ctx, resolvedPath, { newLeaf: input.newLeaf });
return {
path: resolvedPath,
opened: true,
createdIfMissing: false,
};
},
format: (result) => [
{
type: 'text',
text: [
`**Opened ${result.path}**`,
`*Opened:* ${result.opened}`,
`*Created if missing:* ${result.createdIfMissing}`,
].join('\n'),
},
],
});
//# sourceMappingURL=obsidian-open-in-ui.tool.js.map