Skip to content

Companion Obsidian plugin

obsidian-brain works against your vault's files on disk. Three kinds of data, however, only exist inside a running Obsidian process: Dataview DQL query results, Obsidian Bases view rows, and active-editor state (what note is open, cursor position). These require a small companion plugin that runs inside Obsidian.

Repo: sweir1/obsidian-brain-plugin.

What it does

On plugin load:

  1. Binds an HTTP server to 127.0.0.1 on a configurable port (default 27125).
  2. Generates a random bearer token (regenerated every startup, never persisted).
  3. Writes {VAULT}/.obsidian/plugins/obsidian-brain-companion/discovery.json with {port, token, pid, pluginVersion, startedAt, capabilities}.

obsidian-brain server reads the discovery file based on VAULT_PATH, authenticates every request with the token, and re-reads discovery on any 401 or ECONNREFUSED (so a plugin restart that rotated the token doesn't wedge the MCP tools).

Capability gating (plugin v0.2.0+): the plugin writes a capabilities: string[] array naming the features it exposes (e.g. ["status", "active", "dataview"]). The server uses this to fail fast on version mismatch — calling dataview_query against a v0.1.x plugin returns a clean "upgrade to v0.2.0" error before the HTTP call, instead of an opaque 404 from the route lookup. Plugins without the field are treated as ["status", "active"] for backward compatibility.

Install

Via BRAT (while in pre-release)

  1. Install BRAT.
  2. In Obsidian: BRAT: Add a beta plugin for testing → enter sweir1/obsidian-brain-plugin.
  3. Enable obsidian-brain companion under Settings → Community plugins.

Manual

Download main.js + manifest.json from the latest release, drop them into {VAULT}/.obsidian/plugins/obsidian-brain-companion/, reload Obsidian, enable under Community plugins.

Version compatibility

Rule of thumb: install the same major.minor on both sides (server 1.5.x pairs with plugin 1.5.x) — that's the tested, supported combo. Patch-version drift (e.g. server 1.5.4 + plugin 1.5.2) is fine. Other combinations often work thanks to capability gating — the plugin advertises its supported routes in discovery.json and the server fails loudly with a clean "upgrade plugin vX.Y+" error if a required capability is missing — but aren't explicitly tested and aren't guaranteed. If you mix and something breaks, upgrade the lagging side.

Which tools require it

Tool Needs plugin? Notes
active_note (v1.2.0+) yes Returns the path + cursor + selection of the note currently open in Obsidian.
dataview_query (v1.3.0+) yes (plugin ≥ 0.2.0) Runs a DQL query via the Dataview plugin. See Dataview below.
base_query (v1.4.0+) yes (plugin ≥ 1.4.0, Obsidian ≥ 1.10.0, Bases core plugin enabled) Evaluates an Obsidian Bases .base file. See Bases below.

Every other tool (search, read_note, list_notes, find_connections, find_path_between, detect_themes, rank_notes, create_note, edit_note, link_notes, move_note, delete_note, reindex) works standalone with or without the plugin.

When the plugin is absent or unreachable, the plugin-dependent tools return an error containing the install instructions verbatim — the rest of the server keeps working normally.

Security

  • Localhost-only (127.0.0.1). Never binds to a LAN interface.
  • Bearer token required on every request. Random 32-byte hex, regenerated on every plugin load — no persistent secret.
  • Discovery file lives inside the vault directory so its permissions inherit the vault's.
  • No CORS, no cookies, no write endpoints.

Dataview

[!IMPORTANT] Dataview is a separate third-party community plugin, not built by us and not shipped with Obsidian. You install it from Obsidian → Settings → Community plugins → Browse → search "Dataview" (by blacksmithgu) → Install → Enable, then reload Obsidian once so its API registers on app.plugins.plugins.dataview.api. dataview_query returns 424 with a clear not_installed / not_enabled / api_not_ready message until that's done. Full DQL syntax reference: blacksmithgu.github.io/obsidian-dataview. Step-by-step walkthrough in Installing Dataview below.

What "the Dataview community plugin" actually is

Three pieces of software are involved in a single dataview_query call, with overlapping names. Two are ours; one isn't:

# Name Who wrote it Where it runs
1 obsidian-brain us The MCP server (Node package). Spawned by your MCP client.
2 obsidian-brain-companion us Obsidian plugin. Exposes the /dataview HTTP route.
3 Dataview (obsidian-dataview) blacksmithgu Third-party Obsidian community plugin with ~4M+ installs (obsidian-releases community-plugin-stats.json: 4,008,313 as of April 2025). Implements DQL + an in-memory vault index.

Version landscape (as of 2026-04-22): end users installing via Obsidian's Community Plugin browser get 0.5.70 (GitHub release, 2025-04-07 — this is what runs in their vault). Developers who npm install -D obsidian-dataview for types get 0.5.68 (2025-03-15). User-facing runtime is unaffected either way because getAPI(app) returns whatever Dataview the user actually has installed. (Upstream's own "Develop Against Dataview" docs page still cites 0.5.64 — both channels are ahead of the docs.)

