Skip to content
Agentmart
Submit a tool

CLAUDE.md · cyanheads/obsidian-mcp-server

CLAUDE.md from cyanheads/obsidian-mcp-server: 485 lines covering What's Next?, Core Rules, Patterns, Context and more.

Template by cyanheads

IndexedApache-2.0★ 701
Write a review

Install

Works with Claude Code. Read the repository before you give any tool access to your data.

curl -fsSL https://raw.githubusercontent.com/cyanheads/obsidian-mcp-server/main/CLAUDE.md -o CLAUDE.md

Read it and adapt it to your project before use.

Get the weekly diff

The weekly diff: one email a week with new agent tools, notable updates and grade changes, plus security alerts for tools you follow. Double opt-in, one-click unsubscribe.

We use your email only for this newsletter. Privacy

About

The CLAUDE.md file that cyanheads/obsidian-mcp-server (701 stars) keeps for coding agents. It has 485 lines in 14 sections: What's Next?; Core Rules; Patterns; Context; Errors; Structure; Naming; Skills; Commands; Bundling; Changelog; Publishing; Imports; Checklist. Use it as a model for your own project's instructions.

Use cases: note-taking, code-generation

No reviews yet. Installed it? Reviews come from verified installers only. How reviews work

What's in it

A CLAUDE.md file: project memory Claude Code loads at the start of every session.

For
Claude Code
Length
485 lines
File
CLAUDE.md

Sections

  1. What's Next?
  2. Core Rules
  3. Patterns
  4. Context
  5. Errors
  6. Structure
  7. Naming
  8. Skills
  9. Commands
  10. Bundling
  11. Changelog
  12. Publishing
  13. Imports
  14. Checklist

