A secure REST API and Model Context Protocol (MCP) server for your vault.
See the codeGive your scripts, browser extensions, and AI agents a direct line into your Obsidian vault via a secure, authenticated REST API.
Access your vault through the REST API or the built-in MCP server — both interfaces expose the same core capabilities, so scripts, browser extensions, and AI agents all speak the same language.
All requests are served over HTTPS with a locally generated certificate and gated behind API key authentication.
After installing and enabling the plugin, open Settings → Local REST API to find your API key and certificate.
# Check the server is running (no auth required)
curl -k https://127.0.0.1:27124/
# List files at the root of your vault
curl -k -H "Authorization: Bearer <your-api-key>" \
https://127.0.0.1:27124/vault/
# Read a note
curl -k -H "Authorization: Bearer <your-api-key>" \
https://127.0.0.1:27124/vault/path/to/note.md
# Read a specific heading (URL-embedded target)
curl -k -H "Authorization: Bearer <your-api-key>" \
https://127.0.0.1:27124/vault/path/to/note.md/heading/My%20Section
# Append a line to a specific heading (PATCH with a JSON instruction)
curl -k -X PATCH \
-H "Authorization: Bearer <your-api-key>" \
-H "Content-Type: application/json" \
--data '{"targetType":"heading","target":["My Section"],"operation":"append","content":"New line of content"}' \
https://127.0.0.1:27124/vault/path/to/note.md
To avoid certificate warnings, you can download the plugin's certificate authority from https://127.0.0.1:27124/obsidian-local-rest-api.crt and trust it in your OS or browser, or point your HTTP client at it directly (for example curl --cacert obsidian-local-rest-api.crt ...). The plugin generates its own certificate authority on first run and serves a server certificate signed by it, so the download is a CA certificate rather than the server certificate itself. That CA is name-constrained: it can only vouch for 127.0.0.1, localhost, your configured binding host, and the hostnames you list under Subject alternative names, so trusting it does not let it (or anyone who obtains its key) impersonate other sites.
The MCP server runs at https://127.0.0.1:27124/mcp/ and requires that you provide your bearer token for authentication via an Authorization header (i.e. Authorization: Bearer <your-api-key>). Because the plugin uses a locally generated certificate authority, you may need to either trust that certificate in your OS/client, or use the plain HTTP endpoint at http://127.0.0.1:27123/mcp/ (enable it under Settings → Local REST API → Enable HTTP server).
Claude Code has native HTTP MCP support. The quickest way to add the server is via the CLI:
claude mcp add --transport http obsidian https://127.0.0.1:27124/mcp/ \
--header "Authorization: Bearer <your-api-key>"
Or add it manually to .mcp.json in your project root (project-scoped) or configure it user-wide via claude mcp add --scope user:
{
"mcpServers": {
"obsidian": {
"type": "http",
"url": "https://127.0.0.1:27124/mcp/",
"headers": {
"Authorization": "Bearer <your-api-key>"
}
}
}
}
Claude Desktop does not natively support remote HTTP MCP servers, but you can bridge it with mcp-remote (requires Node.js). Add the following to claude_desktop_config.json:
~/Library/Application Support/Claude/claude_desktop_config.json%APPDATA%\Claude\claude_desktop_config.json{
"mcpServers": {
"obsidian": {
"command": "npx",
"args": [
"mcp-remote@latest",
"https://127.0.0.1:27124/mcp/",
"--header",
"Authorization: Bearer <your-api-key>"
]
}
}
}
Restart Claude Desktop after saving the file.
Cursor supports the Streamable HTTP MCP transport. Add the following to ~/.cursor/mcp.json (global) or .cursor/mcp.json (project-specific):
{
"mcpServers": {
"obsidian": {
"url": "https://127.0.0.1:27124/mcp/",
"headers": {
"Authorization": "Bearer <your-api-key>"
}
}
}
}
Any MCP client that supports the Streamable HTTP transport can connect to https://127.0.0.1:27124/mcp/ with an Authorization: Bearer <your-api-key> header. Consult your client's documentation for the exact configuration format.
| Endpoint | Methods | Description |
|---|---|---|
/vault/{path} | GET PUT PATCH POST DELETE | Read, write, or delete any file in your vault |
/active/ | GET PUT PATCH POST DELETE | Operate on the currently open file |
/search/simple/ | POST | Full-text search across all notes |
/search/ | POST | Structured search via JsonLogic |
/commands/ | GET | List available Obsidian commands |
/commands/{commandId}/ | POST | Execute a command |
/tags/ | GET | List all tags with usage counts |
/open/{path} | POST | Open a file in the Obsidian UI |
/ | GET | Server status and authentication check |
/mcp/ | GET POST | MCP (Model Context Protocol) server — connect AI agents directly to your vault |
For full request/response details, see the interactive docs.
Several endpoints answer in a response header rather than in the body: Content-Location tells you which file a targeted or /active/ request actually resolved to, Markdown-Patch-Warnings reports what a PATCH had to work around, Deprecation warns that a format is sunsetting, and Mcp-Session-Id carries the session for a sessionful MCP connection.
Browsers hide response headers from JavaScript unless the server opts them in, so the API sends Access-Control-Expose-Headers: * and all of them are readable with response.headers.get(...). Safari honours the wildcard from 15.4 onward; older browsers see only the CORS-safelisted headers. Requests made with credentials: "include" are not supported — the API authenticates with a bearer token and sends Access-Control-Allow-Origin: *, which browsers reject for credentialed requests.
The PATCH method is one of the most useful features of this API. It lets you make targeted edits without rewriting entire files.
Send a JSON instruction: an operation (replace, prepend, append, or delete) applied to a scope (content, marker, markerAndContent, or parent) of a target — a heading (addressed as an array of heading texts from the top level down), a block reference, or a frontmatter key. The payload rides in content (a string), value (JSON, for frontmatter values), or destination (a heading move):
# Replace the value of a frontmatter field
curl -k -X PATCH \
-H "Authorization: Bearer <your-api-key>" \
-H "Content-Type: application/json" \
--data '{"targetType":"frontmatter","target":"status","operation":"replace","value":"done"}' \
https://127.0.0.1:27124/vault/path/to/note.md
Heading levels inside a content string are relative to the target (a leading # becomes a direct child). Advisory warnings (e.g. a heading rebased past level 6) come back as percent-encoded JSON in the Markdown-Patch-Warnings response header — decode with decodeURIComponent before parsing. Pass ifMatch (the version from a document map) for optimistic concurrency.
Note: Whitespace is library-owned — your content is reduced to trimmed, canonical form (leading and trailing blank lines are meaningless), and the API itself supplies the blank line wherever inserted content faces body text, so an
appendorprependalways lands as its own block and never merges into an existing paragraph. Heading lines, existing blank lines, and each document's spacing style are preserved as-is. See the interactive docs for worked examples.
To continue an existing block instead of starting a new one — say, extending a list — add within to a heading instruction: an index selecting one of the section's top-level body blocks (0-based in document order, negative counting from the end, so -1 is the last block). A within edit splices literally, so you own the joint:
# Add an item to the last list under "Log" (the leading \n continues the block)
curl -k -X PATCH \
-H "Authorization: Bearer <your-api-key>" \
-H "Content-Type: application/json" \
--data '{"targetType":"heading","target":["Log"],"within":-1,"operation":"append","content":"\n- new item"}' \
https://127.0.0.1:27124/vault/path/to/note.md
With markerAndContent scope, prepend/append instead insert a new block beside the indexed one. Indices are positional, so read the document map first and pair the edit with ifMatch.
If your client templates markdown into the request body (Shortcuts, Tasker, curl from a template), JSON-escaping that content into an instruction is fragile. Raw-content mode moves the instruction's fields out of the body — target in the URL (or in Target-Type/Target headers with an explicit Markdown-Patch-Version: 2), operation and options in headers — and the body is the raw payload, no JSON escaping required:
# Append a templated line under a heading — no JSON escaping anywhere
curl -k -X PATCH \
-H "Authorization: Bearer <your-api-key>" \
-H "Operation: append" \
-H "Content-Type: text/markdown" \
--data "- $TEMPLATED_CONTENT" \
https://127.0.0.1:27124/vault/notes/daily.md/heading/Log
A text/* body is the content carrier, an application/json body the value carrier, and no body at all carries nothing (a delete, or a move via a Destination header). Target-Scope, Within (the instruction's within index as a plain integer, e.g. -1), Create-Target-If-Missing, Reject-If-Content-Preexists, and If-Match headers round out the instruction. See the interactive docs for the header encodings and the full details.
Already using the older header-driven PATCH format? It spread the instruction across request headers instead of a JSON body, and is deprecated and will be removed in 6.0. It still works — send
Markdown-Patch-Version: 1to opt back into it (the same header also selects the legacy::-joined document map on GET), and responses served by it carry aDeprecation: true; sunset-version="6.0"header. To upgrade, drop that header and move each header into the JSON body; the interactive docs have the field-by-field mapping table.
See the interactive docs for the full instruction schema and options.
You can read or write a specific part of a note — a heading, block reference, or frontmatter field — without fetching or replacing the whole file. This works on GET, PUT, POST, and PATCH requests (for PATCH this is raw-content mode — add an Operation header).
Append /<target-type>/<target> after the filename. Each nested heading level is its own path segment, so a heading whose text contains :: needs no escaping:
# Read the content under a specific heading
curl -k -H "Authorization: Bearer <your-api-key>" \
https://127.0.0.1:27124/vault/path/to/note.md/heading/My%20Section
# Read a nested heading (one path segment per level)
curl -k -H "Authorization: Bearer <your-api-key>" \
https://127.0.0.1:27124/vault/path/to/note.md/heading/Work/Meetings
# Read a frontmatter field
curl -k -H "Authorization: Bearer <your-api-key>" \
https://127.0.0.1:27124/vault/path/to/note.md/frontmatter/status
# Replace the content of a heading via PUT (heading levels are normalized for you)
curl -k -X PUT \
-H "Authorization: Bearer <your-api-key>" \
-H "Content-Type: text/markdown" \
--data "Updated content" \
https://127.0.0.1:27124/vault/path/to/note.md/heading/My%20Section
# Append to a heading via POST
curl -k -X POST \
-H "Authorization: Bearer <your-api-key>" \
-H "Content-Type: text/markdown" \
--data "Appended content" \
https://127.0.0.1:27124/vault/path/to/note.md/heading/My%20Section
Supported target types: heading, block, frontmatter.
A targeted URL is ambiguous on its face — /vault/notes/log.md/heading/Today could name the Today section of notes/log.md or a file literally called notes/log.md/heading/Today. The server walks backwards down the path until it finds a real file and reports which one it settled on in a Content-Location response header, with each path component percent-encoded on its own (non-ASCII characters, and reserved characters like #, ? and ,) so it can be pasted straight back into a request URL. A request whose URL names the file outright gets no such header.
On a GET, a Target-Scope header selects which part of the target comes back, mirroring the PATCH scopes: content (the default), marker (the label — a heading's raw text, a block's bare id, a frontmatter key), or markerAndContent (the whole node, in exactly the shape a PATCH replace at that scope consumes — a heading subtree reads back with its own line as # Title, levels relative to its parent):
# Read a whole section — heading line included — ready to edit and write back
curl -k -H "Authorization: Bearer <your-api-key>" \
-H "Target-Scope: markerAndContent" \
https://127.0.0.1:27124/vault/path/to/note.md/heading/My%20Section
Deprecated: header-based targeting. Earlier releases targeted a section with
Target-Type,Target, andTarget-Delimiterheaders (plusTarget-Scope/Trim-Target-Whitespace). That form is deprecated and will be removed in 6.0; it is only processed when you also sendMarkdown-Patch-Version: 1(responses then carry aDeprecationheader). Without it, supplying those targeting headers is rejected with400. Supplying both URL-path targeting and the header form on one request returns422 Unprocessable Entity.
POST /search/simple/?query=your+terms runs Obsidian's built-in fuzzy search and returns matching filenames with scored context snippets.
POST /search/ accepts a JsonLogic expression (content type application/vnd.olrapi.jsonlogic+json) and evaluates it against each note's metadata (frontmatter, tags, path, content).
You can follow what happens in the vault as a Server-Sent Events stream. There are two steps. First, register a subscription to one Obsidian event, with an optional JsonLogic filter. Then open the URL that comes back:
# 1. Subscribe to notes under journal/ being modified
curl -X POST -H "Authorization: Bearer <your-api-key>" \
-H "Content-Type: application/vnd.olrapi.jsonlogic+json" \
-d '{"glob": ["journal/*", {"var": "path"}]}' \
https://127.0.0.1:27124/events/vault/modify/
# => {"id": "…", "url": "https://127.0.0.1:27124/events/vault/modify/…/?sig=…&exp=…&n=…", …}
# 2. Follow the stream; with signed URLs on, the URL needs no API key
curl -N "<url>"
It takes two steps because a browser's EventSource can only make GET requests, which have no body to carry a filter.
The events are Obsidian's own, and only these can be streamed:
| Emitter | Events |
|---|---|
vault | create, modify, delete, rename |
metadataCache | changed, deleted, resolve, resolved |
workspace | file-open, active-leaf-change, layout-change |
Each event is serialized by code written for it. That code decides exactly what is sent: the path, the file's NoteJson (the same shape /search/ evaluates), and a few event-specific fields such as oldPath on a rename. Note content is sent only when the filter reads file.content. Events whose payloads are keystrokes, clipboard data, or UI objects (editor-change, quick-preview, editor-paste, the menu events, …) can't be streamed. To react to frontmatter changes, use metadataCache changed: vault modify fires before Obsidian has re-read the file's metadata.
Each message's id is <epoch>-<counter>. A new epoch, or a gap in the counter, means events were missed. Nothing is replayed. A stream URL expires after the signed-URL lifetime (or ?ttl=<seconds>), but a stream opened before then stays open. At most 16 streams can be open at once. Anyone holding a signed stream URL sees the paths and metadata of every event its filter matches, so treat it like the notes themselves. See the API docs for the full message format.
[!NOTE] Several third-party MCP servers for Obsidian exist, but they are no longer necessary — this plugin ships a built-in MCP server that runs inside Obsidian and has direct access to your vault's live metadata, active file, and command palette. If you are currently using a third-party server, switching to this one is likely to give you better results.
The plugin includes a built-in MCP server at /mcp/ so AI agents and MCP-compatible clients can interact with your vault without hand-crafting HTTP requests.
Transport: Streamable HTTP — API key authentication required.
The endpoint serves the 2026-07-28 revision plus the sessionful revisions from 2024-10-07 through 2025-11-25, choosing per request, so clients on either can share it.
The 2026-07-28 revision is stateless: there is no initialize handshake and no session, so the plugin neither issues nor reads the Mcp-Session-Id header. Each request carries its own protocol version and client identity in params._meta, repeats them in the MCP-Protocol-Version, Mcp-Method, and Mcp-Name headers, and is answered on its own. Clients can call server/discover to learn the supported revisions and capabilities up front.
Clients that open with an initialize request are served the sessionful revision they negotiate: the handshake returns an Mcp-Session-Id, GET /mcp/ opens that session's notification stream, and DELETE /mcp/ ends it. Sessions exist only on this path, and they are what keeps the handshake's listChanged capabilities honest: when another plugin registers or removes an MCP tool, every live session is notified, while 2026-07-28 clients hear about it on a subscriptions/listen stream.
Connect your MCP client to https://127.0.0.1:27124/mcp/. Authentication uses a bearer token — find your API key under Settings → Local REST API, then pass it as:
Authorization: Bearer <your-api-key>
The exact config syntax varies by client; see the Quick start examples above or consult your client's documentation for Streamable HTTP remote MCP servers.
[!WARNING] To connect to the MCP server securely, your client must trust the plugin's locally generated certificate authority. You can download and trust it from
https://127.0.0.1:27124/obsidian-local-rest-api.crt, or configure your client to skip TLS verification for127.0.0.1.If trusting a locally generated certificate is not possible in your environment, you can connect insecurely using
http://127.0.0.1:27123/mcp/instead ofhttps://127.0.0.1:27124/mcp/if you have enabled the HTTP endpoint under Settings → Local REST API → Enable HTTP server.
| Tool | Description |
|---|---|
vault_list | List files and subdirectories inside a vault directory |
vault_read | Read a text file's content, frontmatter, tags, and stat; refuses anything that is not valid UTF-8 |
vault_read_binary | Read an attachment: images as an image block the model can see, anything else as a download link or embedded bytes |
vault_get_download_url | Mint a signed, expiring link to a file that works without the API key (only when signed URLs are enabled) |
vault_get_upload_url | Mint a signed, single-use link for uploading a file over PUT (only when signed URLs are enabled) |
events_get_listener_url | Subscribe to an Obsidian event and mint a signed link to its Server-Sent Events stream (only when signed URLs are enabled) |
vault_write | Create or overwrite a text file; refuses paths whose extension names a binary type |
vault_append | Append content to the end of a vault file |
vault_patch | Patch a specific heading, block reference, or frontmatter field |
vault_delete | Delete a vault file (moves to trash by default) |
vault_move | Move (rename) a vault file to a new path |
vault_copy | Copy a vault file to a new path |
vault_get_document_map | List the headings, block references, and frontmatter fields in a file |
active_file_get_path | Return the vault path of the file currently open in Obsidian |
search_query | Search using a JsonLogic query against note metadata |
search_simple | Full-text search using Obsidian's built-in search |
tag_list | List all tags across the vault with usage counts |
command_list | List all registered Obsidian commands |
command_execute | Execute an Obsidian command by ID |
open_file | Open a file in the Obsidian UI |
The REST API has always handled binary content: GET /vault/<path> returns raw bytes with a Content-Type derived from the file extension, and PUT /vault/<path> accepts a body of any content type and stores it byte-for-byte. Neither has a practical size limit.
MCP tools are a different story, because a tool's arguments and results pass through the model. vault_read and vault_write are text tools — they decode and encode UTF-8, which is lossy for anything that is not text — so vault_read refuses a file whose bytes are not valid UTF-8, and vault_write and vault_append refuse a path whose extension names an image (other than SVG, which is text), audio, video, font, PDF, or archive type. Reading an attachment as text and writing the result back is the mistake that destroys attachments, and both halves of it are now refused.
vault_read_binary is the tool for attachments, and what it returns depends on the file:
image content block, downscaled to fit 1568px on the long side, plus a small text block with the file's path, MIME type, size, and dimensions. The model can actually look at the picture, and is billed for its pixels rather than its bytes: a multi-megabyte photo costs a couple of thousand tokens. An image that is still over 512 KiB once downscaled -- a large image that was already inside 1568px, so nothing was resized -- comes back as a download link instead, the same as any other oversized file, or is refused with a pointer at the REST endpoint when signed URLs are off and there is no link to give.resource block. A vector drawing is XML the model can read directly, so nothing is rasterized or resized.resource_link to a signed download URL when signed URLs are enabled (below), so the bytes never enter the conversation. When they are not enabled, a file under 512 KiB is embedded as a resource block with base64 bytes, and a larger one is refused with a pointer at the REST endpoint.An as argument overrides the default: as: "bytes" embeds the raw bytes (under 512 KiB), as: "link" returns a signed link and never reads the file.
The 512 KiB ceiling is not only about token cost. A tool result carrying roughly a megabyte or more of base64 crashes Obsidian's Electron renderer outright, taking the plugin's HTTP server down with it, so the cap is set well below the point where that was observed.
There is no upload tool that carries bytes through the model — emitting base64 as output tokens is impractical beyond a few kilobytes. The agent's host has the file on disk; it uploads it with a PUT to the REST API, either with the API key or with a signed upload URL.
Signed URLs let an agent hand a file to something that is not the MCP client — a browser tab, an <img> tag, a curl in a shell — without also handing over the API key. They are on by default; turn them off under Settings → Local REST API → Advanced settings → Enable signed URLs, and set their lifetime there (default 300 seconds).
While they are on, these MCP tools mint them:
vault_get_download_url returns a resource_link to GET /vault/<path>?sig=…&exp=…&n=…, plus a markdown link for clients that only render text. The link is valid until it expires and can be used repeatedly. Add &download=1 to have the browser save the file instead of showing it.vault_get_upload_url returns a PUT /vault/<path>?sig=…&exp=…&n=… URL and a ready-to-run curl command, with the filename quoted for a POSIX shell so a name containing $, backticks or spaces cannot be expanded when the command is pasted. The link is consumed by the first request that succeeds — claimed at authorization rather than at completion, so concurrent redemptions cannot all pass, and released again if the request does not end in a 2xx. The Content-Type is informational on a signed upload: the bytes are stored exactly as sent, whatever type is declared, because a signed URL authorizes a whole-file write of that content. Without it, a .json destination was parsed and re-serialized, so a pretty-printed file lost its whitespace and trailing newline while still answering 204.vault_read_binary uses download links for non-image files, and for anything when called with as: "link".events_get_listener_url registers an event stream subscription and returns its GET /events/<emitter>/<event>/<id>/?sig=…&exp=…&n=… URL, plus a curl -N command. POST /events/<emitter>/<event>/ returns the same kind of URL.A signed URL authorizes a whole-file write to the path it names, and only that: a request that also carries Target-Type/Target headers, or whose path continues into /heading, /block or /frontmatter, is refused rather than quietly becoming a targeted edit of a document the link never named. Use the API key for targeted writes.
How they work: the signature is an HMAC over the method, the normalized vault path, the expiry, and a random per-link nonce carried as n, under a secret generated fresh every time the plugin loads and kept only in memory. A link is therefore good for one file, one method, one window of time, and never survives an Obsidian restart. The nonce is what makes each mint its own link: exp has one-second granularity, so without it two links minted for the same file in the same second were byte-identical -- and since a spent upload link is remembered by its signature, re-minting straight after an upload handed back the link that had just been consumed. The host is not part of the signature, so the same link works whichever hostname on the certificate the client uses. Anyone holding a link can do what it names until it expires, so treat one as you would the file itself.
Two practical notes: whether a chat client renders a linked image inline is up to the client, and most do not today, but clicking through always works; and a link on the HTTPS port needs the plugin's certificate to be trusted by whatever opens it — the plain-HTTP port avoids that.
| URI | Description |
|---|---|
obsidian://local-rest-api/openapi.yaml | Full OpenAPI specification for this REST API, including routes that extensions describe |
Other plugins can register their own authenticated routes, public routes, MCP tools, and streamable events against this plugin's server. See Adding your own API Routes via an Extension for a walkthrough.
Install this package as a development dependency to get getAPI and the types for everything it returns:
npm install --save-dev obsidian-local-rest-api
This package declares obsidian, zod, and @types/express as peer dependencies, because its types refer to all three — addRoute returns express's IRoute, and addMcpTool takes zod schemas. npm installs peers for you; if you pin them yourself, keep them resolvable. Without them, TypeScript quietly widens those positions to any instead of reporting an error, so a project that suppresses the missing-types diagnostic gets no warning that it has lost type checking exactly where it matters most.
import { getAPI, type LocalRestApiPublicApi } from "obsidian-local-rest-api";
const api: LocalRestApiPublicApi | undefined = getAPI(this.app, this.manifest, 2);
The package entry point is a small standalone module — it resolves the running host plugin out of Obsidian's plugin registry rather than pulling the plugin bundle into your build. Passing an extension API version (2 above) makes getAPI throw ApiVersionUnsupportedError when the installed host is older than the surface you need; omit it to accept whatever is installed and feature-detect yourself. getAPI returns undefined when the plugin isn't installed or hasn't loaded yet.
publicApi.d.ts is generated from src/publicApi.ts, which the implementation is compile-time-checked against, so the published types cannot drift from what the plugin actually offers.
addMcpTool(name, description, schema, callback) sends whatever your callback returns back to the client as a single block of JSON text. From extension API version 3 you can instead pass a definition object, and the callback returns the complete MCP result, which reaches the client unchanged. Use it when you need images, structuredContent checked against an outputSchema, or an isError result that tells the model a call failed in a way it can recover from.
Everything in this section needs version 3, so ask for it when you call getAPI. The types describe the whole interface whichever version you pass, so an extension that asks for 2 still compiles against these methods, and then finds them missing at runtime on an older host:
const api = getAPI(this.app, this.manifest, 3);
api?.addMcpTool({
name: "comments_count",
description: "Count the comments on a note",
inputSchema: { path: z.string() },
outputSchema: { count: z.number() },
callback: async ({ path }) => {
const count = await countComments(path as string);
return {
content: [{ type: "text", text: `${count} comments` }],
structuredContent: { count },
};
},
});
Version 3 also lets an extension expose things that aren't tools:
addMcpResource({ name, uri, read }) adds a resource at a fixed URI.addMcpResourceTemplate({ name, uriTemplate, read, list? }) adds a family of resources addressed by an RFC 6570 template such as tandem://comments/{path}. read gets the matched variables. list is optional, and when you provide it, its resources appear in resources/list.addMcpPrompt({ name, argsSchema?, callback }) adds a prompt. MCP passes prompt arguments as strings.Clients that are already connected are notified when these lists change, and unregister() removes everything the handle registered.
The plugin can't see what an extension's routes accept or return, so they don't appear in the OpenAPI spec until the extension describes them. addOpenApiDescription (extension API version 4) takes the paths, components, and tags your routes need, in the same shape as the matching parts of an OpenAPI document, and merges them into the spec served at /openapi.yaml, /openapi.json, and the MCP openapi-spec resource:
Request version 4 from getAPI so an older host fails loudly instead of lacking the method:
const api = getAPI(this.app, this.manifest, 4);
api.addRoute("/widgets/:id/").get(handler);
api.addOpenApiDescription({
paths: {
"/widgets/{id}/": {
get: {
tags: ["Widgets"],
summary: "Return one widget.",
parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }],
responses: { "200": { description: "The widget." } },
},
},
},
tags: [{ name: "Widgets", description: "Routes added by the Widgets plugin." }],
});
Write path parameters the OpenAPI way ({id}), not express's (:id). Each path you contribute is published with an x-obsidian-extension field set to your plugin ID. A path, component, or tag that the plugin or another extension already declares makes the call throw without publishing anything, and unregister() removes your description along with your routes.
Routes added with addRoute can't live under /vault/, because the plugin's own /vault/* handler claims those paths first. From extension API version 3, addVaultSubresource(name) lets an extension serve routes under any note instead:
import type { VaultSubresourceRequest } from "obsidian-local-rest-api";
const comments = api.addVaultSubresource("comments");
comments.get("/", (req, res) => {
const { vaultFile } = req as VaultSubresourceRequest;
res.json(listComments(vaultFile));
});
comments.get("/:id", (req, res) => { /* ... */ });
GET /vault/Notes/draft.md/comments/a1f3 then reaches that router as GET /a1f3, with the note attached as req.vaultFile; /active/comments/a1f3 does the same for the active file. The plugin resolves the note before your router runs, so it only ever sees notes that exist, and a request your router doesn't answer continues to the plugin's own handlers. Requests need the API key; signed URLs never reach a sub-resource.
A %2F in the URL is a literal slash inside one segment, which Express's own route matching can't tell apart from a separator. req.vaultSubresourceSegments holds the segments after the name, each decoded on its own, for when that matters.
A name is one path segment. heading, block and frontmatter are reserved, and each name can only be registered by one extension at a time.
From extension API version 5, an extension can add its own events to the event streams. Each one is streamed under the extension's plugin id as the emitter:
const api = getAPI(this.app, this.manifest, 5);
// Your plugin's own Events instance; call this.events.trigger("task-completed", ...)
// wherever the event happens.
this.events = new Events();
api.addStreamableEvent("task-completed", {
source: this.events,
serialize: (file, line) => ({ path: (file as TFile).path, line }),
});
// Now available at POST /events/<your plugin id>/task-completed/
Your serializer decides everything a stream sends. The host adds emitter and event and sends nothing else, so return only what someone holding a stream URL should see. Return null to skip an occurrence. unregister() closes any open streams for your events.
See CONTRIBUTING.md. If you want to add functionality without modifying core, consider building an API extension instead — extensions can be developed and released independently.
Inspired by Vinzent03's advanced-uri plugin, with the goal of expanding automation options beyond the constraints of custom URL schemes.
TypeScript
98.4%
JavaScript
1.1%
A secure REST API and Model Context Protocol (MCP) server for your vault.
See the codeGive your scripts, browser extensions, and AI agents a direct line into your Obsidian vault via a secure, authenticated REST API.
Access your vault through the REST API or the built-in MCP server — both interfaces expose the same core capabilities, so scripts, browser extensions, and AI agents all speak the same language.
All requests are served over HTTPS with a locally generated certificate and gated behind API key authentication.
After installing and enabling the plugin, open Settings → Local REST API to find your API key and certificate.
# Check the server is running (no auth required)
curl -k https://127.0.0.1:27124/
# List files at the root of your vault
curl -k -H "Authorization: Bearer <your-api-key>" \
https://127.0.0.1:27124/vault/
# Read a note
curl -k -H "Authorization: Bearer <your-api-key>" \
https://127.0.0.1:27124/vault/path/to/note.md
# Read a specific heading (URL-embedded target)
curl -k -H "Authorization: Bearer <your-api-key>" \
https://127.0.0.1:27124/vault/path/to/note.md/heading/My%20Section
# Append a line to a specific heading (PATCH with a JSON instruction)
curl -k -X PATCH \
-H "Authorization: Bearer <your-api-key>" \
-H "Content-Type: application/json" \
--data '{"targetType":"heading","target":["My Section"],"operation":"append","content":"New line of content"}' \
https://127.0.0.1:27124/vault/path/to/note.md
To avoid certificate warnings, you can download the plugin's certificate authority from https://127.0.0.1:27124/obsidian-local-rest-api.crt and trust it in your OS or browser, or point your HTTP client at it directly (for example curl --cacert obsidian-local-rest-api.crt ...). The plugin generates its own certificate authority on first run and serves a server certificate signed by it, so the download is a CA certificate rather than the server certificate itself. That CA is name-constrained: it can only vouch for 127.0.0.1, localhost, your configured binding host, and the hostnames you list under Subject alternative names, so trusting it does not let it (or anyone who obtains its key) impersonate other sites.
The MCP server runs at https://127.0.0.1:27124/mcp/ and requires that you provide your bearer token for authentication via an Authorization header (i.e. Authorization: Bearer <your-api-key>). Because the plugin uses a locally generated certificate authority, you may need to either trust that certificate in your OS/client, or use the plain HTTP endpoint at http://127.0.0.1:27123/mcp/ (enable it under Settings → Local REST API → Enable HTTP server).
Claude Code has native HTTP MCP support. The quickest way to add the server is via the CLI:
claude mcp add --transport http obsidian https://127.0.0.1:27124/mcp/ \
--header "Authorization: Bearer <your-api-key>"
Or add it manually to .mcp.json in your project root (project-scoped) or configure it user-wide via claude mcp add --scope user:
{
"mcpServers": {
"obsidian": {
"type": "http",
"url": "https://127.0.0.1:27124/mcp/",
"headers": {
"Authorization": "Bearer <your-api-key>"
}
}
}
}
Claude Desktop does not natively support remote HTTP MCP servers, but you can bridge it with mcp-remote (requires Node.js). Add the following to claude_desktop_config.json:
~/Library/Application Support/Claude/claude_desktop_config.json%APPDATA%\Claude\claude_desktop_config.json{
"mcpServers": {
"obsidian": {
"command": "npx",
"args": [
"mcp-remote@latest",
"https://127.0.0.1:27124/mcp/",
"--header",
"Authorization: Bearer <your-api-key>"
]
}
}
}
Restart Claude Desktop after saving the file.
Cursor supports the Streamable HTTP MCP transport. Add the following to ~/.cursor/mcp.json (global) or .cursor/mcp.json (project-specific):
{
"mcpServers": {
"obsidian": {
"url": "https://127.0.0.1:27124/mcp/",
"headers": {
"Authorization": "Bearer <your-api-key>"
}
}
}
}
Any MCP client that supports the Streamable HTTP transport can connect to https://127.0.0.1:27124/mcp/ with an Authorization: Bearer <your-api-key> header. Consult your client's documentation for the exact configuration format.
| Endpoint | Methods | Description |
|---|---|---|
/vault/{path} | GET PUT PATCH POST DELETE | Read, write, or delete any file in your vault |
/active/ | GET PUT PATCH POST DELETE | Operate on the currently open file |
/search/simple/ | POST | Full-text search across all notes |
/search/ | POST | Structured search via JsonLogic |
/commands/ | GET | List available Obsidian commands |
/commands/{commandId}/ | POST | Execute a command |
/tags/ | GET | List all tags with usage counts |
/open/{path} | POST | Open a file in the Obsidian UI |
/ | GET | Server status and authentication check |
/mcp/ | GET POST | MCP (Model Context Protocol) server — connect AI agents directly to your vault |
For full request/response details, see the interactive docs.
Several endpoints answer in a response header rather than in the body: Content-Location tells you which file a targeted or /active/ request actually resolved to, Markdown-Patch-Warnings reports what a PATCH had to work around, Deprecation warns that a format is sunsetting, and Mcp-Session-Id carries the session for a sessionful MCP connection.
Browsers hide response headers from JavaScript unless the server opts them in, so the API sends Access-Control-Expose-Headers: * and all of them are readable with response.headers.get(...). Safari honours the wildcard from 15.4 onward; older browsers see only the CORS-safelisted headers. Requests made with credentials: "include" are not supported — the API authenticates with a bearer token and sends Access-Control-Allow-Origin: *, which browsers reject for credentialed requests.
The PATCH method is one of the most useful features of this API. It lets you make targeted edits without rewriting entire files.
Send a JSON instruction: an operation (replace, prepend, append, or delete) applied to a scope (content, marker, markerAndContent, or parent) of a target — a heading (addressed as an array of heading texts from the top level down), a block reference, or a frontmatter key. The payload rides in content (a string), value (JSON, for frontmatter values), or destination (a heading move):
# Replace the value of a frontmatter field
curl -k -X PATCH \
-H "Authorization: Bearer <your-api-key>" \
-H "Content-Type: application/json" \
--data '{"targetType":"frontmatter","target":"status","operation":"replace","value":"done"}' \
https://127.0.0.1:27124/vault/path/to/note.md
Heading levels inside a content string are relative to the target (a leading # becomes a direct child). Advisory warnings (e.g. a heading rebased past level 6) come back as percent-encoded JSON in the Markdown-Patch-Warnings response header — decode with decodeURIComponent before parsing. Pass ifMatch (the version from a document map) for optimistic concurrency.
Note: Whitespace is library-owned — your content is reduced to trimmed, canonical form (leading and trailing blank lines are meaningless), and the API itself supplies the blank line wherever inserted content faces body text, so an
appendorprependalways lands as its own block and never merges into an existing paragraph. Heading lines, existing blank lines, and each document's spacing style are preserved as-is. See the interactive docs for worked examples.
To continue an existing block instead of starting a new one — say, extending a list — add within to a heading instruction: an index selecting one of the section's top-level body blocks (0-based in document order, negative counting from the end, so -1 is the last block). A within edit splices literally, so you own the joint:
# Add an item to the last list under "Log" (the leading \n continues the block)
curl -k -X PATCH \
-H "Authorization: Bearer <your-api-key>" \
-H "Content-Type: application/json" \
--data '{"targetType":"heading","target":["Log"],"within":-1,"operation":"append","content":"\n- new item"}' \
https://127.0.0.1:27124/vault/path/to/note.md
With markerAndContent scope, prepend/append instead insert a new block beside the indexed one. Indices are positional, so read the document map first and pair the edit with ifMatch.
If your client templates markdown into the request body (Shortcuts, Tasker, curl from a template), JSON-escaping that content into an instruction is fragile. Raw-content mode moves the instruction's fields out of the body — target in the URL (or in Target-Type/Target headers with an explicit Markdown-Patch-Version: 2), operation and options in headers — and the body is the raw payload, no JSON escaping required:
# Append a templated line under a heading — no JSON escaping anywhere
curl -k -X PATCH \
-H "Authorization: Bearer <your-api-key>" \
-H "Operation: append" \
-H "Content-Type: text/markdown" \
--data "- $TEMPLATED_CONTENT" \
https://127.0.0.1:27124/vault/notes/daily.md/heading/Log
A text/* body is the content carrier, an application/json body the value carrier, and no body at all carries nothing (a delete, or a move via a Destination header). Target-Scope, Within (the instruction's within index as a plain integer, e.g. -1), Create-Target-If-Missing, Reject-If-Content-Preexists, and If-Match headers round out the instruction. See the interactive docs for the header encodings and the full details.
Already using the older header-driven PATCH format? It spread the instruction across request headers instead of a JSON body, and is deprecated and will be removed in 6.0. It still works — send
Markdown-Patch-Version: 1to opt back into it (the same header also selects the legacy::-joined document map on GET), and responses served by it carry aDeprecation: true; sunset-version="6.0"header. To upgrade, drop that header and move each header into the JSON body; the interactive docs have the field-by-field mapping table.
See the interactive docs for the full instruction schema and options.
You can read or write a specific part of a note — a heading, block reference, or frontmatter field — without fetching or replacing the whole file. This works on GET, PUT, POST, and PATCH requests (for PATCH this is raw-content mode — add an Operation header).
Append /<target-type>/<target> after the filename. Each nested heading level is its own path segment, so a heading whose text contains :: needs no escaping:
# Read the content under a specific heading
curl -k -H "Authorization: Bearer <your-api-key>" \
https://127.0.0.1:27124/vault/path/to/note.md/heading/My%20Section
# Read a nested heading (one path segment per level)
curl -k -H "Authorization: Bearer <your-api-key>" \
https://127.0.0.1:27124/vault/path/to/note.md/heading/Work/Meetings
# Read a frontmatter field
curl -k -H "Authorization: Bearer <your-api-key>" \
https://127.0.0.1:27124/vault/path/to/note.md/frontmatter/status
# Replace the content of a heading via PUT (heading levels are normalized for you)
curl -k -X PUT \
-H "Authorization: Bearer <your-api-key>" \
-H "Content-Type: text/markdown" \
--data "Updated content" \
https://127.0.0.1:27124/vault/path/to/note.md/heading/My%20Section
# Append to a heading via POST
curl -k -X POST \
-H "Authorization: Bearer <your-api-key>" \
-H "Content-Type: text/markdown" \
--data "Appended content" \
https://127.0.0.1:27124/vault/path/to/note.md/heading/My%20Section
Supported target types: heading, block, frontmatter.
A targeted URL is ambiguous on its face — /vault/notes/log.md/heading/Today could name the Today section of notes/log.md or a file literally called notes/log.md/heading/Today. The server walks backwards down the path until it finds a real file and reports which one it settled on in a Content-Location response header, with each path component percent-encoded on its own (non-ASCII characters, and reserved characters like #, ? and ,) so it can be pasted straight back into a request URL. A request whose URL names the file outright gets no such header.
On a GET, a Target-Scope header selects which part of the target comes back, mirroring the PATCH scopes: content (the default), marker (the label — a heading's raw text, a block's bare id, a frontmatter key), or markerAndContent (the whole node, in exactly the shape a PATCH replace at that scope consumes — a heading subtree reads back with its own line as # Title, levels relative to its parent):
# Read a whole section — heading line included — ready to edit and write back
curl -k -H "Authorization: Bearer <your-api-key>" \
-H "Target-Scope: markerAndContent" \
https://127.0.0.1:27124/vault/path/to/note.md/heading/My%20Section
Deprecated: header-based targeting. Earlier releases targeted a section with
Target-Type,Target, andTarget-Delimiterheaders (plusTarget-Scope/Trim-Target-Whitespace). That form is deprecated and will be removed in 6.0; it is only processed when you also sendMarkdown-Patch-Version: 1(responses then carry aDeprecationheader). Without it, supplying those targeting headers is rejected with400. Supplying both URL-path targeting and the header form on one request returns422 Unprocessable Entity.
POST /search/simple/?query=your+terms runs Obsidian's built-in fuzzy search and returns matching filenames with scored context snippets.
POST /search/ accepts a JsonLogic expression (content type application/vnd.olrapi.jsonlogic+json) and evaluates it against each note's metadata (frontmatter, tags, path, content).
You can follow what happens in the vault as a Server-Sent Events stream. There are two steps. First, register a subscription to one Obsidian event, with an optional JsonLogic filter. Then open the URL that comes back:
# 1. Subscribe to notes under journal/ being modified
curl -X POST -H "Authorization: Bearer <your-api-key>" \
-H "Content-Type: application/vnd.olrapi.jsonlogic+json" \
-d '{"glob": ["journal/*", {"var": "path"}]}' \
https://127.0.0.1:27124/events/vault/modify/
# => {"id": "…", "url": "https://127.0.0.1:27124/events/vault/modify/…/?sig=…&exp=…&n=…", …}
# 2. Follow the stream; with signed URLs on, the URL needs no API key
curl -N "<url>"
It takes two steps because a browser's EventSource can only make GET requests, which have no body to carry a filter.
The events are Obsidian's own, and only these can be streamed:
| Emitter | Events |
|---|---|
vault | create, modify, delete, rename |
metadataCache | changed, deleted, resolve, resolved |
workspace | file-open, active-leaf-change, layout-change |
Each event is serialized by code written for it. That code decides exactly what is sent: the path, the file's NoteJson (the same shape /search/ evaluates), and a few event-specific fields such as oldPath on a rename. Note content is sent only when the filter reads file.content. Events whose payloads are keystrokes, clipboard data, or UI objects (editor-change, quick-preview, editor-paste, the menu events, …) can't be streamed. To react to frontmatter changes, use metadataCache changed: vault modify fires before Obsidian has re-read the file's metadata.
Each message's id is <epoch>-<counter>. A new epoch, or a gap in the counter, means events were missed. Nothing is replayed. A stream URL expires after the signed-URL lifetime (or ?ttl=<seconds>), but a stream opened before then stays open. At most 16 streams can be open at once. Anyone holding a signed stream URL sees the paths and metadata of every event its filter matches, so treat it like the notes themselves. See the API docs for the full message format.
[!NOTE] Several third-party MCP servers for Obsidian exist, but they are no longer necessary — this plugin ships a built-in MCP server that runs inside Obsidian and has direct access to your vault's live metadata, active file, and command palette. If you are currently using a third-party server, switching to this one is likely to give you better results.
The plugin includes a built-in MCP server at /mcp/ so AI agents and MCP-compatible clients can interact with your vault without hand-crafting HTTP requests.
Transport: Streamable HTTP — API key authentication required.
The endpoint serves the 2026-07-28 revision plus the sessionful revisions from 2024-10-07 through 2025-11-25, choosing per request, so clients on either can share it.
The 2026-07-28 revision is stateless: there is no initialize handshake and no session, so the plugin neither issues nor reads the Mcp-Session-Id header. Each request carries its own protocol version and client identity in params._meta, repeats them in the MCP-Protocol-Version, Mcp-Method, and Mcp-Name headers, and is answered on its own. Clients can call server/discover to learn the supported revisions and capabilities up front.
Clients that open with an initialize request are served the sessionful revision they negotiate: the handshake returns an Mcp-Session-Id, GET /mcp/ opens that session's notification stream, and DELETE /mcp/ ends it. Sessions exist only on this path, and they are what keeps the handshake's listChanged capabilities honest: when another plugin registers or removes an MCP tool, every live session is notified, while 2026-07-28 clients hear about it on a subscriptions/listen stream.
Connect your MCP client to https://127.0.0.1:27124/mcp/. Authentication uses a bearer token — find your API key under Settings → Local REST API, then pass it as:
Authorization: Bearer <your-api-key>
The exact config syntax varies by client; see the Quick start examples above or consult your client's documentation for Streamable HTTP remote MCP servers.
[!WARNING] To connect to the MCP server securely, your client must trust the plugin's locally generated certificate authority. You can download and trust it from
https://127.0.0.1:27124/obsidian-local-rest-api.crt, or configure your client to skip TLS verification for127.0.0.1.If trusting a locally generated certificate is not possible in your environment, you can connect insecurely using
http://127.0.0.1:27123/mcp/instead ofhttps://127.0.0.1:27124/mcp/if you have enabled the HTTP endpoint under Settings → Local REST API → Enable HTTP server.
| Tool | Description |
|---|---|
vault_list | List files and subdirectories inside a vault directory |
vault_read | Read a text file's content, frontmatter, tags, and stat; refuses anything that is not valid UTF-8 |
vault_read_binary | Read an attachment: images as an image block the model can see, anything else as a download link or embedded bytes |
vault_get_download_url | Mint a signed, expiring link to a file that works without the API key (only when signed URLs are enabled) |
vault_get_upload_url | Mint a signed, single-use link for uploading a file over PUT (only when signed URLs are enabled) |
events_get_listener_url | Subscribe to an Obsidian event and mint a signed link to its Server-Sent Events stream (only when signed URLs are enabled) |
vault_write | Create or overwrite a text file; refuses paths whose extension names a binary type |
vault_append | Append content to the end of a vault file |
vault_patch | Patch a specific heading, block reference, or frontmatter field |
vault_delete | Delete a vault file (moves to trash by default) |
vault_move | Move (rename) a vault file to a new path |
vault_copy | Copy a vault file to a new path |
vault_get_document_map | List the headings, block references, and frontmatter fields in a file |
active_file_get_path | Return the vault path of the file currently open in Obsidian |
search_query | Search using a JsonLogic query against note metadata |
search_simple | Full-text search using Obsidian's built-in search |
tag_list | List all tags across the vault with usage counts |
command_list | List all registered Obsidian commands |
command_execute | Execute an Obsidian command by ID |
open_file | Open a file in the Obsidian UI |
The REST API has always handled binary content: GET /vault/<path> returns raw bytes with a Content-Type derived from the file extension, and PUT /vault/<path> accepts a body of any content type and stores it byte-for-byte. Neither has a practical size limit.
MCP tools are a different story, because a tool's arguments and results pass through the model. vault_read and vault_write are text tools — they decode and encode UTF-8, which is lossy for anything that is not text — so vault_read refuses a file whose bytes are not valid UTF-8, and vault_write and vault_append refuse a path whose extension names an image (other than SVG, which is text), audio, video, font, PDF, or archive type. Reading an attachment as text and writing the result back is the mistake that destroys attachments, and both halves of it are now refused.
vault_read_binary is the tool for attachments, and what it returns depends on the file:
image content block, downscaled to fit 1568px on the long side, plus a small text block with the file's path, MIME type, size, and dimensions. The model can actually look at the picture, and is billed for its pixels rather than its bytes: a multi-megabyte photo costs a couple of thousand tokens. An image that is still over 512 KiB once downscaled -- a large image that was already inside 1568px, so nothing was resized -- comes back as a download link instead, the same as any other oversized file, or is refused with a pointer at the REST endpoint when signed URLs are off and there is no link to give.resource block. A vector drawing is XML the model can read directly, so nothing is rasterized or resized.resource_link to a signed download URL when signed URLs are enabled (below), so the bytes never enter the conversation. When they are not enabled, a file under 512 KiB is embedded as a resource block with base64 bytes, and a larger one is refused with a pointer at the REST endpoint.An as argument overrides the default: as: "bytes" embeds the raw bytes (under 512 KiB), as: "link" returns a signed link and never reads the file.
The 512 KiB ceiling is not only about token cost. A tool result carrying roughly a megabyte or more of base64 crashes Obsidian's Electron renderer outright, taking the plugin's HTTP server down with it, so the cap is set well below the point where that was observed.
There is no upload tool that carries bytes through the model — emitting base64 as output tokens is impractical beyond a few kilobytes. The agent's host has the file on disk; it uploads it with a PUT to the REST API, either with the API key or with a signed upload URL.
Signed URLs let an agent hand a file to something that is not the MCP client — a browser tab, an <img> tag, a curl in a shell — without also handing over the API key. They are on by default; turn them off under Settings → Local REST API → Advanced settings → Enable signed URLs, and set their lifetime there (default 300 seconds).
While they are on, these MCP tools mint them:
vault_get_download_url returns a resource_link to GET /vault/<path>?sig=…&exp=…&n=…, plus a markdown link for clients that only render text. The link is valid until it expires and can be used repeatedly. Add &download=1 to have the browser save the file instead of showing it.vault_get_upload_url returns a PUT /vault/<path>?sig=…&exp=…&n=… URL and a ready-to-run curl command, with the filename quoted for a POSIX shell so a name containing $, backticks or spaces cannot be expanded when the command is pasted. The link is consumed by the first request that succeeds — claimed at authorization rather than at completion, so concurrent redemptions cannot all pass, and released again if the request does not end in a 2xx. The Content-Type is informational on a signed upload: the bytes are stored exactly as sent, whatever type is declared, because a signed URL authorizes a whole-file write of that content. Without it, a .json destination was parsed and re-serialized, so a pretty-printed file lost its whitespace and trailing newline while still answering 204.vault_read_binary uses download links for non-image files, and for anything when called with as: "link".events_get_listener_url registers an event stream subscription and returns its GET /events/<emitter>/<event>/<id>/?sig=…&exp=…&n=… URL, plus a curl -N command. POST /events/<emitter>/<event>/ returns the same kind of URL.A signed URL authorizes a whole-file write to the path it names, and only that: a request that also carries Target-Type/Target headers, or whose path continues into /heading, /block or /frontmatter, is refused rather than quietly becoming a targeted edit of a document the link never named. Use the API key for targeted writes.
How they work: the signature is an HMAC over the method, the normalized vault path, the expiry, and a random per-link nonce carried as n, under a secret generated fresh every time the plugin loads and kept only in memory. A link is therefore good for one file, one method, one window of time, and never survives an Obsidian restart. The nonce is what makes each mint its own link: exp has one-second granularity, so without it two links minted for the same file in the same second were byte-identical -- and since a spent upload link is remembered by its signature, re-minting straight after an upload handed back the link that had just been consumed. The host is not part of the signature, so the same link works whichever hostname on the certificate the client uses. Anyone holding a link can do what it names until it expires, so treat one as you would the file itself.
Two practical notes: whether a chat client renders a linked image inline is up to the client, and most do not today, but clicking through always works; and a link on the HTTPS port needs the plugin's certificate to be trusted by whatever opens it — the plain-HTTP port avoids that.
| URI | Description |
|---|---|
obsidian://local-rest-api/openapi.yaml | Full OpenAPI specification for this REST API, including routes that extensions describe |
Other plugins can register their own authenticated routes, public routes, MCP tools, and streamable events against this plugin's server. See Adding your own API Routes via an Extension for a walkthrough.
Install this package as a development dependency to get getAPI and the types for everything it returns:
npm install --save-dev obsidian-local-rest-api
This package declares obsidian, zod, and @types/express as peer dependencies, because its types refer to all three — addRoute returns express's IRoute, and addMcpTool takes zod schemas. npm installs peers for you; if you pin them yourself, keep them resolvable. Without them, TypeScript quietly widens those positions to any instead of reporting an error, so a project that suppresses the missing-types diagnostic gets no warning that it has lost type checking exactly where it matters most.
import { getAPI, type LocalRestApiPublicApi } from "obsidian-local-rest-api";
const api: LocalRestApiPublicApi | undefined = getAPI(this.app, this.manifest, 2);
The package entry point is a small standalone module — it resolves the running host plugin out of Obsidian's plugin registry rather than pulling the plugin bundle into your build. Passing an extension API version (2 above) makes getAPI throw ApiVersionUnsupportedError when the installed host is older than the surface you need; omit it to accept whatever is installed and feature-detect yourself. getAPI returns undefined when the plugin isn't installed or hasn't loaded yet.
publicApi.d.ts is generated from src/publicApi.ts, which the implementation is compile-time-checked against, so the published types cannot drift from what the plugin actually offers.
addMcpTool(name, description, schema, callback) sends whatever your callback returns back to the client as a single block of JSON text. From extension API version 3 you can instead pass a definition object, and the callback returns the complete MCP result, which reaches the client unchanged. Use it when you need images, structuredContent checked against an outputSchema, or an isError result that tells the model a call failed in a way it can recover from.
Everything in this section needs version 3, so ask for it when you call getAPI. The types describe the whole interface whichever version you pass, so an extension that asks for 2 still compiles against these methods, and then finds them missing at runtime on an older host:
const api = getAPI(this.app, this.manifest, 3);
api?.addMcpTool({
name: "comments_count",
description: "Count the comments on a note",
inputSchema: { path: z.string() },
outputSchema: { count: z.number() },
callback: async ({ path }) => {
const count = await countComments(path as string);
return {
content: [{ type: "text", text: `${count} comments` }],
structuredContent: { count },
};
},
});
Version 3 also lets an extension expose things that aren't tools:
addMcpResource({ name, uri, read }) adds a resource at a fixed URI.addMcpResourceTemplate({ name, uriTemplate, read, list? }) adds a family of resources addressed by an RFC 6570 template such as tandem://comments/{path}. read gets the matched variables. list is optional, and when you provide it, its resources appear in resources/list.addMcpPrompt({ name, argsSchema?, callback }) adds a prompt. MCP passes prompt arguments as strings.Clients that are already connected are notified when these lists change, and unregister() removes everything the handle registered.
The plugin can't see what an extension's routes accept or return, so they don't appear in the OpenAPI spec until the extension describes them. addOpenApiDescription (extension API version 4) takes the paths, components, and tags your routes need, in the same shape as the matching parts of an OpenAPI document, and merges them into the spec served at /openapi.yaml, /openapi.json, and the MCP openapi-spec resource:
Request version 4 from getAPI so an older host fails loudly instead of lacking the method:
const api = getAPI(this.app, this.manifest, 4);
api.addRoute("/widgets/:id/").get(handler);
api.addOpenApiDescription({
paths: {
"/widgets/{id}/": {
get: {
tags: ["Widgets"],
summary: "Return one widget.",
parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }],
responses: { "200": { description: "The widget." } },
},
},
},
tags: [{ name: "Widgets", description: "Routes added by the Widgets plugin." }],
});
Write path parameters the OpenAPI way ({id}), not express's (:id). Each path you contribute is published with an x-obsidian-extension field set to your plugin ID. A path, component, or tag that the plugin or another extension already declares makes the call throw without publishing anything, and unregister() removes your description along with your routes.
Routes added with addRoute can't live under /vault/, because the plugin's own /vault/* handler claims those paths first. From extension API version 3, addVaultSubresource(name) lets an extension serve routes under any note instead:
import type { VaultSubresourceRequest } from "obsidian-local-rest-api";
const comments = api.addVaultSubresource("comments");
comments.get("/", (req, res) => {
const { vaultFile } = req as VaultSubresourceRequest;
res.json(listComments(vaultFile));
});
comments.get("/:id", (req, res) => { /* ... */ });
GET /vault/Notes/draft.md/comments/a1f3 then reaches that router as GET /a1f3, with the note attached as req.vaultFile; /active/comments/a1f3 does the same for the active file. The plugin resolves the note before your router runs, so it only ever sees notes that exist, and a request your router doesn't answer continues to the plugin's own handlers. Requests need the API key; signed URLs never reach a sub-resource.
A %2F in the URL is a literal slash inside one segment, which Express's own route matching can't tell apart from a separator. req.vaultSubresourceSegments holds the segments after the name, each decoded on its own, for when that matters.
A name is one path segment. heading, block and frontmatter are reserved, and each name can only be registered by one extension at a time.
From extension API version 5, an extension can add its own events to the event streams. Each one is streamed under the extension's plugin id as the emitter:
const api = getAPI(this.app, this.manifest, 5);
// Your plugin's own Events instance; call this.events.trigger("task-completed", ...)
// wherever the event happens.
this.events = new Events();
api.addStreamableEvent("task-completed", {
source: this.events,
serialize: (file, line) => ({ path: (file as TFile).path, line }),
});
// Now available at POST /events/<your plugin id>/task-completed/
Your serializer decides everything a stream sends. The host adds emitter and event and sends nothing else, so return only what someone holding a stream URL should see. Return null to skip an occurrence. unregister() closes any open streams for your events.
See CONTRIBUTING.md. If you want to add functionality without modifying core, consider building an API extension instead — extensions can be developed and released independently.
Inspired by Vinzent03's advanced-uri plugin, with the goal of expanding automation options beyond the constraints of custom URL schemes.
TypeScript
98.4%
JavaScript
1.1%