We do not reimplement DQL. The chain is:

MCP client → obsidian-brain (stdio JSON-RPC)
            → companion plugin (HTTP POST /dataview on 127.0.0.1)
              → Dataview API (in-process JS call: api.query(source, originFile?))
                → returns Result<QueryResult, string>
              ← normalizer flattens Link/DateTime/DataArray/Duration
            ← { kind, ... }
          ← normalized result

The companion plugin resolves Dataview via app.plugins.plugins.dataview?.api. That's literally what Dataview's own getAPI(app) sanctioned wrapper does internally (src/index.ts L49-52):

export const getAPI = (app?: App): DataviewApi | undefined => {
  if (app) return app.plugins.plugins.dataview?.api;
  else return window["DataviewAPI"];
};

Both paths return the same DataviewApi object. We use the back-door path because it avoids making obsidian-dataview a runtime dependency. Authoritative upstream: Dataview's plugin-author guide and plugin-api.ts source.

Installing Dataview

Dataview is installed in Obsidian, not via npm:

  1. Obsidian → Settings → Community plugins → Browse.
  2. Search "Dataview" (by blacksmithgu).
  3. Install → Enable.
  4. Reload Obsidian once. The plugin API registers on app.plugins.plugins.dataview at enable-time, but a fresh install sometimes needs a reload before our companion plugin can see it.

dataview_query requires both plugins enabled in the same vault. If Dataview is missing, our companion returns HTTP 424 and the MCP tool surfaces an install-prompt message verbatim.

The index-ready caveat

Dataview builds its index asynchronously after Obsidian startup and fires app.metadataCache.on("dataview:index-ready", ...) when the first pass completes. Before that event fires, api.query() may return partial results against an incomplete index. We don't currently block on that event — in practice reindexing is fast enough that interactive use rarely notices. If dataview_query returns surprisingly few rows in the first few seconds of Obsidian startup, retry after the index warms. Subsequent vault changes are picked up via Dataview's own dataview:metadata-change events without needing to wait again.

Requirements recap

  • Companion plugin v0.2.0+ (advertises the dataview capability).
  • The Dataview community plugin installed in the same vault and enabled.
  • Obsidian running — the query is evaluated in-process.

Request shape

MCP input:

{
  "query": "TABLE file.name, rating FROM #book WHERE status = \"reading\" LIMIT 50",
  "source": "optional/origin-file.md",
  "timeoutMs": 30000
}

Response shape (normalized)

The plugin flattens Dataview's runtime types — Link, DateTime, DataArray, Duration — to plain JSON before they go over the wire, so MCP clients don't need the Dataview typings to understand the output. The wire format is a discriminated union keyed by kind:

kind payload notes
table { headers: string[], rows: Value[][] } Link → path string; DateTime → ISO; DataArray → plain array
list { values: Value[] } Same flattening per value
task { items: NormalizedListItem[] } Includes both SListEntry (task: false) and STask (task: true); grouping trees are flattened into a flat list keyed by path + line
calendar { events: [{date, link, value?}] } date is ISO; link is the vault-relative path

NormalizedListItem fields: task, text, path, line, tags, children, plus (when task: true): status, checked, completed, fullyCompleted, due, completion, scheduled, start, created.

Timeout caveat

Dataview's api.query() does not support cancellation. timeoutMs bounds how long this tool waits for the HTTP response; if it fires, the query is still running inside Obsidian to completion, burning CPU. Two mitigations:

  1. Prefer LIMIT N in DQL for any open-ended query over a large vault.
  2. The plugin serialises /dataview requests — a second expensive query can't stack behind a stuck first one. You'll get queued until the first finishes.

