AGENTS.md · cyanheads/obsidian-mcp-server
AGENTS.md from cyanheads/obsidian-mcp-server: 485 lines covering What's Next?, Core Rules, Patterns, Context and more.
Template by cyanheads
Install
Works with Codex, Cursor, GitHub Copilot, Gemini CLI, OpenCode, 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/AGENTS.md -o AGENTS.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.
About
Use cases: note-taking, code-generation
Reviews
Write a reviewNo reviews yet. Installed it? Reviews come from verified installers only. How reviews work
What's in it
An AGENTS.md file: instructions coding agents (Codex, Cursor, Copilot, Gemini CLI and others) read before working in a repo.
- For
- Codex, Cursor, GitHub Copilot, Gemini CLI, OpenCode, Claude Code
- Length
- 485 lines
- File
AGENTS.md
Sections
- What's Next?
- Core Rules
- Patterns
- Context
- Errors
- Structure
- Naming
- Skills
- Commands
- Bundling
- Changelog
- Publishing
- Imports
- 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
- Repository
- github.com/cyanheads/obsidian-mcp-server
- First seen
- Oct 24, 2026
- Last checked
- Oct 24, 2026
- Source
- GitHubgithub-graphql
More from cyanheads
- 0.655score
MCP Ts Template
cyanheads
TypeScript template for building MCP servers with declarative tooling, observability, and auth.
ConnectorAFreshness: ARemoteLocalRead-only★ 152Claude Code +5 - 0.625score
Git MCP Server
cyanheads
Comprehensive Git MCP server enabling native git tools including clone, commit, worktree, & more.
ConnectorBFreshness: BRemoteLocal★ 242↓ 1.6K/wkClaude Code +5 - 0.618score
Obsidian MCP Server
cyanheads
Read, write, search, and surgically edit Obsidian notes, tags, and frontmatter.
ConnectorAFreshness: ARemoteLocal★ 701Claude Code +5 - 0.597score
PubMed MCP Server
cyanheads
Search PubMed/Europe PMC, fetch articles and full text (PMC/EPMC/Unpaywall), citations, MeSH terms.
ConnectorAFreshness: ARemoteLocalRead-only★ 162↓ 1.6K/wkClaude Code +5 - 0.545score
Ntfy MCP Server
cyanheads
Send, manage, and replay ntfy push notifications.
ConnectorAFreshness: ARemoteLocal★ 23Claude Code +5 - 0.542score
Brapi MCP Server
cyanheads
Collaborative BrAPI v2.1 MCP workspace: studies, germplasm, genotypes across Breedbase, T3, more.
ConnectorAFreshness: ARemoteLocal★ 4↓ 660/wkClaude Code +5
Cite this page
AGENTS.md · cyanheads/obsidian-mcp-server. Agent Mart. https://agentmart.dev/templates/cyanheads-obsidian-mcp-server-agents-md (last updated 2026-10-24)