First lines (shown under the repo's Apache-2.0 license)

# Agent Protocol

**Server:** obsidian-mcp-server
**Version:** 3.7.2
**Framework:** [@cyanheads/mcp-ts-core](https://www.npmjs.com/package/@cyanheads/mcp-ts-core) `^0.13.18`
**Engines:** Bun ≥1.4.0, Node ≥24.0.0
**MCP SDK:** `@modelcontextprotocol/server` ^2.4.0
**Zod:** ^4.6.7

> **Read the framework docs first:** `node_modules/@cyanheads/mcp-ts-core/CLAUDE.md` contains the full API reference — builders, Context, error codes, exports, patterns. This file covers server-specific conventions only.

---

## What's Next?

When the user asks what to do next, what's left, or needs direction, suggest relevant options based on the current project state:

1. **Re-run the `setup` skill** — ensures CLAUDE.md, skills, structure, and metadata are populated and up to date with the current codebase
2. **Run the `design-mcp-server` skill** — if the tool/resource surface hasn't been mapped yet, work through domain design
3. **Add tools/resources/prompts** — scaffold new definitions using the `add-tool`, `add-app-tool`, `add-resource`, `add-prompt` skills
4. **Add services** — scaffold domain service integrations using the `add-service` skill
5. **Add tests** — scaffold tests for existing definitions using the `add-test` skill
6. **Field-test definitions** — exercise tools/resources/prompts with real inputs using the `field-test` skill, get a report of issues and pain points
7. **Run `devcheck`** — lint, format, typecheck, and security audit
8. **Run the `security-pass` skill** — audit handlers for MCP-specific security gaps: output injection, scope blast radius, input sinks, tenant isolation
9. **Run the `polish-docs-meta` skill** — finalize README, CHANGELOG, metadata, and agent protocol for shipping
10. **Run the `maintenance` skill** — investigate changelogs, adopt upstream changes, and sync skills after `bun update --latest`

Tailor suggestions to what's actually missing or stale — don't recite the full list every time.

---

## Core Rules

- **Logic throws, framework catches.** Tool/resource handlers are pure — throw on failure, no `try/catch`. Plain `Error` is fine; the framework catches, classifies, and formats. Use error factories (`notFound()`, `validationError()`, etc.) when the error code matters.
- **Use `ctx.log`** for request-scoped logging. No `console` calls.
- **The delete confirmation is opt-in; when on, it is gated on a consent record.** `OBSIDIAN_DELETE_ELICITATION` (default `false`) decides whether `obsidian_delete_note` confirms through `ctx.requestInput`. Off is the default for two reasons: a client that declares elicitation but cannot render the form declines every round automatically, which the server cannot tell from a real decline (#151), and the round forces a stateful HTTP session, which locks out `MCP_SESSION_MODE=stateless`. Off, `OBSIDIAN_WRITE_PATHS` / `OBSIDIAN_READ_ONLY` bound what a delete can reach — the same bound `obsidian_write_note` with `overwrite: true` has — and `destructiveHint` stays for client-side approval. The mode is the operator's choice alone: never derive it from `ctx.clientCapabilities` or from a decline. On, the handler redeems (reads and deletes) the `ctx.state` record named by `ctx.inputs.state()` before anything else, checks write scope on the resolved path before it reads the note or asks, and acts on the `ctx.inputs` answer only when that record deep-equals `{ operation, clientId, subject, target, contentHash }` for this call; otherwise it stores a fresh record (`ttl: 600`) and `return ctx.requestInput(...)` with the record id as `requestState`. An answer alone is never proof the user was asked. Never `await` user input mid-handler.
- **All Obsidian access goes through `getObsidianService()`.** No direct `fetch()` calls to the Local REST API in tools/resources — the service centralizes auth, TLS, timeouts, and `ctx.signal` propagation.
- **Secrets in env vars only.** `OBSIDIAN_API_KEY` is required; never hardcoded.
- **Cut noise.** Add only what earns its place: no speculative generality, no guards for states the framework already prevents (Zod-validated params, classified errors), no abstraction until a third caller proves it, no option nothing sets.
- **Command-palette tools are opt-in.** `obsidian_list_commands` and `obsidian_execute_command` are callable only when `OBSIDIAN_ENABLE_COMMANDS=true` — Obsidian commands are opaque and can be destructive. When the flag is unset, the entry point wraps both with `disabledTool()` so they're absent from `tools/list` (LLM can't invoke) but visible in the operator-facing manifest with a hint to enable them.
- **Path-policy gating goes through `PathPolicy`.** Every path-taking method on `ObsidianService` calls `policy.assertReadable` / `assertWritable` before the upstream HTTP call; `listFiles` gates with `assertListable` and filters entries with `filterListing` (readable entries plus folders on the way to the scope), so every listing consumer inherits the scope; `obsidian_search_notes` filters hits via `svc.policy.filterReadable`. Under `policy.restrictsReads`, `searchJsonLogic` never sends the caller's tree upstream: one `/search/` guarded by `readScopePattern` fetches the readable notes' contexts (plus one body fetch per batch of readable notes when the tree names `content`), `filterReadable` gates them, `keepReadable` reduces `links` / `backlinks` / `unresolvedLinks`, and `createJsonLogicEvaluation` runs the tree on a worker thread with the plugin's operators, bounded by depth, a per-note build budget, a per-search kept-result budget, and a deadline (`logic_too_costly`; a note too deep to hand to the worker is `note_too_deep`), so no hit, result, or error reads a note outside the scope. Link targets are still Obsidian's vault-wide resolution, so the reduced link fields can shift when an out-of-scope note appears or disappears (#165); docs state that bound, never a stronger one. `listTags` reads per-note tags from the same readable contexts, with no evaluation. An `active` / `periodic` target is gated on the path it resolves to, under the target's label: `resolveTarget` / `pinTarget` return a `PathTarget` whose `resolvedFrom` the asserts take, so a denial names `(active file)` or `daily note for 2026-10-01`, never a path the caller did not send, and a tool that reads a target then writes it writes to that pinned path rather than resolving the target again. Don't bypass this — `OBSIDIAN_READ_PATHS` / `OBSIDIAN_WRITE_PATHS` / `OBSIDIAN_READ_ONLY` are the single chokepoint, and `path_forbidden` is declared on every path-taking tool's `errors[]` contract.
- **Close the loop on issues.** When implementing work tracked by a GitHub issue, comment on the issue with what landed and close it. Do both — a comment without a close leaves stale issues open; a close without a comment leaves no record of what shipped. The comment is for future readers — state the concrete changes, not the conversation that produced them.

---

## Patterns

### Tool — `obsidian_list_tags`

A small read-only tool that wraps a single service call, normalizes the response into the output schema, and renders a markdown twin in `format()`. Reduced for illustration — the live definition also carries `nameRegex` / `minCount` / `limit` inputs, the count-descending sort and cap they feed, an `errors[]` contract, and an `enrichment` block.

```ts
import { tool, z } from '@cyanheads/mcp-ts-core';
import { getObsidianService } from '@/services/obsidian/obsidian-service.js';

export const obsidianListTags = tool('obsidian_list_tags', {
  description:
    'List every tag found across the vault, with usage counts. Includes hierarchical parents — `work/tasks` contributes to both `work` and `work/tasks`.',
  annotations: { readOnlyHint: true, idempotentHint: true },
  input: z.object({}),
  output: z.object({
    tags: z
      .array(
        z.object({
          name: z.string().describe('Tag name without the leading `#`.'),
          count: z
            .number()
            .describe(
              'Times the tag, or a tag nested under it, occurs across the vault. When OBSIDIAN_READ_PATHS is set: the number of readable notes carrying the tag or a tag nested under it.',
            ),
        }).describe('A tag with its usage count.'),
      )
      .describe('Matching tags ordered by `count` descending.'),
  }),
  auth: ['tool:obsidian_list_tags:read'],

  async handler(_input, ctx) {
    const svc = getObsidianService();
    const tags = await svc.listTags(ctx);
    return { tags: tags.map((t) => ({ name: t.name, count: t.count })) };
  },

  // format() populates content[] — the markdown twin of structuredContent.
  // Different clients read different surfaces (Claude Code → structuredContent,
  // Claude Desktop → content[]); both must carry the same data.
  // Enforced at lint time: every field in `output` must appear in the rendered text.
  format: (result) => {
    if (result.tags.length === 0) {
      return [{ type: 'text', text: '_No tags found in the vault._' }];
    }
    const lines = [`**${result.tags.length} tags**`, ''];
    for (const t of result.tags) lines.push(`- \`#${t.name}\` (${t.count})`);
    return [{ type: 'text', text: lines.join('\n') }];
  },
});
```

For a destructive tool with optional human-in-the-loop confirmation, see `obsidian-delete-note.tool.ts`. `src/index.ts` builds it with `buildDeleteNoteTool({ elicitation })` from `OBSIDIAN_DELETE_ELICITATION`, so the description states the active mode; the exported `obsidianDeleteNote` is the off build, kept as the specimen the definition linter and tests import. Both modes check write scope on the resolved path, then read the note before the DELETE. That read is the folder guard (`path_is_directory`), since the Local REST API's `DELETE` on a folder path removes the folder and everything in it. It is also the exact-case guard, so it goes through `note+json` (`getNoteJson`), never the raw-markdown read. On a case-insensitive filesystem the raw read and the plugin v4.x `DELETE` (`adapter.exists` + `adapter.remove`) case-fold, so a wrong-case path would read and then permanently remove the differently-cased note. `note+json` resolves through Obsidian's case-sensitive vault index on v4.x and v5.x, so a wrong-case path fails `note_missing` before any DELETE, with near matches listed in `suggestions` and never substituted. With the confirmation on, it redeems a single-use consent record from `ctx.state` (bound to the operation, the caller, the resolved path, and a SHA-256 of the note's content), suspends with `ctx.requestInput` for an embedded `elicitation/create` round when no matching record backs the answer, and branches on `ctx.inputs.view()` so a declined prompt on a matching round fails instead of re-asking. Only the confirming build declares `cancelled` in `errors[]`, since the off build never throws it. Both builds carry the `destructiveHint` annotation. Pattern: framework `api-context` § Consent gates.

### Resource — `obsidian://status`