Errors

  • 424 dataview_not_installed → Dataview community plugin isn't in the vault. Install it from Settings → Community plugins.
  • 400 dql_error → Dataview rejected the query (syntax, unknown field, etc.). The message is surfaced verbatim.
  • Capability error before HTTP call → plugin is v0.1.x and doesn't know the /dataview route. Upgrade to v0.2.0.

DQL reference

See the upstream DQL query structure and query types docs.

Bases

What Bases is

Bases is a core Obsidian feature (not a community plugin) that shipped in Obsidian 1.10.0. A .base file is a YAML document declaring a set of views over the vault's markdown files — each view has a filters: tree (AND/OR/NOT with comparisons), a sort: list, a limit:, and a columns: projection. Inside Obsidian, Bases renders each view as a table / gallery / card grid you can navigate in the UI.

base_query evaluates that same pipeline headlessly and returns the rows as JSON, so an MCP client can ask "give me all books I'm currently reading with rating ≥ 4" without a human opening Obsidian.

Why this is "Path B"

As of Obsidian 1.12.x, Bases ships public TypeScript types for BasesEntry / BasesQueryResult / BasesView and a Plugin.registerBasesView() hook, but no public API for running a query headlessly. QueryController's body is empty in obsidian.d.ts; there is no app.bases.runQuery(config) or app.bases.getViewFiles(path). A forum request opened 2026-01-31 is still unacknowledged as of 2026-04-22.

