This release is mostly about getting files that aren't markdown in and out of your vault. An agent can now actually look at an image in your notes instead of being handed a wall of base64, and anything else moves over a signed, expiring link rather than through the conversation. Alongside that: a path traversal in the MCP tools is fixed (GHSA-j9qv-qgpv-345g), and the HTTPS certificate the plugin generates now works in Firefox, which it never has.
At a glance
Features
- Images an agent can see —
vault_read_binaryreturns a real image, not base64 text - Signed URLs — move a file's bytes without putting them in the conversation
Content-Locationon targeted routes — find out which file your URL actually resolved to- Readable response headers in browsers — sessionful MCP works from JavaScript at all
- Cached backlinks index — one graph walk per change instead of one per read
- MCP revision 2026-07-28 — served on SDK v2, with older clients still working
Bug fixes
- MCP tools could write outside your vault —
vault_writeaccepted../../escape.md(GHSA-j9qv-qgpv-345g) vault_readsilently destroyed attachments — reading a PNG as text, then writing it back, ate the file- Firefox rejected the generated certificate — the plugin served a CA certificate where a leaf belongs
- Three smaller fixes
Features
Images an agent can see: an attachment could only reach a model as base64 text
The problem: MCP had no way to hand a model a picture. Reading an attachment meant getting its bytes back as base64 inside a text block — roughly 0.4 tokens per byte, so a multi-megabyte photo was hundreds of thousands of tokens, and after all that the model still couldn't see the image. It had a base64 string.
What's new: vault_read_binary returns a raster image as an MCP image block, downscaled on Obsidian's own canvas to fit 1568px on its long edge, with a short text block giving the path, MIME type, size, and dimensions. That same multi-megabyte photo is now a couple of thousand tokens and the model can look at it. SVGs skip the canvas entirely and come back as their source text, byte-for-byte what's in the vault, so the model reads the markup directly. This closes #205.
Anything that isn't an image comes back as a link to a signed download URL, so its bytes never enter the conversation at all. With signed URLs turned off, a file under 512 KiB is embedded directly and larger ones are refused with a pointer at the REST endpoint. as: "bytes" and as: "link" override the default either way.
Signed URLs: getting a file in or out of the vault meant routing every byte through the API client
The problem: Every read and write went through the bearer token, which meant the bytes had to travel through whatever was holding that token. For an agent, that's the conversation. There was no way to say "here is a link to this file" and let something else do the transfer.
What's new: GET and PUT /vault/<path> now accept a time-limited, HMAC-signed URL in place of the Authorization header, and two MCP tools mint them: vault_get_download_url and vault_get_upload_url. Each comes with a ready-to-run curl command. An upload URL is single-use.
This is on by default. A signed link is a capability — anyone holding it can use it until it expires — so there's an Enable signed URLs setting to turn the whole thing off, which also unregisters the two tools, and a configurable TTL.
Content-Location on targeted routes: a URL with a target embedded in it was ambiguous, and nothing told you how it resolved
The problem: /vault/notes/log.md/heading/Today could mean the Today section of notes/log.md, or a file literally named notes/log.md/heading/Today. The server resolves it by walking backwards down the path until it finds a real file, but nothing in the response said which way that went. You could write to a section and not know which file you'd written to.
What's new: GET, PUT, POST, and PATCH on /vault/{filename} set Content-Location to the vault-relative path the URL resolved to whenever the URL embedded a target. It's percent-encoded per path segment, so a filename containing #, ?, , or a space survives the round trip and can be pasted straight back into a request URL.
Whole-file requests don't get the header, and neither does header-based targeting (Target-Type/Target) — in both cases you named the file yourself, so there's nothing to report.
Readable response headers in browsers: JavaScript clients couldn't read anything this API tells them
The problem: Both CORS configurations set methods and nothing else, and the cors package omits Access-Control-Expose-Headers entirely when exposedHeaders is unset. A browser client could read only the seven CORS-safelisted response headers. Everything this API sets to tell you something was on the wire, readable by curl, and invisible to JavaScript.
The sharp edge was Mcp-Session-Id: the MCP SDK's own client reads it off the response to complete a handshake, and got null. Sessionful MCP could not work from a browser at all. Content-Location, Markdown-Patch-Warnings, ETag, Content-Disposition, Deprecation, and X-Response-Time were all degraded more quietly.
What's new: Both routers expose their response headers, so browser clients — including this project's own web extension — can read them.
Cached backlinks index: every single-note read walked the entire vault link graph
The problem: Asking for one note's metadata — GET /vault/<path> with Accept: application/vnd.olrapi.note+json, or the MCP vault_read tool — walked all of resolvedLinks and every target inside it, kept the one entry it needed, and threw the rest away. The bulk search path had always passed a prebuilt index through its loop; the single-note paths never did.
What's new: The index is memoised and rebuilt only when Obsidian reports the link graph has moved. Bulk callers can still pass their own snapshot, so every row of a result set describes the same moment even if the graph shifts mid-loop.
MCP revision 2026-07-28: the served protocol revision predated SDK v2
What's new: The July 28, 2026 specification is served through the official TypeScript SDK v2, with server/discover, per-request protocol metadata, and modern Streamable HTTP validation. The sessionful legacy path is kept alongside it — Mcp-Session-Id, GET/DELETE lifecycle, and tools/list_changed notifications — so initialization-based clients keep working. A client asking for an unsupported revision gets a -32022 naming the ones that are supported.
Bug fixes
MCP tools could write outside your vault (GHSA-j9qv-qgpv-345g)
The problem: The MCP vault_write tool accepted a path of ../../Ausserhalb.md, wrote two directories above the vault, and returned {"message": "OK"}. The advisory that reported it noted that every other mutating tool refused the same input and concluded vault_write was a lone regression.
That turned out not to be the story. Obsidian's Vault API isn't a sandbox, and its two halves disagree about what an out-of-vault path means — the other tools were refusing by accident, not by design, which is what decided where the fix goes.
What's fixed: Escaping paths are refused at a single chokepoint in the vault operations layer, so every path-taking tool is covered by the same rule rather than by whichever accident happened to catch it. Tests assert that no create, write, or remove call is made at all, not merely that an error came back.
Known limitation: this is a textual containment check, not a real-path one. A path that stays inside the vault as written can still point outside it through a symlinked folder, and Obsidian's API exposes no real-path primitive to check that with. A symlink inside your vault is something you put there deliberately, so that's a judged-acceptable gap rather than an oversight — but it is a gap.
You are affected if you expose this plugin's MCP server to anything you don't fully trust to choose its own paths. Upgrading is the fix; there's no setting that mitigates it.
vault_read silently destroyed attachments
The problem: vault_read decoded a file as UTF-8 and returned whatever that produced. Point it at a PNG and you got a lossy, unusable string and a result that looked successful. Write that string back through vault_write and the file was gone. Every step of that round trip reported success, so nothing told you the attachment had been eaten — and vault_list would happily lead an agent to your attachments folder.
What's fixed: vault_read refuses a file whose bytes aren't valid UTF-8, and the refusal names vault_read_binary as the way to read it instead. The check decodes the bytes, re-encodes them, and compares — anything that doesn't survive that round trip lost data on the way in.
Firefox rejected the generated certificate
The problem: Since the beginning, the plugin generated one self-signed certificate with basicConstraints cA:true and served that same certificate as the TLS leaf. RFC 5280 doesn't forbid it, but Firefox is among the verifiers that do: importing the certificate as an authority there still produced MOZILLA_PKIX_ERROR_CA_CERT_USED_AS_END_ENTITY. The README's "download and trust the certificate" advice was never achievable for Firefox users. (Fixes #338, and revisits the position taken in #270.)
What's fixed: The plugin now generates a ten-year CA plus a one-year server certificate signed by it. The HTTPS server presents leaf plus CA, and /obsidian-local-rest-api.crt serves the CA, since that's what people import. Anything that trusted the old certificate as an authority validates the new chain the same way. Because there's now a CA, the server certificate renews itself on plugin load inside its last 30 days, so the yearly expiry nag is gone.
Existing installs are not regenerated automatically. A new CA means re-importing it everywhere you trusted the old one, which is your call to make. The Certificates settings page shows a mild "Update available" note, and GET / reports certificateInfo.regenerateReason: "ca-used-as-leaf". Your current certificate keeps working. The Certificates page also gains "CA certificate" and "CA private key" fields if you'd rather bring your own.
Also fixed
- A write to
/active/that failed never sent a reply — the request simply hung.redirectToVaultPathfired its delegated call withvoid, so the outer handler resolved before the inner one finished and the rejection never reached the error handler. (#354; Thanks @ChristophPfeuffer!) simpleSearchcould slice its context window through the middle of a surrogate pair, splitting an emoji or other astral character into replacement characters. (#330, #331; Thanks @jtulak!)- The sample MCP configuration in the Readme no longer overflows its code block. (#325; Thanks @rca-umb!)