```ts
import { resource, z } from '@cyanheads/mcp-ts-core';
import { getObsidianService } from '@/services/obsidian/obsidian-service.js';

export const obsidianStatus = resource('obsidian://status', {
  name: 'obsidian-status',
  description:
    'Server reachability, plugin version, and auth status of the Obsidian Local REST API.',
  mimeType: 'application/json',
  params: z.object({}),
  output: z.object({
    status: z.string().describe('Upstream reported status string.'),
    service: z.string().describe('Service identifier returned by the plugin.'),
    authenticated: z.boolean().describe('Whether the configured OBSIDIAN_API_KEY is recognized.'),
  }),
  auth: ['resource:obsidian-status:read'],
  async handler(_params, ctx) {
    const svc = getObsidianService();
    return await svc.getStatus(ctx);
  },
});
```

For a parameterized resource, see `obsidian-vault-note.resource.ts` (`obsidian://vault/{+path}`) — the `{+path}` segment captures everything after `/vault/` including slashes.

### Prompt

This server exposes a CRUD/search surface; no recurring multi-turn pattern benefits from a structured prompt template, so `allPromptDefinitions` is intentionally empty. Add one with `prompt('name', { ... })` if a workflow emerges.

### Server config — `OBSIDIAN_*` env vars

```ts
// src/config/server-config.ts — lazy-parsed, separate from framework config
import { z } from '@cyanheads/mcp-ts-core';
import { parseEnvConfig } from '@cyanheads/mcp-ts-core/config';

/** true/false/1/0/yes/no/on/off, case-insensitive; anything else fails startup. */
const envBoolean = z.union([z.boolean(), z.stringbool()]);

const ServerConfigSchema = z.object({
  apiKey: z.string().min(1).describe('Bearer token for the Obsidian Local REST API plugin.'),
  baseUrl: z.string().url().default('http://127.0.0.1:27123'),
  verifySsl: envBoolean.default(false),
  requestTimeoutMs: z.coerce.number().int().positive().default(30_000),
  enableCommands: envBoolean.default(false),
  /** Path-policy allowlists — comma-separated, prefix-based, case-insensitive. Unset = full vault. */
  readPaths: envPathList,
  writePaths: envPathList,
  readOnly: envBoolean.default(false),
  /** Opt-in delete confirmation round; when true and writes are allowed, HTTP requires a stateful session. */
  deleteElicitation: envBoolean.default(false),
  omnisearchUrl: z.string().url().optional(),
});

let _config: z.infer<typeof ServerConfigSchema> | undefined;
export function getServerConfig() {
  _config ??= parseEnvConfig(ServerConfigSchema, {
    apiKey: 'OBSIDIAN_API_KEY',

Signals

Stars
701
Forks
107
Open issues
10
Contributors
3
Last commit (this folder)
Oct 24, 2026 (4 days ago)
Last release
Oct 24, 2026 (4 days ago)

From GitHub (fetched Oct 24, 2026).

Details

License
Apache-2.0
First seen
Oct 24, 2026
Last checked
Oct 24, 2026

More from cyanheads

Cite this page

CLAUDE.md · cyanheads/obsidian-mcp-server. Agent Mart. https://agentmart.dev/templates/cyanheads-obsidian-mcp-server-claude-md (last updated 2026-10-24)