So the companion plugin takes Path B: it parses the .base YAML itself (via Obsidian's bundled parseYaml), walks app.vault.getMarkdownFiles() + app.metadataCache.getFileCache() to build entries, and runs a whitelisted expression evaluator against the filter tree. When upstream exposes a read-access API, we plan a drop-in swap — the public type surface (BasesEntry, BasesQueryResult) has been stable since 1.10.0 and our response shape mirrors it closely.

Requirements recap

  • Companion plugin v1.4.0+ (advertises the base capability).
  • Obsidian ≥ 1.10.0 — the handler verifies via typeof Plugin.prototype.registerBasesView === "function".
  • Bases core plugin enabled (Obsidian → Settings → Core plugins → toggle "Bases" on).
  • Obsidian running — the evaluator reads the live metadata cache.

Request shape

MCP input:

{
  "file": "Bases/Books.base",
  "view": "active-books",
  "timeoutMs": 30000
}

Or with inline YAML instead of a file path:

{
  "yaml": "filters:\n  ...\nviews:\n  active-books:\n    ...",
  "view": "active-books"
}

Either file or yaml is required. When both are supplied, file wins.

Response shape

{
  "view": "active-books",
  "rows": [
    {"file": {"name": "Dune", "path": "books/Dune.md"}, "status": "reading", "rating": 5}
  ],
  "total": 42,
  "executedAt": "2026-04-23T10:30:00.000Z"
}
  • view echoes the requested view name.
  • rows — one object per matching entry. The file sub-object always has at least {name, path}; the rest of the columns come from the view's columns: list, flattened to JSON primitives (Dates → ISO strings, arrays kept as arrays, nested frontmatter objects preserved).
  • total is the pre-limit count (how many entries matched the filter). If the view sets limit: 10 and 42 match, total is 42 and rows.length is 10.
  • executedAt is an ISO timestamp captured server-side when the handler completed.

Supported v1.4.0 expression subset

The evaluator accepts a whitelist — anything outside the whitelist throws unsupported_construct with the offending fragment named.

Construct Example
Tree ops and: [...], or: [...], not: {...}
Comparison operators (in leaf objects) {status: {"==": "reading"}}, {rating: {">=": 4}}
Comparison shorthand (== implied) {status: reading}
Leaf boolean in string expressions file.hasTag("book") && status == "reading"
Negation in string expressions !file.inFolder("archive")
File properties file.name, file.path, file.folder, file.ext, file.size, file.mtime, file.ctime, file.tags
File methods file.hasTag("book"), file.inFolder("books")
Frontmatter access frontmatter.status, frontmatter.project.name, or bare status (defaults to frontmatter.status)
View sort: [{column: rating, direction: desc}, title]
View limit: limit: 20
View columns: [status, rating, {column: "frontmatter.author", as: author}]

Rejected constructs (deferred to v1.4.x patches)

The evaluator surfaces 400 unsupported_construct for anything below, so LLM clients see exactly what needs to ship and when:

Construct Example Ships in
Arithmetic (+ - * / %) rating + 1 > 3 v1.4.1
formulas: block at top level formulas: {doubled: "rating * 2"} v1.4.2
summaries: block at top level summaries: {totalRating: "sum(rating)"} v1.4.3
Method calls other than file.hasTag / file.inFolder .toFixed(1), .format("YYYY"), .contains(...), .asLink() later v1.4.x
Function calls today(), now(), date(x), list(...), link(...), icon(...) later v1.4.x
Regex literals /pattern/i.matches(x) later v1.4.x
this context references this.file no plan yet — raise a ticket if needed

Timeout caveat

timeoutMs (default 30000) bounds the HTTP wait. The plugin evaluator itself has no cancellation API — it walks every markdown file in the vault to build entries, then runs the filter tree. If the timeout fires, the evaluator keeps running inside Obsidian until it finishes. Mitigations:

  1. Prefer limit: N inside the view for open-ended queries over large vaults.
  2. The plugin serialises /base requests — a second expensive evaluation can't stack behind a stuck first one. Second call queues until the first finishes.

Errors

  • 424 bases_not_enabled → Obsidian's Bases core plugin is off. Toggle it on: Settings → Core plugins → Bases.
  • 424 unsupported_obsidian_version → Obsidian < 1.10.0. Upgrade Obsidian.
  • 400 bad_request → Missing view, or neither file nor yaml supplied.
  • 400 base_file_read_failedfile path didn't resolve against app.vault.adapter.read.
  • 400 unsupported_construct → Filter/sort/column referenced an expression the v1.4.0 subset doesn't cover. Message names the fragment verbatim and the v1.4.x patch that will ship it.
  • 400 base_eval_error → YAML parse/structural problem (missing views: map, unknown view name, malformed sort:).
  • Capability error before HTTP call → plugin doesn't advertise base in its discovery file. Upgrade to plugin v1.4.0+.

Troubleshooting

active_note returns "plugin unavailable"

Check in order:

  1. Is Obsidian running? The plugin only answers while Obsidian is open.
  2. Is the plugin enabled? Obsidian → Settings → Community plugins → verify obsidian-brain companion shows a green toggle.
  3. Is it installed against the same vault your MCP client has VAULT_PATH pointing at? The discovery file is vault-scoped.
  4. Look for {VAULT}/.obsidian/plugins/obsidian-brain-companion/discovery.json. If missing, reload the plugin.
  5. curl -H "Authorization: Bearer $(jq -r .token …/discovery.json)" http://127.0.0.1:$(jq -r .port …/discovery.json)/status — should return {ok: true, ...}. If it doesn't, the port may be blocked by something else; change it under Settings → obsidian-brain companion.

Port conflict

Default 27125 avoids the 27123/27124 owned by the Local REST API plugin. If something else is grabbing 27125 on your machine, change the port under Settings → obsidian-brain companion. After changing, disable and re-enable the plugin so the HTTP server rebinds.

Rotated token after plugin restart

Handled automatically. The server re-reads the discovery file on 401 and retries once.

dataview_query returns "Dataview community plugin is not installed"

424 response from the companion plugin. You need both plugins enabled in the vault:

  1. obsidian-brain companion (this one) — exposes the /dataview route.
  2. Dataview (blacksmithgu) — evaluates the actual DQL. Install from Settings → Community plugins → Browse → search "Dataview".

Dataview's own plugin must be enabled (not just installed). If both are enabled and you still see the error, reload Obsidian — the companion checks for Dataview via app.plugins.plugins.dataview.api at request time, so a freshly-enabled Dataview may need an Obsidian reload before its API is registered on the global.

dataview_query requires the companion plugin v0.2.0 or later

Error returned before the HTTP call because the server sees the plugin doesn't advertise the dataview capability in its discovery file. Upgrade the plugin:

  • BRAT: Check for updates → install v0.2.0.
  • Manual: download main.js + manifest.json from the plugin's latest release and overwrite the v0.1.x files in {VAULT}/.obsidian/plugins/obsidian-brain-companion/. Disable + re-enable the plugin in Settings → Community plugins.

dataview_query timed out

The HTTP wait exceeded timeoutMs (default 30000). The Dataview query itself keeps running inside Obsidian until it finishes — Dataview has no cancellation API. Either add LIMIT N to your DQL, or raise timeoutMs if the query is genuinely expensive. Concurrent requests queue server-side (one in-flight at a time), so a second call won't make things worse.