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
TypeScript
/**
* @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