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.
125 lines • 5.39 kB
JavaScript
/**
* @fileoverview Vault path resolution helpers. Two layered behaviors:
*
* 1. **Case-insensitive fallback.** If a path lookup 404s, list the parent
* directory and look for a single case-insensitive filename match. If
* found, retry against the canonical filesystem path (matches v2.x
* behavior). This silently fixes "Readme.md" vs "README.md" on Linux; on
* Mac/Windows the OS already case-folds and the fallback is a no-op.
*
* 2. **"Did you mean" suggestions.** When no case match exists but the parent
* directory has near-matches (e.g., extension-stripped variants), re-throw
* NotFound enriched with the candidates in the message and
* `error.data.suggestions[]`.
*
* Read/open/delete tools wrap their service calls with `withCaseFallback`.
* @module mcp-server/tools/definitions/_shared/suggest-paths
*/
import { conflict, JsonRpcErrorCode, McpError, notFound } from '@cyanheads/mcp-ts-core/errors';
const MAX_SUGGESTIONS = 5;
/**
* Wrap a service call with case-insensitive path fallback and "did you mean"
* enrichment. Non-path targets pass through — their paths are resolved
* upstream, so neither layer applies.
*
* For path targets:
* - **Exact match** → returns `{ result, resolvedPath: target.path }`.
* - **Single case match** → retries with the canonical path and returns
* `{ result, resolvedPath: <canonical> }`.
* - **Multiple case matches** → throws `Conflict` with the candidates so the
* agent can disambiguate.
* - **No case match, extension-stripped near-matches** → throws `NotFound`
* enriched with a "did you mean" hint and `suggestions[]`.
* - **No matches at all** → re-throws the original NotFound unchanged.
*
* `resolvedPath` is `undefined` for non-path targets — callers derive the
* canonical path from the result itself (typically `NoteJson.path`).
*/
export async function withCaseFallback(ctx, svc, target, fn) {
if (target.type !== 'path') {
return { result: await fn(target), resolvedPath: undefined };
}
try {
return { result: await fn(target), resolvedPath: target.path };
}
catch (err) {
if (!(err instanceof McpError) || err.code !== JsonRpcErrorCode.NotFound) {
throw err;
}
const probe = await probeParentDir(ctx, svc, target.path);
const sole = probe.caseMatches.length === 1 ? probe.caseMatches[0] : undefined;
if (sole !== undefined) {
const result = await fn({ type: 'path', path: sole });
return { result, resolvedPath: sole };
}
if (probe.caseMatches.length > 1) {
const list = probe.caseMatches.map((m) => `"${m}"`).join(', ');
throw conflict(`Ambiguous case-insensitive matches for '${target.path}': ${list}.`, {
path: target.path,
reason: 'ambiguous_path',
matches: probe.caseMatches,
...ctx.recoveryFor('ambiguous_path'),
}, { cause: err });
}
if (probe.extInsensitive.length === 0)
throw err;
const suggestions = probe.extInsensitive.slice(0, MAX_SUGGESTIONS);
const list = suggestions.map((s) => `"${s}"`).join(', ');
const prefix = err.message.replace(/[.!?]?\s*$/, '');
throw notFound(`${prefix}. Did you mean: ${list}?`, { ...(err.data ?? {}), suggestions }, { cause: err });
}
}
/**
* List the parent directory of `path` and return up to {@link MAX_SUGGESTIONS}
* close-match candidates. Match order: case-insensitive equality first, then
* extension-stripped equality. Returns `[]` on listing failure or empty
* basename. Used by callers that need suggestions without performing the
* underlying operation (e.g., `obsidian_open_in_ui`'s explicit messaging).
*/
export async function findSimilarPaths(ctx, svc, path) {
const probe = await probeParentDir(ctx, svc, path);
return [...probe.caseMatches, ...probe.extInsensitive].slice(0, MAX_SUGGESTIONS);
}
async function probeParentDir(ctx, svc, path) {
const empty = { caseMatches: [], extInsensitive: [] };
const normalized = path.replace(/^\/+|\/+$/g, '');
if (!normalized)
return empty;
const slash = normalized.lastIndexOf('/');
const dir = slash >= 0 ? normalized.slice(0, slash) : '';
const base = slash >= 0 ? normalized.slice(slash + 1) : normalized;
if (!base)
return empty;
let entries;
try {
const listing = await svc.listFiles(ctx, dir);
entries = listing.files;
}
catch {
return empty;
}
const baseLower = base.toLowerCase();
const baseNoExt = stripExtension(baseLower);
const caseMatches = [];
const extInsensitive = [];
for (const entry of entries) {
if (entry.endsWith('/'))
continue;
const entryLower = entry.toLowerCase();
if (entryLower === baseLower) {
caseMatches.push(qualify(dir, entry));
}
else if (stripExtension(entryLower) === baseNoExt) {
extInsensitive.push(qualify(dir, entry));
}
}
return { caseMatches, extInsensitive };
}
function stripExtension(s) {
const dot = s.lastIndexOf('.');
return dot > 0 ? s.slice(0, dot) : s;
}
function qualify(dir, base) {
return dir ? `${dir}/${base}` : base;
}
//# sourceMappingURL=suggest-paths.js.map