Obsidian MCP Tools
A couple of tools to enable remote https access to an Obsidian MCP server.
obsidian-mcp-brain
A thin Node.js HTTP server that provides remote MCP access to an Obsidian vault. It implements all MCP tools natively and exposes them to remote clients such as Claude Code and Claude Desktop via HTTP.
Why this server is needed
Remote MCP clients (Claude Code 2.x, Claude Desktop) connect over HTTP, not stdio, and the standard approaches have issues:
No HTTP transport for local MCP servers
Existing solutions like mcp-proxy
wrap stdio MCP servers and expose them over HTTP. However, mcp-proxy has a
session-management bug: when Claude Code opens its GET /mcp notification
stream at the same time as sending tool-list requests (which it always does),
mcp-proxy's response routing gets confused and tools/list silently times out.
This server implements the MCP HTTP transport layer directly and implements all
tools natively, eliminating that class of bug.
No OAuth 2.0 discovery
The MCP 2025-03-26 spec requires every
non-localhost remote MCP server to expose OAuth 2.0 discovery endpoints
(/.well-known/oauth-protected-resource,
/.well-known/oauth-authorization-server, /authorize, /token, /register).
Without them, Claude Code refuses to connect. This server serves a public (no
credentials required) OAuth flow so that Claude Code's auth handshake completes
without needing real credentials.
What this server does
Claude Code / Claude Desktop
│ HTTPS (e.g. Tailscale)
▼
obsidian-mcp-brain :3002
├─ OAuth 2.0 discovery endpoints
├─ MCP Streamable HTTP transport (POST /mcp, GET /mcp)
├─ Path deny list (access control)
└─ Vault access (node:fs/promises)
│
▼
Obsidian vault (filesystem)
Prerequisites
- Node.js 18+
- A way to expose the server over HTTPS to your remote client — Tailscale Serve is recommended, because it also keeps the server on an overlay network, but any HTTPS reverse proxy on a private network works
- Optional: The
query-graphtool (which queries a vault using a natural-language knowledge graph) requires thegraphifyCLI to be installed and onPATH, and a knowledge graph to be pre-built for that vault (rungraphify --obsidianagainst it once, before calling the tool).query-graphis always listed, but calling it against a vault with no graph returns a clear error rather than an answer.
Installation
Install dependencies and build the packages:
npm install
The obsidian-mcp-brain package can then be run directly from the workspace, or
you can copy the built files to wherever you want to run them from. Then set it
up as a persistent service.
systemd (Linux)
First, create your config file — see Configuration below for
its full shape. By default the server reads ~/.config/obsidian-mcp.json, so no
extra environment variable is needed unless you want the config somewhere else.
Then create ~/.config/systemd/user/obsidian-mcp.service:
[Unit]
Description=Obsidian MCP Server
After=network.target
[Service]
ExecStart=node /path/to/obsidian-mcp-brain/obsidian-mcp-brain/obsidian-mcp-brain.mjs
Restart=on-failure
RestartSec=5
[Install]
WantedBy=default.target
(If your config file lives somewhere other than ~/.config/obsidian-mcp.json,
add Environment=CONFIG_PATH=/path/to/your/config.json under [Service].)
Then enable and start it:
systemctl --user daemon-reload
systemctl --user enable --now obsidian-mcp.service
macOS (launchd)
Create ~/Library/LaunchAgents/com.obsidian-mcp-brain.plist:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.obsidian-mcp-brain</string>
<key>ProgramArguments</key>
<array>
<string>/usr/local/bin/node</string>
<string>/path/to/obsidian-mcp-brain/obsidian-mcp-brain/obsidian-mcp-brain.mjs</string>
</array>
<key>EnvironmentVariables</key>
<dict>
<key>CONFIG_PATH</key>
<string>/path/to/your/obsidian-mcp.json</string>
</dict>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
<key>StandardOutPath</key>
<string>/tmp/obsidian-mcp-brain.log</string>
<key>StandardErrorPath</key>
<string>/tmp/obsidian-mcp-brain.log</string>
</dict>
</plist>
Load it:
launchctl load ~/Library/LaunchAgents/com.obsidian-mcp-brain.plist
To restart after a config change:
launchctl unload ~/Library/LaunchAgents/com.obsidian-mcp-brain.plist
launchctl load ~/Library/LaunchAgents/com.obsidian-mcp-brain.plist
Docker
Instead of installing Node.js, you can run the server from the container image
published to the GitHub Container Registry for linux/amd64 and linux/arm64.
It is configured with the same JSON file as every other install; only two things
differ from a native run:
listenHostmust be"0.0.0.0", so the port you publish can reach the server inside the container (the default,127.0.0.1, is unreachable from outside it).- Vault paths in the config are paths inside the container, so mount each
vault there (below, at
/vaults/knowledge).
{
"listenHost": "0.0.0.0",
"mcpBaseUrl": "https://your-hostname:4001",
"vaults": {
"knowledge": { "path": "/vaults/knowledge" }
}
}
docker run -d --name obsidian-mcp-brain --restart unless-stopped \
--user "$(id -u):$(id -g)" \
-v /path/to/your/obsidian/vault:/vaults/knowledge \
-v /path/to/obsidian-mcp.json:/config/obsidian-mcp.json:ro \
-p 127.0.0.1:3002:3002 \
ghcr.io/a1ecbr0wn/obsidian-mcp-brain
The port is published on the host's loopback only, so put HTTPS in front of it
the same way as for a native install (the Tailscale Serve command below works
unchanged). --user makes the container write to your vault as you; without it
the container runs as an unprivileged user with UID 1000. If you change
listenPort, publish that port instead of 3002.
The image does not include graphify, so query-graph always returns an error
in a container ("graphify command not found on PATH" once the vault has a
graph); use a native install if you need that tool. Everything else works. See
the Docker install guide
for a docker compose example and hardening options.
Tailscale Serve (HTTPS tunnel)
Point Tailscale at the server's local port:
sudo tailscale serve --bg --https 4001 http://localhost:3002
This exposes the server at https://<your-tailscale-hostname>:4001.
There are step-by-step pages, with the settings this server needs and why, for Tailscale Serve, nginx and Caddy.
Configuration
All configuration lives in one JSON file — no environment variables are read
except CONFIG_PATH, which says where to find it.
- Location:
CONFIG_PATHenv var if set, otherwise~/.config/obsidian-mcp.json. - The server serves one or more named vaults from a single process; a client
picks which one a call applies to via the
vaultargument every tool exceptlist-vaultsandget-setup-statustakes.
Shape
{
"listenPort": 3002,
"listenHost": "127.0.0.1",
"mcpBaseUrl": "https://your-hostname:4001",
"denyPaths": ["private"],
"denyBinaryPaths": ["**/*.pdf"],
"graphifyQueryTimeoutMs": 60000,
"fetchMaxBytes": 10485760,
"fetchTimeoutMs": 30000,
"readMaxBytes": 10485760,
"readNoteMaxChars": 50000,
"uploadMaxBytes": 52428800,
"uploadTtlSeconds": 300,
"uploadStallSeconds": 30,
"uploadNetworkPrefix": 0,
"logFile": "/var/log/obsidian-mcp.log",
"sessionTimeoutSeconds": 1800,
"trashRetentionDays": 30,
"uploadTempDir": "/var/tmp/obsidian-mcp-uploads",
"trustedProxies": ["127.0.0.1", "::1"],
"vaults": {
"knowledge": {
"path": "/path/to/your/obsidian/vault"
},
"work": {
"path": "/path/to/another/vault",
"denyPaths": ["confidential"],
"denyBinaryPaths": ["scans"]
}
}
}
Each vault entry can also set its own denyPaths and denyBinaryPaths, which
are added on top of the global list for that vault only (see below).
Path deny list (denyPaths)
denyPaths lets you prevent the MCP client from reading or writing specific
folders in a vault. Paths are relative to the vault root and prefix-matched, so
denying people blocks people/, people/alice/notes.md, and so on.
The top-level denyPaths applies to every configured vault. A vault's own
denyPaths (if any) is added on top of that global list, restricting that vault
further without affecting any other vault:
{
"denyPaths": ["private"],
"vaults": {
"knowledge": { "path": "/path/to/your/obsidian/vault" },
"work": { "path": "/path/to/another/vault", "denyPaths": ["confidential", "drafts"] }
}
}
Here, both vaults block private; work additionally blocks confidential and
drafts, while knowledge is unaffected by that extra restriction.
The deny list is enforced before the tool reads or writes any file. Blocked
requests receive a structured MCP error (isError: true) rather than a
transport-level failure, so the client can report the reason clearly.
Affected tools: read-note, create-note, edit-note, delete-note,
move-note (source and destination), create-file, fetch-file,
read-file, delete-file, move-file (source and
destination), find-backlinks, resolve-wikilink, add-tags, remove-tags,
set-frontmatter-field, remove-frontmatter-field, create-folder,
search-vault (when a path scope is given).
list-notes, list-files, list-tags, search-tags, new-notes,
changed-notes, and rename-tag are equally protected, just via a different
mechanism: instead of a single check up front, they filter out denied files
individually as they walk the vault.
list-vaults doesn't touch vault files at all — it just returns configured
vault names — so it's the only tool genuinely unaffected by the deny list.
query-graph takes a free-text question rather than a vault path, so it isn't
subject to the deny list either. Warning: if the graphify graph was built
over denied folders, the tool can reveal their content; build the graph from a
vault without denied folders, or leave the tool unused.
Write preconditions (expectedMtime)
Every tool that writes to an existing file accepts an optional expectedMtime:
the ISO 8601 last-modified timestamp you previously got back from read-note or
list-notes. Pass it back on a later write and the server refuses the write —
with no changes made — if the file's mtime has moved since, telling you both the
timestamp you expected and its actual current one so you know to re-read and
retry.
This is a compare-and-swap check, not a lock: it's meant to catch "I read this note a while ago, and something else touched it since," not to serialize concurrent writers. It's optional so existing callers are unaffected, but a client following a read-then-write pattern (read a note, decide what to change, write it back) should always pass it — otherwise an edit made by something else in between is silently overwritten.
read-note → note content + Last-Modified: 2026-09-20T10:15:00.000Z
...decide what to change...
edit-note → { operation: "replace", content: "...", expectedMtime: "2026-09-20T10:15:00.000Z" }
Applies to: edit-note (all operations), delete-note, move-note (checked
against the source file), set-frontmatter-field, remove-frontmatter-field,
move-file and delete-file (checked against the source file).
For add-tags/remove-tags, which operate on a files[] array,
expectedMtime is instead an object mapping each vault-relative path to its
expected timestamp; every listed file's precondition is checked before any file
in the batch is written, so the batch either applies wholly or not at all.
Not applicable to create-note, create-file, or fetch-file,
which already fail if the destination exists, nor to rename-tag, which sweeps
the whole vault rather than targeting one file.
edit-note operations
Beyond append, prepend, and replace, edit-note supports targeted,
section-scoped edits so a large note doesn't need to be resent in full for a
small change:
replace-section— replaces the content under a heading (matched by exact text, without the#characters), leaving the heading line itself in place. Everything under the heading goes, sub-sections included, up to the next heading of the same or a higher level.contentis the new body only, so don't repeat the heading; to rename a heading, usereplace. Acontentof only white space clears the section.delete-section— removes a heading and everything under it, heading line and sub-sections included. When the section is the last thing in the note, the blank lines before it go too and the note keeps its final newline. A line of only#characters is not a heading, so a section runs through it and both operations remove it with the rest.toggle-checkbox— flips (or explicitly sets) a- [ ]/- [x]line, matched by its exact text.
If a heading or taskText match isn't unique in the note, the call fails with
a list of every match (line number, and heading level where relevant); pass the
1-based occurrence from that list on a follow-up call to disambiguate.
Tools that change a note write the text they make in the note's own line ending
(CRLF or LF, taken from its first line break) and leave the other lines as they
were: append, prepend, replace-section, the tag and frontmatter tools, and
the tag line add-tags puts in the body. create-note, replace and
create-file write what they are sent.
Listing files
list-files lists every kind of file, so an agent can find the scripts, PDFs and
images that read-file, replace-file, move-file and the other file tools
work on. Give it an extension to narrow the list. list-notes lists the
notes (.md files) only and takes no extension: a call that passes one, with
any value, is refused with a message that names list-files.
- Give the extension without a dot:
pdf, not.pdfor*.pdf.*matches any run of characters and?one character. - Case is ignored, so
jpgfindsIMG_0001.JPG. - The extension is the text after the last dot, so
gzfindsbackup.tar.gz. A file with no extension is listed only by*. - Each line is the vault-relative path, which includes the extension, then the
last-modified timestamp;
list-filesadds the size in bytes as a third field (list-noteslines have the path and timestamp only). Fields are separated by tabs.pathscopes the listing to a folder. Neither tool reads a file's content, so neither gives a hash:read-filedoes. - Hidden files and folders (names starting with a dot, such as
.obsidianand.trash) and symbolic links are not listed. Ifpathis itself a hidden folder, only its notes are listed, whateverextensionsays (list-files). extension(list-filesonly) is at most 64 characters and has no spaces at either end.denyPathsapplies.denyBinaryPathsdoes not hide names: it only stops a file's content being read.
Reading part of a note
read-note refuses a note longer than readNoteMaxChars (50,000 characters by
default) instead of returning something the client then rejects. Call it with
outline: true to list the note's headings (line range, size of the section,
which occurrence of that text it is), then with heading (and occurrence, if
it repeats) to read one section: the heading line and everything under it up to
the next heading of the same or a higher level, sub-sections included, without
the blank lines that follow it. A heading from the outline can be passed
straight to edit-note. See the tools
page.
fetch-file
create-file requires the client to send the file as base64 — expensive
through an LLM client, since base64 is read in and written out again on top of
its already-larger-than-binary size. fetch-file instead has the server
download a URL itself and write the result, so the client only ever sends a URL
string.
fetch-file → { filename: "photo.jpg", folder: "attachments", url: "https://example.com/photo.jpg" }
Because the server performs the request itself, a caller-supplied URL is
effectively asking this host to make an arbitrary outbound call — on any
deployment, this host may be able to reach private network services that
shouldn't be exposed to a remote MCP client. fetch-file treats this as
its primary risk:
- Only
http/httpsURLs are accepted. - The hostname is resolved and the request is refused if any resolved address is loopback, link-local, RFC1918, CGNAT, IETF protocol/benchmarking/documentation, multicast, reserved (all IPv4); loopback, link-local, site-local, unique-local, Teredo, discard-only, 6to4, NAT64, IPv4-compatible, and multicast (all IPv6); or IPv4-mapped IPv6 (automatically unwrapped to check the embedded IPv4).
- Every redirect hop is re-validated the same way — not just the initial URL — since a public hostname can redirect to a private address.
- The response body is size-checked while streaming, so an oversized response is aborted mid-transfer rather than after it's already been downloaded.
- A hard timeout (
fetchTimeoutMs, overridable per call) aborts a slow or hanging response. - The destination path is deny-path- and collision-checked before any network call, so a denied or already-occupied path never causes an outbound request.
maxBytes and timeoutMs can be overridden per call; otherwise they default to
the config file's fetchMaxBytes/fetchTimeoutMs.
read-file
read-file returns an existing binary file (a PDF, say) to the client as
two content blocks: a text line
(attachments/report.pdf (48213 bytes, application/pdf)) and an MCP embedded
resource carrying the base64 bytes and the file's mimeType.
read-file → { filename: "report.pdf", folder: "attachments" }
The server never parses a PDF, extracts text, renders pages or runs OCR, so it
needs no extra software; the client does the reading, which is why one tool
covers a PDF with a real text layer and a scanned PDF whose text is only in
images. In Claude Code the bytes are saved to a file and the agent opens that
path with its Read tool (checked end to end for both kinds of PDF). Claude
Desktop chat shows images but silently drops a PDF and rejects other types, so
use a code mode chat there; other clients have not been checked. The
skills/read-file skill makes an agent check this first. A .md
note is returned too, as its raw bytes (text/markdown), but read-note is
better for a note. A file larger than
readMaxBytes (10 MiB by default) is refused before it is read.
upload-file
create-file needs the model to write the whole file out as base64,
which is impractical beyond a few tens of kilobytes (an 810 KB PDF is about a
million tokens) and gives no way to check the file arrived intact.
upload-file reserves a one-time URL that the agent sends the file to
directly, so its bytes never pass through the model:
upload-file → { filename: "report.pdf", folder: "attachments", size: 810490, sha256: "<64 hex characters>" }
curl --fail-with-body -sS -T report.pdf "https://server:4001/up/<random token>"
The size and SHA-256 come from a tool (wc -c, sha256sum), never from the
model. The URL is the config's mcpBaseUrl (scheme, host and port, no path or
trailing slash), then /up/, then a random token. The server checks the
received bytes against the declared size and hash and places the file only if
both match; otherwise it discards it.
- The URL works for one request only: the first request that names it uses it up, whatever that request is. If anything goes wrong, request a new URL.
- It works from any address by default, and lasts
uploadTtlSeconds(300 by default).uploadNetworkPrefixcan require the same address as the call that reserved it (32) or the same network (16 to 31). The server logs the reason for every refusal. - The destination must not exist, and
denyPathsis checked when the URL is reserved and again when the file is placed. - A note (
.md) can be uploaded too, for a large or generated file that is already on disk. It is placed only if it is valid UTF-8; otherwise the upload is refused with400and nothing is written. Usecreate-notefor text the agent has written itself. - At most
uploadMaxBytes(50 MiB by default), 16 pending URLs and 4 uploads at once. - The file is held in
uploadTempDiruntil verified, never inside a vault. At startup the server refuses a staging folder owned by another user, and tightens one that others can access to owner-only. - Symbolic links in the destination are followed before the deny rules are checked, so an upload can't be used to write outside the vault.
- Behind a reverse proxy, set
trustedProxiesto the proxy's address (for example["127.0.0.1", "::1"]) so the address in the log and in theuploadNetworkPrefixcheck is the real caller's, fromX-Forwarded-For. The header is ignored from any other connection. Without it every caller looks like the proxy, and a hint is logged once.
It needs an agent with a shell on a machine that can reach the server's address,
such as Claude Code. The token limits one upload to one destination but is not
authentication: anyone who can call the server's tools can reserve a URL, and
the URL is visible in the conversation, which is why it is short-lived and
single-use. curl exits with code 22 and prints the server's explanation on any
failure: 403 an Origin header (or a wrong address, when
uploadNetworkPrefix is set), 404 unknown, used or expired URL, 409
destination appeared meanwhile, 411 no Content-Length, 413 or 400 size
mismatch, 422 hash mismatch, 503 too many uploads at once.
replace-file
upload-file never overwrites. replace-file replaces a file that
is already in the vault, with the same one-time URL and curl upload, but only
if the file is still the version the caller read. Both expectedSha256 and
expectedMtime are required, and they come from the sha256: and
Last-Modified: lines of read-file:
replace-file → { filename: "report.pdf", folder: "attachments", size: 912340, sha256: "<new file>", expectedSha256: "<file in the vault>", expectedMtime: "2026-05-01T09:30:00.123Z" }
- The two values are checked when the URL is reserved and again when the file
arrives, so a file that someone changed in between is never overwritten
(
409). - The old version moves to
.trash; the new file is renamed over the destination, so a complete file is always there. - The file must exist, a new file identical to it is refused, and a file under
denyBinaryPathscannot be replaced. - Everything else, including the limits and the address rules, is as for
upload-file. Thereplace-fileskill does the hashing and thecurlcall.
Binary read protection (denyBinaryPaths)
denyPaths removes a path from every tool; denyBinaryPaths only stops the
agent reading a binary file's contents with read-file. The files stay
visible: the agent can still create, move and delete them, and find-backlinks
still sees them. Each entry is a vault-relative path with optional wildcards:
*matches any run of characters within one path segment,?matches one character within a segment, and**as a whole segment matches any number of segments, including none. Everything else is literal; matching is case-sensitive.- An entry protects a path it matches and everything under a folder it matches.
scansprotectsscans/a.pdfandscans/2026/b.pdfbut notscans-old/a.pdf;**/*.pdfprotects every PDF in the vault;*/scansprotectsa/scans/x.pdf.
As with denyPaths, the top-level list applies to every vault and a vault's own
list is added on top. A refused read returns
Reading is restricted for '<path>'. move-file (and move-note, for a
non-.md file) refuses to move a protected file to a path that is not protected
(judged by the destination's final path, so renaming scan.pdf to scan.txt
under **/*.pdf is refused too); moving within the protected area, or moving an
unprotected file in, is allowed.
To keep the protection from being sidestepped, .trash is protected
automatically whenever any denyBinaryPaths are set (a non-permanent delete
moves a file there), read-note refuses a protected non-.md file, and
read-file resolves symbolic links and checks the real location,
refusing a link that points outside the vault. The other tools judge a path as
given and don't resolve symlinks, so don't put symlinks to sensitive files in a
served vault.
Connecting Claude Code
Add the server to your Claude Code config (~/.claude.json or via
claude mcp add):
{
"mcpServers": {
"obsidian": {
"type": "http",
"url": "https://your-hostname:4001/mcp"
}
}
}
Claude Code will prompt you to authenticate the first time — click through the OAuth flow (it uses a public/no-credentials token, so no real account is needed).
Connecting Claude Desktop
Unlike Claude Code, Claude Desktop doesn't speak the MCP Streamable HTTP
transport directly — it only launches local stdio subprocesses. To reach a
remote server like this one, it needs a small relay in between that translates
its stdio JSON-RPC traffic into HTTP+SSE calls against the server's /mcp
endpoint. Two options:
mcp-remotevianpx(below) — the quickest option, no install or local files needed, good for a plain no-credentials setup like this server's public OAuth flow.- This repo's own
mcp-shim— a zero-dependency local script, worth using instead if you need a bearer token, a custom request timeout, or want to avoid annpxdownload on every Claude Desktop launch.
Edit claude_desktop_config.json (find it via Claude Desktop → Settings →
Developer → Edit Config) and add an entry under mcpServers, replacing the
URL below with your server's actual address:
{
"mcpServers": {
"obsidian": {
"command": "npx",
"args": [
"mcp-remote@latest",
"https://your-hostname:4001/mcp"
]
}
}
}
Restart Claude Desktop after saving.
Agent skills and agents
The repository includes three agent skills, read-file,
upload-file and replace-file, in
skills/.
The tools work without them; the skills tell an agent how to use the tools well
and what to say when a client can't show a file. The server tells a connected
agent about them: the get-setup-status tool lists the skills and agents and
how to install them, and a plugin can carry agents as well as skills.
The tools used to be called *-binary-file; the old names no longer exist, so
reinstall the plugin and rewrite any permission rule for upload-binary-file.sh
as one for upload-file.sh.
Available Tools
How the obsidian-mcp-brain works
The server implements the MCP Streamable HTTP transport
and answers protocol versions 2025-06-18, 2025-03-26 and 2024-11-05: initialize
returns the version the client asks for if the server speaks it, and the newest
otherwise, and a request whose MCP-Protocol-Version header names another
version is refused with 400. A request carries one JSON-RPC message; a batch
(an array) is refused with 400.
-
POST /mcp— receives JSON-RPC requests from the client.initializecreates a session and returns server capabilities. Notifications return 202 code. All other requests are dispatched to the corresponding tool handler and the response is returned as an inline SSE event. -
GET /mcp— keeps a long-lived SSE stream open per session for server-to-client notifications. The server declares nolistChangedcapability and sends none today; the stream is kept so that clients which open it (and shims that reconnect it) work. -
DELETE /mcp— ends the session named byMcp-Session-Idand its stream, and answers204. An unknown or missing session answers404. A session that has been idle forsessionTimeoutSecondsis removed without it.
All MCP tools are implemented natively in the server and operate directly on
vault files via node:fs/promises. Each tool resolves its vault argument
against the configured vaults and validates access control (that vault's
effective deny list) before executing.
resources/list and prompts/list return empty results — the server does not
expose vault files as resources or prompts, only as tools.
Security
The OAuth flow is intentionally public — there are no real credentials. Access
control relies on the network layer, so run the server on a private network, or
an overlay network such as Tailscale, not on the public internet. The config
file's denyPaths feature provides coarse-grained control over which parts of a
vault the MCP client can touch, but it is not a substitute for network-level
access control.
Releasing
obsidian-mcp-brain publishes to npm via two tag-triggered GitHub Actions
workflows (mcp-shim is not published separately). To cut a release:
- Go to the repo's Actions tab → Tag a release → Run workflow.
- Leave the
versioninput blank to auto-bump based on conventional-commit messages since the last tag (via git-cliff), or type an explicit version (v1.2.0or1.2.0). - That workflow bumps both
package.jsonfiles (root andobsidian-mcp-brain/package.json) and.claude-plugin/plugin.json, commits and tags (both GPG-signed), pushes, and triggers thepublishworkflow at the new tag. publishruns the full test suite, publishes to npm with provenance, packages the agent skills (scripts/package-skills.sh), and creates a GitHub Release with a changelog generated from the commits since the last tag and the.skilland.pluginfiles attached.
No manual npm version or npm publish step is needed — the workflow does
both.
Authentication: publishing uses npm Trusted
Publishing (OIDC) — GitHub Actions
authenticates directly via its own OIDC identity, with no NPM_TOKEN or other
long-lived secret involved. This is configured as a Trusted Publisher on the
package's npmjs.com settings (org a1ecbr0wn, repo obsidian-mcp-brain,
workflow filename publish.yml), with the "Allowed actions" setting set to
permit direct publish (Trusted Publisher configs default to stage-only, which
would otherwise leave every release pending manual approval on npmjs.com).
Before a package's very first-ever publish: npm requires a live, interactive
one-time password for the first publish of any brand-new package name,
regardless of token type or OIDC — this is an anti-squatting safeguard with no
CI workaround. The first release of a new package must be published once
manually from a local machine
(npm publish --workspace=obsidian-mcp-brain --access public, without
--provenance, which needs GitHub Actions' OIDC context and fails locally).
Every release after that can go through tag.yml/publish.yml unattended.
mcp-shim
A lightweight shim that connects Claude Desktop to a remote MCP server over HTTPS (Streamable HTTP transport). It relays Claude Desktop's stdio JSON-RPC protocol to the remote server's HTTP+SSE interface.
Requirements
Node.js 18 or later (no npm install needed — no dependencies).
Configuration for Claude Desktop
Edit claude_desktop_config.json (find it via Claude Desktop → Settings →
Developer → Edit Config) and add an entry under mcpServers:
{
"mcpServers": {
"my-server": {
"command": "node",
"args": ["/absolute/path/to/mcp-shim.mjs"],
"env": {
"MCP_URL": "https://my-server.example.com/mcp"
}
}
}
}
Restart Claude Desktop after saving.
Environment Variables
Examples
Server with no auth
{
"mcpServers": {
"obsidian": {
"command": "node",
"args": ["/Users/alice/obsidian-mcp-brain/mcp-shim/mcp-shim.mjs"],
"env": {
"MCP_URL": "https://obsidian-mcp.example.com/mcp"
}
}
}
}
Server with a bearer token
{
"mcpServers": {
"my-api": {
"command": "node",
"args": ["/Users/alice/obsidian-mcp-brain/mcp-shim/mcp-shim.mjs"],
"env": {
"MCP_URL": "https://api.example.com/mcp",
"MCP_TOKEN": "sk-..."
}
}
}
}
Custom timeout
{
"mcpServers": {
"slow-server": {
"command": "node",
"args": ["/Users/alice/obsidian-mcp-brain/mcp-shim/mcp-shim.mjs"],
"env": {
"MCP_URL": "https://slow.example.com/mcp",
"MCP_TIMEOUT": "30000"
}
}
}
}
How the mcp-shim works
- Claude Desktop launches the shim as a subprocess and communicates over stdio.
- The shim forwards each JSON-RPC message from Claude Desktop as an HTTPS POST
to
MCP_URL. - On the first response, it opens a persistent SSE GET stream on the same URL to receive server-initiated messages.
- The SSE stream reconnects automatically with exponential backoff if it drops.
- When Claude Desktop exits, the shim sends an HTTP DELETE to cleanly end the session.