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.

127 lines 7.32 kB
/** * @fileoverview Obsidian Local REST API service. Wraps every upstream HTTP * endpoint we use, builds the right URL/headers/body for the consolidated * `target` discriminator, and classifies errors for the framework. * @module services/obsidian/obsidian-service */ import type { Context } from '@cyanheads/mcp-ts-core'; import { type Dispatcher, type RequestInit, fetch as undiciFetch } from 'undici'; import { type ServerConfig } from '../../config/server-config.js'; import { PathPolicy } from './path-policy.js'; import type { DocumentMap, FileListing, NoteJson, NoteTarget, ObsidianCommand, ObsidianTag, OmnisearchHit, PatchHeaders, StructuredSearchHit, TextSearchHit, VaultStatus } from './types.js'; type UndiciResponse = Awaited<ReturnType<typeof undiciFetch>>; /** * The HTTP fetch contract this service depends on. Defaults to undici's * `fetch`; tests inject a stub here instead of mocking the `undici` module * (Bun's runtime treats `undici` as a builtin, so `vi.mock('undici')` has no * effect under `bunx vitest`). */ export type ObsidianFetch = (url: string, init: RequestInit & { dispatcher?: Dispatcher; signal?: AbortSignal; }) => Promise<UndiciResponse>; export declare class ObsidianService { #private; /** * @param config - Validated server config (api key, base URL, TLS, timeouts). * @param fetchImpl - Optional fetch override for tests. Defaults to undici's * `fetch`, which honors the constructed TLS dispatcher in production. */ constructor(config: ServerConfig, fetchImpl?: ObsidianFetch); /** Path-policy accessor — used by `obsidian_search_notes` to filter hits. */ get policy(): PathPolicy; /** Resolved Omnisearch URL (derived from baseUrl or OBSIDIAN_OMNISEARCH_URL override). */ get omnisearchUrl(): string; getStatus(ctx: Context): Promise<VaultStatus>; /** * Probe whether the configured `OBSIDIAN_API_KEY` is accepted. Hits the * authenticated `/vault/` listing endpoint and reports `true` only on a 2xx * response. Network/auth errors yield `false` — the resource caller wants a * boolean, not an exception. Aborts are re-thrown so cancellation/timeout * doesn't masquerade as an auth failure. */ probeAuthenticated(ctx: Context): Promise<boolean>; getNoteContent(ctx: Context, target: NoteTarget): Promise<string>; getNoteJson(ctx: Context, target: NoteTarget): Promise<NoteJson>; /** * Resolve `target` to a vault-relative path. For path targets this is a * no-op; for `active` and `periodic` targets we have to ask upstream which * concrete file is currently in play. */ resolvePath(ctx: Context, target: NoteTarget): Promise<string>; getDocumentMap(ctx: Context, target: NoteTarget): Promise<DocumentMap>; writeNote(ctx: Context, target: NoteTarget, content: string, contentType?: 'markdown' | 'json'): Promise<void>; appendToNote(ctx: Context, target: NoteTarget, content: string, contentType?: 'markdown' | 'json'): Promise<void>; patchNote(ctx: Context, target: NoteTarget, content: string, headers: PatchHeaders): Promise<void>; deleteNote(ctx: Context, target: NoteTarget): Promise<void>; /** * Byte size of a note at `target`, derived from the HEAD `Content-Length` * header. Returns `null` on 404 — distinct from a 0-byte file. * * Source-of-truth rule for note byte sizes across mutating tools: * 1. HEAD `Content-Length` (this method) — when no GET is in flight. * 2. `Buffer.byteLength(deliveredContent)` — when a GET happens anyway (free). * 3. `note.stat.size` from the JSON envelope — REJECTED: shares the upstream * `getAbstractFileByPath` cache path with the rest of the envelope, so it * can't act as an independent cross-check (cache-desync scenario in * coddingtonbear/obsidian-local-rest-api#237). Always prefer delivered * bytes or HEAD over the metadata field. * * Bypasses retries (a 404 is the answer, not a transient failure) and * gates readable on path targets before issuing the HEAD. */ tryGetSize(ctx: Context, target: NoteTarget): Promise<number | null>; /** * Like `tryGetSize`, but throws `note_missing` on 404 — for verification * reads that come *after* a write where the file is expected to exist. */ getSize(ctx: Context, target: NoteTarget): Promise<number>; listFiles(ctx: Context, dirPath?: string): Promise<FileListing>; listTags(ctx: Context): Promise<ObsidianTag[]>; listCommands(ctx: Context): Promise<ObsidianCommand[]>; searchText(ctx: Context, query: string, contextLength?: number): Promise<TextSearchHit[]>; searchJsonLogic(ctx: Context, logic: Record<string, unknown>): Promise<StructuredSearchHit[]>; /** * One-shot startup probe for the Omnisearch plugin's HTTP endpoint. Returns * `true` only when the response is `HTTP 200`, declares `application/json`, * and the body parses as a JSON array — unrouted paths on the Omnisearch * server also return `200` with an empty body, so status alone is * insufficient. The entry point passes the return value into the * `obsidian_search_notes` factory to decide whether to expose the * `omnisearch` mode. */ probeOmnisearch(signal?: AbortSignal): Promise<boolean>; /** * Query the Omnisearch HTTP endpoint. Normalizes the response on the way * out: decodes HTML entities + `<br>` → `\n` in `excerpt`, renames `path` * to `filename`, and drops `vault`. Throws `omnisearch_unreachable` * (ServiceUnavailable) on network failures or non-2xx responses — the * plugin can shut down mid-session (Obsidian quits, plugin disabled), and * the tool needs a distinct signal from the upstream's success cases. */ searchOmnisearch(ctx: Context, query: string): Promise<OmnisearchHit[]>; executeCommand(ctx: Context, commandId: string): Promise<void>; openInUi(ctx: Context, path: string, opts?: { newLeaf?: boolean; }): Promise<void>; } /** * Encode a vault-relative path for the URL. Splits on `/` and `\` (so * Windows-style separators are honored), URL-encodes each segment, and * rejoins with `/` since the Local REST API plugin expects forward slashes. * * Rejects `.` and `..` segments here rather than relying on the upstream Local * REST API plugin to normalize them — `PathPolicy` short-circuits to "allow" * when `OBSIDIAN_READ_PATHS` is unset, and `..` is unreserved per RFC 3986 so * `encodeURIComponent` leaves it intact. This is the single chokepoint before * URL construction, so guard vault escape here. Backslash is treated as a * separator so `..\..\etc` traverses identically to `../../etc` and can't * sneak past as a single opaque segment. */ export declare function encodeVaultPath(path: string): string; export declare function initObsidianService(config?: ServerConfig, fetchImpl?: ObsidianFetch): void; /** Test-only: directly install an instance (e.g., one backed by a stub fetch). */ export declare function setObsidianService(service: ObsidianService | undefined): void; export declare function getObsidianService(): ObsidianService; export {}; //# sourceMappingURL=obsidian-service.d.ts.map