@nathanld/obsidian-server 0.0.1 → 0.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (4) hide show
  1. package/README.md +135 -30
  2. package/cli.js +548 -36
  3. package/launcher.cjs +182 -0
  4. package/package.json +14 -6
package/README.md CHANGED
@@ -4,30 +4,54 @@ Point it at any [Obsidian](https://obsidian.md) vault and get, out of the box:
4
4
 
5
5
  - an **MCP server** (Streamable HTTP, `/mcp`) exposing your vault's commands as tools to Claude and other MCP clients
6
6
  - a **REST API** (`/api/*`) over the same commands
7
- - **Bearer-token auth** (via [better-auth](https://www.better-auth.com/)'s API key plugin) protecting everything except a bare `/health` check
7
+ - **two Bearer-token auth paths**, both via [better-auth](https://www.better-auth.com/) and both protecting everything except a bare `/health` check:
8
+ - a long-lived **API key** (better-auth's API key plugin) — unscoped, owner-minted, the simplest path for a client you trust with everything `--allow` exposes
9
+ - full **OAuth 2.1** (MCP Authorization spec, targeting spec revision **2026-07-28**, the locked release candidate as of 2026-05-21, with back-compat for the **2025-11-25** revision) — dynamic client registration or Client ID Metadata Documents, PKCE, scoped access tokens, for clients like claude.ai that expect to discover and authorize themselves
8
10
 
9
11
  It works by driving the official Obsidian CLI companion binary under the hood, then classifying and gating every command by how safe it is to expose to an LLM.
10
12
 
11
13
  ## Requirements
12
14
 
13
- - **Bun >= 1.2** the server is Bun-only (it uses `bun:sqlite`, `Bun.serve`, and `Bun.spawn` directly; it will not run under Node).
15
+ - **Node >= 18** (to run the published package via `npx`/`npm`), **or Bun >= 1.2** (to run it via `bunx`/`bun add`, or to build/run from source - the server itself is written against Bun APIs like `bun:sqlite`, `Bun.serve`, and `Bun.spawn`). You do not need both: see [Install / run](#install--run) below.
14
16
  - The **Obsidian desktop app** must be running, with its official CLI companion installed and on `PATH` (or pass `--binary <path>` to point at it explicitly). If the CLI can't be reached, `serve` exits with an error explaining how to fix it.
15
17
 
16
18
  ## Install / run
17
19
 
18
- Run it without installing anything:
20
+ **No Bun required.** `@nathanld/obsidian-server` ships a native, self-contained binary for every supported platform (built with `bun build --compile`, the same approach [esbuild](https://esbuild.github.io/), [Biome](https://biomejs.dev/), and [Turborepo](https://turbo.build/) use), published as `optionalDependencies`. `npm`/`npx` on a supported platform installs and runs the matching binary directly - Node just spawns it, and Bun is never invoked:
21
+
22
+ ```sh
23
+ npx @nathanld/obsidian-server serve
24
+ ```
25
+
26
+ Or with Bun, if you already have it:
19
27
 
20
28
  ```sh
21
29
  bunx @nathanld/obsidian-server serve
22
30
  ```
23
31
 
24
- Or install it globally:
32
+ Or install it globally with either:
25
33
 
26
34
  ```sh
35
+ npm install -g @nathanld/obsidian-server
36
+ obsidian-server serve
37
+
38
+ # or
27
39
  bun add -g @nathanld/obsidian-server
28
40
  obsidian-server serve
29
41
  ```
30
42
 
43
+ ### Platform support
44
+
45
+ | OS | Arch | Native binary |
46
+ | ------- | ---------- | ------------- |
47
+ | Linux | x64, arm64 | yes |
48
+ | macOS | x64, arm64 | yes |
49
+ | Windows | x64 | yes |
50
+
51
+ On any other platform/arch, or if the matching binary was skipped at install time (e.g. `npm install --omit=optional`), the CLI's launcher falls back to running the bundled `cli.js` under `bun` if `bun` is on `PATH`; if neither a native binary nor `bun` is available, it exits with an error explaining how to fix it (install without skipping optional dependencies, or install Bun).
52
+
53
+ Either way, the **Obsidian desktop app + its CLI companion** (see [Requirements](#requirements)) is a runtime requirement regardless of how you install `@nathanld/obsidian-server` - it's what actually drives your vault.
54
+
31
55
  ## First run: the token flow
32
56
 
33
57
  The first time `serve` starts, it bootstraps a single local "owner" account and mints an API key automatically. That key is printed **exactly once**, in the startup banner:
@@ -71,10 +95,10 @@ claude mcp add --transport http obsidian http://127.0.0.1:3000/mcp --header "Aut
71
95
  obsidian-server <command> [flags]
72
96
  ```
73
97
 
74
- | Flag | Global |
75
- |---|---|
98
+ | Flag | Global |
99
+ | ----------------- | --------------------------- |
76
100
  | `--version`, `-v` | Print the version and exit. |
77
- | `--help`, `-h` | Print usage and exit. |
101
+ | `--help`, `-h` | Print usage and exit. |
78
102
 
79
103
  ### `serve`
80
104
 
@@ -82,18 +106,22 @@ obsidian-server <command> [flags]
82
106
  obsidian-server serve [--vault <name>] [--port 3000] [--host 127.0.0.1]
83
107
  [--allow destructive] [--allow app-control]
84
108
  [--data-dir <path>] [--binary <path>] [--timeout-ms <n>]
109
+ [--public-url https://host[:port]]
110
+ [--dangerously-allow-insecure-cimd-hosts]
85
111
  ```
86
112
 
87
- | Flag | Meaning |
88
- |---|---|
89
- | `--vault <name>` | Target a specific vault by name. Omit to use whatever vault is currently active in the running Obsidian app. |
90
- | `--port <n>` | Port to bind (default `3000`). |
91
- | `--host <host>` | Host/interface to bind (default `127.0.0.1`). See [Security](#security) before binding beyond localhost. |
92
- | `--allow destructive` | Also expose commands classified `destructive` (repeatable/combinable with `--allow app-control`). |
93
- | `--allow app-control` | Also expose commands classified `app-control`. |
94
- | `--data-dir <path>` | Override where the auth SQLite database lives. Defaults to `$XDG_DATA_HOME/obsidian-server`, or `~/.local/share/obsidian-server`. |
95
- | `--binary <path>` | Explicit path to the `obsidian` CLI binary, if it isn't on `PATH`. |
96
- | `--timeout-ms <n>` | Timeout for each underlying `obsidian` CLI invocation. |
113
+ | Flag | Meaning |
114
+ | ----------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
115
+ | `--vault <name>` | Target a specific vault by name. Omit to use whatever vault is currently active in the running Obsidian app. |
116
+ | `--port <n>` | Port to bind (default `3000`). |
117
+ | `--host <host>` | Host/interface to bind (default `127.0.0.1`). See [Security](#security) before binding beyond localhost. |
118
+ | `--allow destructive` | Also expose commands classified `destructive` (repeatable/combinable with `--allow app-control`). |
119
+ | `--allow app-control` | Also expose commands classified `app-control`. |
120
+ | `--data-dir <path>` | Override where the auth SQLite database lives. Defaults to `$XDG_DATA_HOME/obsidian-server`, or `~/.local/share/obsidian-server`. |
121
+ | `--binary <path>` | Explicit path to the `obsidian` CLI binary, if it isn't on `PATH`. |
122
+ | `--timeout-ms <n>` | Timeout for each underlying `obsidian` CLI invocation. |
123
+ | `--public-url https://host[:port]` | The canonical OAuth issuer / MCP resource URL — the URL a remote client actually reaches this server through. Defaults to `http://<host>:<port>`, which only works for local clients. See [OAuth 2.1](#oauth-21-mcp-clients). |
124
+ | `--dangerously-allow-insecure-cimd-hosts` | Allow Client ID Metadata Document URLs on `http:`/loopback/private hosts. Local testing only — never set this on a server reachable from anything but the machine running it. |
97
125
 
98
126
  ### `token`
99
127
 
@@ -109,13 +137,13 @@ obsidian-server token revoke --id <id> [--data-dir <path>]
109
137
 
110
138
  ## REST endpoints
111
139
 
112
- | Method | Path | Auth | Description |
113
- |---|---|---|---|
114
- | `GET` | `/health` | open | `{ ok, obsidian, vault }` — whether the server is up and whether the Obsidian CLI is currently reachable. |
115
- | `GET` | `/api/commands` | Bearer | List every exposed command (name, description, classification, options), given the current `--allow` flags. |
140
+ | Method | Path | Auth | Description |
141
+ | ------ | --------------------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
142
+ | `GET` | `/health` | open | `{ ok, obsidian, vault }` — whether the server is up and whether the Obsidian CLI is currently reachable. |
143
+ | `GET` | `/api/commands` | Bearer | List every exposed command (name, description, classification, options), given the current `--allow` flags. |
116
144
  | `POST` | `/api/commands/:name` | Bearer | Run one command. JSON body is a flat object of option values (string/number/boolean); empty body is fine for commands that take no options. Returns `{ ok: true, stdout }`, or `{ ok: false, error }` with `404` (unknown command or gated-off), `400` (bad body/options), `504` (the CLI call timed out), or `502` (the CLI call itself failed). |
117
145
 
118
- Every route under `/api/*` (and `/mcp`, below) requires `Authorization: Bearer <token>`; a missing, malformed, unknown, expired, or disabled key all get an identical `401 { "error": "unauthorized" }` see [Security](#security).
146
+ Every route under `/api/*` (and `/mcp`, below) requires `Authorization: Bearer <token>` either an API key or an OAuth access token (see [OAuth 2.1](#oauth-21-mcp-clients)). A missing or invalid credential of either kind gets a spec-shaped `401` with a `WWW-Authenticate: Bearer ...` challenge (see [Security](#security)); an OAuth token missing the scope a command needs gets `403 insufficient_scope` instead (API keys are unscoped and never hit this).
119
147
 
120
148
  ## MCP
121
149
 
@@ -124,16 +152,32 @@ Every route under `/api/*` (and `/mcp`, below) requires `Authorization: Bearer <
124
152
  - **`obsidian_<command>`** — one tool per exposed command, named by replacing `:` with `_` and prefixing `obsidian_` (e.g. the `daily:append` command becomes the `obsidian_daily_append` tool). Commands that aren't currently allowed (see gating, below) are never registered as tools at all — they don't exist, rather than existing and refusing.
125
153
  - **`server_commands`** — lists every currently-exposed command along with its MCP tool name, description, classification, and options, as JSON. Useful for a client to discover what's available without guessing tool names.
126
154
 
155
+ Calling a tool with an OAuth access token that lacks the scope its command's classification requires (see below) doesn't refuse the call at the transport level — it returns a normal tool result with `isError: true` naming the missing scope, since JSON-RPC `tools/call` has no HTTP status code to carry instead.
156
+
157
+ ## OAuth 2.1 (MCP clients)
158
+
159
+ Alongside API keys, this server is a **co-located OAuth 2.1 Authorization Server** for its own `/mcp` endpoint, implementing the MCP Authorization spec — targeting revision **2026-07-28** (the locked release candidate as of 2026-05-21), with back-compat for the **2025-11-25** revision's clients. This is what lets clients like claude.ai add the server themselves, with no manually-minted token: they discover the server's Authorization Server, register themselves (dynamically, or via a Client ID Metadata Document), and run the standard authorization-code-with-PKCE flow.
160
+
161
+ - **Discovery**: `/.well-known/oauth-protected-resource/mcp` and `/.well-known/oauth-protected-resource` (RFC 9728) advertise this server's `resource` (the exact `/mcp` URL) and its Authorization Server; `/.well-known/oauth-authorization-server` (RFC 8414) advertises the OAuth endpoints, `S256`-only PKCE, and (RFC 9207 / RC 2026-07-28) `authorization_response_iss_parameter_supported`.
162
+ - **Client registration**: `POST /api/auth/mcp/register` (RFC 7591 Dynamic Client Registration — no auth required to register, by design) **or** a Client ID Metadata Document (RC 2026-07-28): a `client_id` that's itself an HTTPS URL pointing at a small JSON document (`{client_id, client_name, redirect_uris}`). Both are supported side by side; `client_id_metadata_document_supported: true` is advertised in the Authorization Server metadata alongside `"none"` in `token_endpoint_auth_methods_supported`, which is what claude.ai checks for before preferring CIMD.
163
+ - **Sign-in**: this is a single-owner server with no password login. The authorize flow redirects an unauthenticated browser to `/login`, which sends a one-time magic sign-in link — **printed to the terminal `obsidian-server` is running in**, not emailed anywhere (there's no email transport in a self-hosted, single-owner deployment).
164
+ - **PKCE is required** (`S256` only — `plain` is rejected) on every authorization-code exchange.
165
+ - **Scopes**: one scope per command classification — `vault:read`, `vault:write`, `vault:destructive`, `vault:app-control` — mapping 1:1 onto the `--allow` gates above. Only the classifications currently exposed by `--allow` are ever accepted or advertised; requesting a scope for a classification `--allow` doesn't currently expose is refused the same way an unrecognized scope would be.
166
+ - **Refresh tokens**: request `offline_access` alongside another scope to get one back. This server additionally revokes the previous access/refresh token pair on every successful refresh (single-use rotation) — a hardening this server adds on top of the underlying OAuth plugin, which mints a new pair but doesn't invalidate the old one on its own.
167
+ - **`--public-url`** is the issuer and the base of every URL above — it must be the URL a remote client actually reaches this server through (HTTPS, generally, for anything but same-machine clients). Defaults to `http://<host>:<port>`, which only works locally; the startup banner prints a warning when that default is in effect.
168
+
169
+ API keys and OAuth access tokens are accepted interchangeably on every Bearer-protected route — this is additive, not a replacement; existing API-key-based setups keep working unmodified.
170
+
127
171
  ## Safety gating model
128
172
 
129
173
  The Obsidian CLI exposes a **102-command surface**. Every command is hand-classified into one of four tiers (`packages/obsidian-cli`'s `classify.ts`):
130
174
 
131
- | Classification | Exposed by default? | Meaning |
132
- |---|---|---|
133
- | `read` | Yes | Pure queries — no side effects on vault content or the app. |
134
- | `write` | Yes | Mutates vault note content (create/append/prepend a note, set a property, insert a template, toggle a task, ...). |
135
- | `destructive` | No — needs `--allow destructive` | Irreversibly removes or overwrites vault content (delete, remove a property, restore an older version over the current one). |
136
- | `app-control` | No — needs `--allow app-control` | Affects the running app/workspace rather than vault content: opening panes/tabs, plugin/theme/snippet management, sync pause/resume, restart/reload, running an arbitrary registered command by id, and the entire `Developer:` surface (DevTools protocol access, arbitrary `eval`). Treated as the most restricted tier even where an individual verb only reads data, because the blast radius or opacity is high. An unrecognized/future command name also defaults here. |
175
+ | Classification | Exposed by default? | Meaning |
176
+ | -------------- | -------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
177
+ | `read` | Yes | Pure queries — no side effects on vault content or the app. |
178
+ | `write` | Yes | Mutates vault note content (create/append/prepend a note, set a property, insert a template, toggle a task, ...). |
179
+ | `destructive` | No — needs `--allow destructive` | Irreversibly removes or overwrites vault content (delete, remove a property, restore an older version over the current one). |
180
+ | `app-control` | No — needs `--allow app-control` | Affects the running app/workspace rather than vault content: opening panes/tabs, plugin/theme/snippet management, sync pause/resume, restart/reload, running an arbitrary registered command by id, and the entire `Developer:` surface (DevTools protocol access, arbitrary `eval`). Treated as the most restricted tier even where an individual verb only reads data, because the blast radius or opacity is high. An unrecognized/future command name also defaults here. |
137
181
 
138
182
  `read` and `write` are always exposed; `destructive` and `app-control` are opt-in per `serve` invocation via `--allow`, and only take effect for that run — nothing is ever persisted as "allowed" across restarts.
139
183
 
@@ -141,8 +185,69 @@ The Obsidian CLI exposes a **102-command surface**. Every command is hand-classi
141
185
 
142
186
  - **Binds to `127.0.0.1` by default.** Only requests from the same machine can reach it.
143
187
  - **Binding beyond localhost is not fully wired up yet.** Passing `--host 0.0.0.0` (or any non-localhost address) prints a startup warning that DNS-rebinding protection is disabled for that bind, because the underlying MCP transport only auto-enables its Host/Origin allowlist for localhost-class binds and this CLI does not yet expose flags to configure `allowedHosts`/`allowedOrigins` itself. Treat any non-localhost bind as unprotected against DNS rebinding until that's addressed, and prefer an SSH tunnel or reverse proxy you control instead of binding a public interface directly.
144
- - **Keys are hashed at rest.** better-auth's API key plugin hashes stored keys (SHA-256); the plaintext is only ever shown once, at mint time.
145
- - **Uniform 401s.** A missing header, malformed header, unknown key, expired key, or disabled key are all indistinguishable from the outside every failure mode returns the same `401 { "error": "unauthorized" }` with a `WWW-Authenticate: Bearer` challenge, so nothing about *why* auth failed is observable.
188
+ - **API keys are hashed at rest.** better-auth's API key plugin hashes stored keys (SHA-256); the plaintext is only ever shown once, at mint time. They are unscoped — an API key grants everything `--allow` currently exposes, with no per-command scope check (that's the OAuth model's job; see below).
189
+ - **OAuth access tokens are opaque, single-audience, and scoped.** They're random strings stored in this server's own database — never a JWT, never verified any way but a database lookupso they're only ever redeemable against this server (the RFC 8707 "audience binding" intent is satisfied structurally: there's no shared token namespace another resource server could accept). Each token carries the scopes (`vault:read`/`vault:write`/`vault:destructive`/`vault:app-control`) it was granted for, enforced per command classification on every `/mcp` tool call and `/api/*` request.
190
+ - **Uniform 401s.** A missing header, malformed header, unknown/expired/disabled API key, or unknown/expired OAuth token are all indistinguishable from the outside — every failure mode returns a spec-shaped `401 { "error": "unauthorized" }` with a `WWW-Authenticate: Bearer error="invalid_token", ...` challenge (carrying `resource_metadata` and the currently-exposed `scope` list, per the MCP spec), so nothing about _why_ auth failed is observable. An authenticated-but-under-scoped OAuth request gets `403 insufficient_scope` instead, naming the missing scope.
191
+ - **Client ID Metadata Document fetches are SSRF-guarded.** A CIMD `client_id` is unauthenticated attacker-controllable input (anyone can point an authorize request at any URL); this server only fetches `https:` URLs on non-private, non-loopback hosts unless `--dangerously-allow-insecure-cimd-hosts` is set (local testing only). This guard is about the metadata-document URL only — it never restricts `redirect_uris`, which legitimately include loopback addresses for native/CLI clients (RFC 8252).
192
+
193
+ ## Development
194
+
195
+ This is a Bun monorepo: `apps/server` (the CLI/server entrypoint), `packages/auth` and `packages/obsidian-cli` (private workspace libraries bundled into it), plus `docs` and `scripts` (private, never published). The published artifact is npm's `@nathanld/obsidian-server` **main package** (built by `bun run build:dist` - see that script's header comment for exactly what it bundles) plus one **platform package** per supported OS/arch (`@nathanld/obsidian-server-<os>-<arch>`, built by `bun run build:binaries` - see `scripts/platform-targets.ts` for the matrix), each containing a `bun build --compile` standalone binary. The main package's `bin` is `dist/launcher.cjs`, a dependency-free Node script that resolves and runs the right platform package's binary (falling back to `bun cli.js` when none matches) - see that file's header comment for the resolution/fallback logic.
196
+
197
+ ```sh
198
+ bun install
199
+ bun test # unit tests (Bun's test runner)
200
+ bunx tsc --noEmit -p . # typecheck
201
+ bunx oxlint . # lint
202
+ bunx oxfmt --check . # format check (bunx oxfmt . to fix)
203
+ bun run build:dist # build dist/ (the main package)
204
+ bun run smoke:dist # pack + install the main tarball + exercise the bin (via bun)
205
+ bun run build:binaries # build dist-binaries/<os>-<arch>/ (one platform package per target)
206
+ bun run smoke:binaries # pack + npm install main+linux-x64 tarballs + exercise the bin (via node, no Bun)
207
+ bun run release:check # all of the above, in order
208
+ ```
209
+
210
+ `.github/workflows/ci.yml` runs this exact gate (plus `npm publish --dry-run` from `dist/` and from `dist-binaries/linux-x64/`) on every pull request and every push to `main`.
211
+
212
+ ### Releasing
213
+
214
+ Releases are driven by [changesets](https://github.com/changesets/changesets), with a small twist: this repo publishes a single npm package (`@nathanld/obsidian-server`) whose version lives in the **root** `package.json`, but changesets only versions workspace packages. `.changeset/config.json` handles this by:
215
+
216
+ - fixed-grouping the three private `@obsidian-server/*` workspace packages (`apps/server`, `packages/auth`, `packages/obsidian-cli`) so they always version together,
217
+ - `privatePackages: { version: true, tag: false }` so those `"private": true` packages still get versioned (just never individually git-tagged - `docs` and `scripts` are separately excluded via `ignore`, since they're internal-only and never meant to carry a release version at all),
218
+ - and `scripts/sync-version.ts`, run right after `bunx changeset version` (see the `changeset:version` root script), which copies `apps/server/package.json`'s newly-bumped version onto the root `package.json` - keeping root the single source of truth that `scripts/build-dist.ts` reads and bakes into the published binary.
219
+
220
+ End-to-end flow:
221
+
222
+ 1. A contributor makes a change and runs `bunx changeset` to describe it (patch/minor/major + a human-readable summary), and commits the generated `.changeset/*.md` file alongside their change.
223
+ 2. That PR merges to `main`. `.github/workflows/release-pr.yml` runs [`changesets/action`](https://github.com/changesets/action) in **version-only mode** (no `publish` command configured, so it never runs `npm publish` - it only opens or updates a "Version Packages" PR that runs `bun run changeset:version`, i.e. `bunx changeset version` + the sync step above).
224
+ 3. Repeat 1-2 for as many changes as you like; the Version Packages PR keeps accumulating them.
225
+ 4. When ready to release, merge the "Version Packages" PR. That merge lands on `main` with no changesets left pending and the new version already baked into `package.json` (root, `apps/server`, and the fixed-grouped packages, all in agreement).
226
+ 5. `release-pr.yml`'s `tag` job runs again on that push. Since there are now no pending changesets and no existing `v<version>` tag for the new version, `scripts/tag-if-ready.ts` (unit tested in `scripts/test/tag-if-ready.test.ts`; dry-run it locally with `bun run tag:release -- --dry-run`) creates and pushes tag `v<version>`.
227
+ 6. That tag push triggers `.github/workflows/publish.yml`, which re-runs the full gate (now including `build:binaries`/`smoke:binaries`), guards that the tag exactly matches `v<root package.json version>`, and then runs the **real** `npm publish` - every platform package (`@nathanld/obsidian-server-<os>-<arch>`) first, then the main package `dist/` - plus creates a GitHub release with the npm tarball and a compressed archive per platform binary attached. This is the only place in the whole setup that publishes for real. Every publish is idempotent (skipped if that exact `name@version` is already on the registry), so re-running the workflow after a partial failure is safe.
228
+
229
+ **Important limitation:** the tag pushed in step 5 uses the workflow's default `GITHUB_TOKEN`, which - by GitHub's built-in loop-prevention behavior - does **not** trigger other workflows' `on: push: tags` triggers. So step 6 may not start automatically. If it doesn't, trigger it manually:
230
+
231
+ ```sh
232
+ gh workflow run publish.yml -f tag=v<version>
233
+ ```
234
+
235
+ (`publish.yml` accepts this as a `workflow_dispatch` input specifically as a fallback for this case.)
236
+
237
+ #### Repo settings required
238
+
239
+ - **"Allow GitHub Actions to create and approve pull requests"** must be enabled under the repo's Settings → Actions → General, or `release-pr.yml` cannot open/update the Version Packages PR.
240
+ - **`NPM_TOKEN` secret**: create a granular npm automation token on [npmjs.com](https://www.npmjs.com) scoped to publish rights for `@nathanld/obsidian-server` **and every `@nathanld/obsidian-server-<os>-<arch>` platform package** (see `scripts/platform-targets.ts` for the current list), and add it as a repo secret named `NPM_TOKEN`. A granular token scoped to only the main package will publish `dist/` fine but fail on the platform packages. This is required because npm's interactive 2FA prompt can't be answered from CI - an automation token bypasses that.
241
+
242
+ #### Manual fallback
243
+
244
+ If you need to publish outside the automated flow entirely (e.g. the very first release, or CI is down), `dist/` and every `dist-binaries/<os>-<arch>/` can be published by hand from your own machine, once their version is already correct:
245
+
246
+ ```sh
247
+ bun run release:check # full gate, dry-run only
248
+ bun run release:publish -- --yes # real publish
249
+ bun run release:publish -- --yes --otp=123456 # if your npm account has 2FA
250
+ ```
146
251
 
147
252
  ## License
148
253
 
package/cli.js CHANGED
@@ -6,7 +6,10 @@ import { parseArgs } from "util";
6
6
 
7
7
  // apps/server/src/gating.ts
8
8
  var DEFAULT_CLASSIFICATIONS = ["read", "write"];
9
- var GATED_CLASSIFICATIONS = ["destructive", "app-control"];
9
+ var GATED_CLASSIFICATIONS = [
10
+ "destructive",
11
+ "app-control"
12
+ ];
10
13
 
11
14
  class UnknownAllowFlagError extends Error {
12
15
  value;
@@ -39,6 +42,7 @@ import { dirname } from "path";
39
42
  import { Database } from "bun:sqlite";
40
43
  import { betterAuth } from "better-auth";
41
44
  import { apiKey } from "@better-auth/api-key";
45
+ import { magicLink, mcp } from "better-auth/plugins";
42
46
 
43
47
  // packages/auth/src/bearer.ts
44
48
  var BEARER_PATTERN = /^Bearer\s+(.+)$/i;
@@ -51,6 +55,12 @@ function extractBearerToken(headerValue) {
51
55
  }
52
56
 
53
57
  // packages/auth/src/config.ts
58
+ var DEFAULT_OAUTH_SCOPES = [
59
+ "vault:read",
60
+ "vault:write",
61
+ "vault:destructive",
62
+ "vault:app-control"
63
+ ];
54
64
  function ensureParentDirExists(dbPath) {
55
65
  if (dbPath === ":memory:" || dbPath.startsWith("file::memory:"))
56
66
  return;
@@ -59,6 +69,11 @@ function ensureParentDirExists(dbPath) {
59
69
  function createAuth(options) {
60
70
  ensureParentDirExists(options.dbPath);
61
71
  const database = new Database(options.dbPath, { create: true });
72
+ if (options.oauth && !options.baseURL) {
73
+ throw new Error("createAuth: options.baseURL is required when options.oauth is set (better-auth's " + "mcp plugin needs it to build OAuth issuer/endpoint URLs).");
74
+ }
75
+ const oauth = options.oauth;
76
+ const scopes = oauth?.scopes ?? DEFAULT_OAUTH_SCOPES;
62
77
  return betterAuth({
63
78
  database,
64
79
  baseURL: options.baseURL,
@@ -72,6 +87,23 @@ function createAuth(options) {
72
87
  requireName: true,
73
88
  rateLimit: { enabled: false },
74
89
  customAPIKeyGetter: (ctx) => extractBearerToken(ctx.headers?.get("authorization") ?? null)
90
+ }),
91
+ magicLink({
92
+ disableSignUp: true,
93
+ sendMagicLink: async (data) => {
94
+ await oauth?.sendMagicLink?.({ email: data.email, url: data.url, token: data.token });
95
+ }
96
+ }),
97
+ mcp({
98
+ loginPage: oauth?.loginPage ?? "/login",
99
+ resource: oauth?.resource,
100
+ oidcConfig: {
101
+ loginPage: oauth?.loginPage ?? "/login",
102
+ requirePKCE: true,
103
+ storeClientSecret: "hashed",
104
+ scopes: [...scopes],
105
+ metadata: { scopes_supported: [...scopes] }
106
+ }
75
107
  })
76
108
  ]
77
109
  });
@@ -90,7 +122,12 @@ async function bootstrap(auth, options = {}) {
90
122
  const existing = await ctx.adapter.findMany({ model: "user", limit: 1 });
91
123
  const existingUser = existing[0];
92
124
  if (existingUser) {
93
- return { id: existingUser.id, email: existingUser.email, name: existingUser.name, created: false };
125
+ return {
126
+ id: existingUser.id,
127
+ email: existingUser.email,
128
+ name: existingUser.name,
129
+ created: false
130
+ };
94
131
  }
95
132
  const email = options.email ?? DEFAULT_OWNER_EMAIL;
96
133
  const name = options.name ?? DEFAULT_OWNER_NAME;
@@ -101,13 +138,17 @@ async function bootstrap(auth, options = {}) {
101
138
  });
102
139
  return { id: user.id, email: user.email, name: user.name, created: true };
103
140
  }
104
- async function resolveOwnerId(auth) {
141
+ async function getOwner(auth) {
105
142
  const ctx = await auth.$context;
106
143
  const existing = await ctx.adapter.findMany({ model: "user", limit: 1 });
107
144
  const owner = existing[0];
108
145
  if (!owner) {
109
146
  throw new Error("No owner user found. Run bootstrap() (or the `bootstrap` CLI command) first.");
110
147
  }
148
+ return { id: owner.id, email: owner.email, name: owner.name };
149
+ }
150
+ async function resolveOwnerId(auth) {
151
+ const owner = await getOwner(auth);
111
152
  return owner.id;
112
153
  }
113
154
  // packages/auth/src/keys.ts
@@ -129,7 +170,16 @@ async function mintKey(auth, options) {
129
170
  expiresAt: created.expiresAt
130
171
  };
131
172
  }
132
- var SAFE_KEY_COLUMNS = ["id", "name", "start", "enabled", "createdAt", "expiresAt", "lastRequest", "referenceId"];
173
+ var SAFE_KEY_COLUMNS = [
174
+ "id",
175
+ "name",
176
+ "start",
177
+ "enabled",
178
+ "createdAt",
179
+ "expiresAt",
180
+ "lastRequest",
181
+ "referenceId"
182
+ ];
133
183
  async function listKeys(auth, options = {}) {
134
184
  const userId = options.userId ?? await resolveOwnerId(auth);
135
185
  const ctx = await auth.$context;
@@ -172,34 +222,81 @@ async function ensureOwnerAndKey(auth, options = {}) {
172
222
  const minted = await mintKey(auth, { userId: owner.id, name: options.keyName ?? "bootstrap" });
173
223
  return { created: true, key: minted.key, keyId: minted.id };
174
224
  }
225
+ // packages/auth/src/challenge.ts
226
+ function quote(value) {
227
+ return value.replace(/"/g, "'");
228
+ }
229
+ function buildBearerChallenge(options) {
230
+ let header = `Bearer error="${options.error}", error_description="${quote(options.description)}"`;
231
+ if (options.scope && options.scope.length > 0) {
232
+ header += `, scope="${options.scope.join(" ")}"`;
233
+ }
234
+ if (options.resourceMetadataUrl) {
235
+ header += `, resource_metadata="${options.resourceMetadataUrl}"`;
236
+ }
237
+ return header;
238
+ }
239
+
175
240
  // packages/auth/src/middleware.ts
176
- function unauthorized(c) {
177
- c.header("WWW-Authenticate", "Bearer");
241
+ function unauthorized(c, options) {
242
+ const challenge = buildBearerChallenge({
243
+ error: "invalid_token",
244
+ description: "missing or invalid bearer token",
245
+ scope: options.scopes,
246
+ resourceMetadataUrl: options.resourceMetadataUrl
247
+ });
248
+ c.header("WWW-Authenticate", challenge);
178
249
  return c.json({ error: "unauthorized" }, 401);
179
250
  }
180
- function requireAuth(auth) {
251
+ function requireAuth(auth, options = {}) {
181
252
  return async (c, next) => {
182
253
  const token = extractBearerToken(c.req.header("authorization"));
183
254
  if (!token) {
184
- return unauthorized(c);
255
+ return unauthorized(c, options);
185
256
  }
186
- let result;
257
+ let apiKeyResult;
187
258
  try {
188
- result = await auth.api.verifyApiKey({ body: { key: token } });
259
+ apiKeyResult = await auth.api.verifyApiKey({ body: { key: token } });
189
260
  } catch {
190
- return unauthorized(c);
261
+ apiKeyResult = undefined;
191
262
  }
192
- if (!result.valid || !result.key) {
193
- return unauthorized(c);
263
+ if (apiKeyResult?.valid && apiKeyResult.key) {
264
+ const ctx = await auth.$context;
265
+ const user = await ctx.internalAdapter.findUserById(apiKeyResult.key.referenceId);
266
+ if (!user) {
267
+ return unauthorized(c, options);
268
+ }
269
+ c.set("user", { id: user.id, email: user.email, name: user.name });
270
+ c.set("apiKey", { id: apiKeyResult.key.id, name: apiKeyResult.key.name });
271
+ await next();
272
+ return;
194
273
  }
195
- const ctx = await auth.$context;
196
- const user = await ctx.internalAdapter.findUserById(result.key.referenceId);
197
- if (!user) {
198
- return unauthorized(c);
274
+ let session = null;
275
+ try {
276
+ session = await auth.api.getMcpSession({
277
+ headers: c.req.raw.headers,
278
+ request: c.req.raw,
279
+ asResponse: false
280
+ });
281
+ } catch {
282
+ session = null;
199
283
  }
200
- c.set("user", { id: user.id, email: user.email, name: user.name });
201
- c.set("apiKey", { id: result.key.id, name: result.key.name });
202
- await next();
284
+ if (session) {
285
+ const ctx = await auth.$context;
286
+ const user = await ctx.internalAdapter.findUserById(session.userId);
287
+ if (!user) {
288
+ return unauthorized(c, options);
289
+ }
290
+ c.set("user", { id: user.id, email: user.email, name: user.name });
291
+ c.set("oauthToken", {
292
+ clientId: session.clientId,
293
+ scopes: session.scopes ? session.scopes.split(" ").filter((s) => s.length > 0) : [],
294
+ expiresAt: Math.floor(session.accessTokenExpiresAt.getTime() / 1000)
295
+ });
296
+ await next();
297
+ return;
298
+ }
299
+ return unauthorized(c, options);
203
300
  };
204
301
  }
205
302
  // packages/obsidian-cli/src/classify.ts
@@ -602,7 +699,132 @@ function commandToZodShape(cmd) {
602
699
  }
603
700
  // apps/server/src/app.ts
604
701
  import { createMcpHonoApp } from "@modelcontextprotocol/hono";
605
- import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/server";
702
+ import {
703
+ getOAuthProtectedResourceMetadataUrl,
704
+ WebStandardStreamableHTTPServerTransport
705
+ } from "@modelcontextprotocol/server";
706
+
707
+ // apps/server/src/cimd.ts
708
+ var LOOPBACK_HOSTNAMES = new Set(["localhost", "127.0.0.1", "::1", "[::1]", "0.0.0.0"]);
709
+ function isPrivateOrLoopbackHost(hostname) {
710
+ const h = hostname.toLowerCase();
711
+ if (LOOPBACK_HOSTNAMES.has(h) || h.endsWith(".localhost"))
712
+ return true;
713
+ const ipv4 = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(h);
714
+ if (ipv4) {
715
+ const a = Number(ipv4[1]);
716
+ const b = Number(ipv4[2]);
717
+ if (a === 10 || a === 127 || a === 0)
718
+ return true;
719
+ if (a === 169 && b === 254)
720
+ return true;
721
+ if (a === 172 && b >= 16 && b <= 31)
722
+ return true;
723
+ if (a === 192 && b === 168)
724
+ return true;
725
+ }
726
+ if (h === "::1" || h.startsWith("fc") || h.startsWith("fd") || h.startsWith("fe80"))
727
+ return true;
728
+ return false;
729
+ }
730
+ function isCimdClientId(value, options = {}) {
731
+ let url;
732
+ try {
733
+ url = new URL(value);
734
+ } catch {
735
+ return false;
736
+ }
737
+ const schemeOk = url.protocol === "https:" || options.allowInsecureHosts === true && url.protocol === "http:";
738
+ if (!schemeOk)
739
+ return false;
740
+ if (!options.allowInsecureHosts && isPrivateOrLoopbackHost(url.hostname))
741
+ return false;
742
+ return true;
743
+ }
744
+ function isValidCimdDocument(value, clientId) {
745
+ if (typeof value !== "object" || value === null)
746
+ return false;
747
+ const doc = value;
748
+ if (doc.client_id !== clientId)
749
+ return false;
750
+ if (!Array.isArray(doc.redirect_uris) || doc.redirect_uris.length === 0)
751
+ return false;
752
+ if (!doc.redirect_uris.every((u) => typeof u === "string"))
753
+ return false;
754
+ if (doc.client_name !== undefined && typeof doc.client_name !== "string")
755
+ return false;
756
+ return true;
757
+ }
758
+ var DEFAULT_TTL_MS = 5 * 60 * 1000;
759
+ var resolvedCache = new Map;
760
+ function ttlFromCacheControl(header) {
761
+ if (!header)
762
+ return DEFAULT_TTL_MS;
763
+ const match = /max-age=(\d+)/.exec(header);
764
+ if (!match?.[1])
765
+ return DEFAULT_TTL_MS;
766
+ return Math.max(1, Number(match[1])) * 1000;
767
+ }
768
+ async function resolveCimdClient(auth, clientId, options = {}) {
769
+ if (!isCimdClientId(clientId, options))
770
+ return;
771
+ const cached = resolvedCache.get(clientId);
772
+ if (cached && cached.expiresAt > Date.now())
773
+ return;
774
+ const ctx = await auth.$context;
775
+ const existing = await ctx.adapter.findOne({
776
+ model: "oauthApplication",
777
+ where: [{ field: "clientId", value: clientId }]
778
+ });
779
+ let res;
780
+ try {
781
+ res = await fetch(clientId, { headers: { Accept: "application/json" } });
782
+ } catch {
783
+ if (existing)
784
+ resolvedCache.set(clientId, { expiresAt: Date.now() + DEFAULT_TTL_MS });
785
+ return;
786
+ }
787
+ if (!res.ok) {
788
+ if (existing)
789
+ resolvedCache.set(clientId, { expiresAt: Date.now() + DEFAULT_TTL_MS });
790
+ return;
791
+ }
792
+ let body;
793
+ try {
794
+ body = await res.json();
795
+ } catch {
796
+ return;
797
+ }
798
+ if (!isValidCimdDocument(body, clientId))
799
+ return;
800
+ const now = new Date;
801
+ const redirectUrls = body.redirect_uris.join(",");
802
+ const name = body.client_name ?? clientId;
803
+ if (existing) {
804
+ await ctx.adapter.update({
805
+ model: "oauthApplication",
806
+ where: [{ field: "clientId", value: clientId }],
807
+ update: { name, redirectUrls, updatedAt: now }
808
+ });
809
+ } else {
810
+ await ctx.adapter.create({
811
+ model: "oauthApplication",
812
+ data: {
813
+ name,
814
+ clientId,
815
+ clientSecret: "",
816
+ redirectUrls,
817
+ type: "public",
818
+ disabled: false,
819
+ createdAt: now,
820
+ updatedAt: now
821
+ }
822
+ });
823
+ }
824
+ resolvedCache.set(clientId, {
825
+ expiresAt: Date.now() + ttlFromCacheControl(res.headers.get("cache-control"))
826
+ });
827
+ }
606
828
 
607
829
  // apps/server/src/exposed-commands.ts
608
830
  function exposedCommands(commands, allowed) {
@@ -616,6 +838,91 @@ function countExposedByClassification(commands, allowed) {
616
838
  return counts;
617
839
  }
618
840
 
841
+ // apps/server/src/login-page.ts
842
+ function escapeHtml(value) {
843
+ return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
844
+ }
845
+ function escapeJsString(value) {
846
+ return JSON.stringify(value);
847
+ }
848
+ function renderLoginPage(options) {
849
+ const emailHtml = escapeHtml(options.ownerEmail);
850
+ const emailJs = escapeJsString(options.ownerEmail);
851
+ return `<!doctype html>
852
+ <html lang="en">
853
+ <head>
854
+ <meta charset="utf-8">
855
+ <meta name="viewport" content="width=device-width, initial-scale=1">
856
+ <title>Sign in - obsidian-server</title>
857
+ <style>
858
+ :root { color-scheme: light dark; }
859
+ body {
860
+ font-family: ui-sans-serif, system-ui, -apple-system, sans-serif;
861
+ max-width: 32rem;
862
+ margin: 4rem auto;
863
+ padding: 0 1.5rem;
864
+ line-height: 1.5;
865
+ }
866
+ h1 { font-size: 1.25rem; }
867
+ button {
868
+ font: inherit;
869
+ padding: 0.6rem 1.2rem;
870
+ border-radius: 0.4rem;
871
+ border: 1px solid #8884;
872
+ cursor: pointer;
873
+ }
874
+ button:disabled { opacity: 0.6; cursor: default; }
875
+ #status { margin-top: 1rem; }
876
+ code { padding: 0.1rem 0.3rem; border-radius: 0.25rem; background: #8882; }
877
+ </style>
878
+ </head>
879
+ <body>
880
+ <h1>Sign in to obsidian-server</h1>
881
+ <p>
882
+ This server has one owner account (<code>${emailHtml}</code>). Signing in
883
+ sends a one-time link - it is printed to the terminal where
884
+ <code>obsidian-server</code> is running, not emailed anywhere.
885
+ </p>
886
+ <button id="send" type="button">Send me a sign-in link</button>
887
+ <p id="status"></p>
888
+ <script>
889
+ (function () {
890
+ var ownerEmail = ${emailJs};
891
+ var button = document.getElementById("send");
892
+ var status = document.getElementById("status");
893
+
894
+ button.addEventListener("click", function () {
895
+ button.disabled = true;
896
+ status.textContent = "Sending...";
897
+
898
+ var callbackURL = window.location.origin + "/api/auth/mcp/authorize" + window.location.search;
899
+
900
+ fetch("/api/auth/sign-in/magic-link", {
901
+ method: "POST",
902
+ headers: { "Content-Type": "application/json" },
903
+ body: JSON.stringify({ email: ownerEmail, callbackURL: callbackURL }),
904
+ })
905
+ .then(function (res) {
906
+ if (!res.ok) throw new Error("request failed: " + res.status);
907
+ return res.json();
908
+ })
909
+ .then(function () {
910
+ status.textContent =
911
+ "A sign-in link has been printed to the terminal where obsidian-server " +
912
+ "is running. Open it there to finish signing in.";
913
+ })
914
+ .catch(function (err) {
915
+ button.disabled = false;
916
+ status.textContent = "Could not send the sign-in link: " + err.message;
917
+ });
918
+ });
919
+ })();
920
+ </script>
921
+ </body>
922
+ </html>
923
+ `;
924
+ }
925
+
619
926
  // apps/server/src/mcp.ts
620
927
  import { McpServer } from "@modelcontextprotocol/server";
621
928
  import { z as z2 } from "zod";
@@ -625,9 +932,40 @@ function commandToToolName(command) {
625
932
  return `obsidian_${command.replace(/:/g, "_")}`;
626
933
  }
627
934
 
935
+ // apps/server/src/scopes.ts
936
+ function classificationToScope(classification) {
937
+ return `vault:${classification}`;
938
+ }
939
+ var CLASSIFICATIONS = [
940
+ "read",
941
+ "write",
942
+ "destructive",
943
+ "app-control"
944
+ ];
945
+ function exposedScopes(allowed) {
946
+ return CLASSIFICATIONS.filter((c) => allowed.has(c)).map(classificationToScope);
947
+ }
948
+
628
949
  // apps/server/src/mcp.ts
629
950
  var SERVER_NAME = "obsidian-server";
630
951
  var SERVER_VERSION = "0.1.0";
952
+ function checkScope(ctx, classification) {
953
+ const authInfo = ctx.http?.authInfo;
954
+ if (!authInfo || authInfo.extra?.credentialType === "api-key")
955
+ return;
956
+ const requiredScope = classificationToScope(classification);
957
+ if (authInfo.scopes.includes(requiredScope))
958
+ return;
959
+ return {
960
+ content: [
961
+ {
962
+ type: "text",
963
+ text: `insufficient_scope: this tool requires the "${requiredScope}" scope`
964
+ }
965
+ ],
966
+ isError: true
967
+ };
968
+ }
631
969
  function buildMcpServer(options) {
632
970
  const { client, allowed } = options;
633
971
  const server = new McpServer({ name: SERVER_NAME, version: SERVER_VERSION });
@@ -641,7 +979,10 @@ function buildMcpServer(options) {
641
979
  registered.add(toolName);
642
980
  const description = `${command.description} [${classification}]`;
643
981
  const inputSchema = commandToZodShape(command);
644
- server.registerTool(toolName, { title: command.name, description, inputSchema }, async (args) => {
982
+ server.registerTool(toolName, { title: command.name, description, inputSchema }, async (args, ctx) => {
983
+ const scopeError = checkScope(ctx, classification);
984
+ if (scopeError)
985
+ return scopeError;
645
986
  try {
646
987
  const result = await client.run(command.name, args);
647
988
  return { content: [{ type: "text", text: result.stdout || "(empty)" }] };
@@ -664,7 +1005,12 @@ function buildMcpServer(options) {
664
1005
  options: command.options
665
1006
  }));
666
1007
  return {
667
- content: [{ type: "text", text: JSON.stringify({ vault: client.vault, commands }, null, 2) }]
1008
+ content: [
1009
+ {
1010
+ type: "text",
1011
+ text: JSON.stringify({ vault: client.vault, commands }, null, 2)
1012
+ }
1013
+ ]
668
1014
  };
669
1015
  });
670
1016
  return server;
@@ -679,10 +1025,72 @@ function needsDnsRebindingWarning(host, allowedHosts) {
679
1025
  return !isLocalhostClass(host) && (!allowedHosts || allowedHosts.length === 0);
680
1026
  }
681
1027
 
1028
+ // apps/server/src/well-known.ts
1029
+ import { oAuthDiscoveryMetadata, oAuthProtectedResourceMetadata } from "better-auth/plugins";
1030
+ function authorizationServerMetadata(auth, scopesSupported) {
1031
+ const upstream = oAuthDiscoveryMetadata(auth);
1032
+ return async (request) => {
1033
+ const res = await upstream(request);
1034
+ if (res.status !== 200)
1035
+ return res;
1036
+ const body = await res.json();
1037
+ body.scopes_supported = [...scopesSupported];
1038
+ body.authorization_response_iss_parameter_supported = true;
1039
+ body.client_id_metadata_document_supported = true;
1040
+ return new Response(JSON.stringify(body), { status: 200, headers: res.headers });
1041
+ };
1042
+ }
1043
+ function protectedResourceMetadata(auth) {
1044
+ return oAuthProtectedResourceMetadata(auth);
1045
+ }
1046
+
682
1047
  // apps/server/src/app.ts
683
1048
  function isOptionValue(value) {
684
1049
  return typeof value === "string" || typeof value === "number" || typeof value === "boolean";
685
1050
  }
1051
+ function appendIssParam(res, issuer) {
1052
+ const location = res.headers.get("location");
1053
+ if (!location)
1054
+ return res;
1055
+ let url;
1056
+ try {
1057
+ url = new URL(location, issuer);
1058
+ } catch {
1059
+ return res;
1060
+ }
1061
+ if (!url.searchParams.has("code"))
1062
+ return res;
1063
+ url.searchParams.set("iss", issuer);
1064
+ const headers = new Headers(res.headers);
1065
+ headers.set("location", url.toString());
1066
+ return new Response(res.body, { status: res.status, statusText: res.statusText, headers });
1067
+ }
1068
+ async function extractClientId(req) {
1069
+ const fields = await extractTokenRequestFields(req);
1070
+ return fields.client_id;
1071
+ }
1072
+ async function extractTokenRequestFields(req) {
1073
+ if (req.method === "GET" || req.method === "HEAD") {
1074
+ const params = new URL(req.url).searchParams;
1075
+ return {
1076
+ client_id: params.get("client_id") ?? undefined,
1077
+ grant_type: params.get("grant_type") ?? undefined,
1078
+ refresh_token: params.get("refresh_token") ?? undefined
1079
+ };
1080
+ }
1081
+ const clone = req.clone();
1082
+ try {
1083
+ const contentType = clone.headers.get("content-type") ?? "";
1084
+ const raw = contentType.includes("application/json") ? await clone.json() : Object.fromEntries(new URLSearchParams(await clone.text()));
1085
+ return {
1086
+ client_id: typeof raw.client_id === "string" ? raw.client_id : undefined,
1087
+ grant_type: typeof raw.grant_type === "string" ? raw.grant_type : undefined,
1088
+ refresh_token: typeof raw.refresh_token === "string" ? raw.refresh_token : undefined
1089
+ };
1090
+ } catch {
1091
+ return {};
1092
+ }
1093
+ }
686
1094
  async function parseCommandBody(req) {
687
1095
  const raw = await req.text();
688
1096
  if (raw.trim().length === 0)
@@ -707,6 +1115,10 @@ async function parseCommandBody(req) {
707
1115
  }
708
1116
  async function buildApp(options) {
709
1117
  const { client, auth, allowed } = options;
1118
+ const publicUrl = options.publicUrl.replace(/\/$/, "");
1119
+ const mcpResourceUrl = `${publicUrl}/mcp`;
1120
+ const resourceMetadataUrl = getOAuthProtectedResourceMetadataUrl(new URL(mcpResourceUrl));
1121
+ const scopesSupported = exposedScopes(allowed);
710
1122
  const warnings = [];
711
1123
  if (needsDnsRebindingWarning(options.host, options.allowedHosts)) {
712
1124
  warnings.push(`Binding to "${options.host}" without --allow-hosts/--allow-origins leaves DNS-rebinding protection ` + "disabled. Pass allowedHosts/allowedOrigins when binding beyond localhost.");
@@ -725,7 +1137,37 @@ async function buildApp(options) {
725
1137
  }
726
1138
  return c.json({ ok: true, obsidian: reachable, vault: client.vault ?? null });
727
1139
  });
728
- const authMiddleware = requireAuth(auth);
1140
+ const prmHandler = protectedResourceMetadata(auth);
1141
+ app.get("/.well-known/oauth-protected-resource/mcp", (c) => prmHandler(c.req.raw));
1142
+ app.get("/.well-known/oauth-protected-resource", (c) => prmHandler(c.req.raw));
1143
+ const asHandler = authorizationServerMetadata(auth, scopesSupported);
1144
+ app.get("/.well-known/oauth-authorization-server", (c) => asHandler(c.req.raw));
1145
+ const owner = await getOwner(auth);
1146
+ app.get("/login", (c) => c.html(renderLoginPage({ ownerEmail: owner.email })));
1147
+ const cimdOptions = { allowInsecureHosts: options.allowInsecureCimdHosts };
1148
+ app.get("/api/auth/mcp/authorize", async (c) => {
1149
+ const clientId = await extractClientId(c.req.raw);
1150
+ if (clientId)
1151
+ await resolveCimdClient(auth, clientId, cimdOptions).catch(() => {});
1152
+ const res = await auth.handler(c.req.raw);
1153
+ return appendIssParam(res, publicUrl);
1154
+ });
1155
+ app.post("/api/auth/mcp/token", async (c) => {
1156
+ const fields = await extractTokenRequestFields(c.req.raw);
1157
+ if (fields.client_id)
1158
+ await resolveCimdClient(auth, fields.client_id, cimdOptions).catch(() => {});
1159
+ const res = await auth.handler(c.req.raw);
1160
+ if (fields.grant_type === "refresh_token" && fields.refresh_token && res.status === 200) {
1161
+ const ctx = await auth.$context;
1162
+ await ctx.adapter.delete({
1163
+ model: "oauthAccessToken",
1164
+ where: [{ field: "refreshToken", value: fields.refresh_token }]
1165
+ }).catch(() => {});
1166
+ }
1167
+ return res;
1168
+ });
1169
+ app.all("/api/auth/*", (c) => auth.handler(c.req.raw));
1170
+ const authMiddleware = requireAuth(auth, { resourceMetadataUrl, scopes: scopesSupported });
729
1171
  app.use("/api/*", authMiddleware);
730
1172
  app.use("/mcp", authMiddleware);
731
1173
  app.get("/api/commands", (c) => {
@@ -754,6 +1196,19 @@ async function buildApp(options) {
754
1196
  error: `Command "${name}" is classified "${classification}" and is not exposed. Restart the server with --allow ${classification} to enable it.`
755
1197
  }, 403);
756
1198
  }
1199
+ const oauthToken = c.get("oauthToken");
1200
+ if (oauthToken) {
1201
+ const requiredScope = classificationToScope(classification);
1202
+ if (!oauthToken.scopes.includes(requiredScope)) {
1203
+ c.header("WWW-Authenticate", buildBearerChallenge({
1204
+ error: "insufficient_scope",
1205
+ description: `command "${name}" requires the "${requiredScope}" scope`,
1206
+ scope: [requiredScope],
1207
+ resourceMetadataUrl
1208
+ }));
1209
+ return c.json({ ok: false, error: `insufficient_scope: requires the "${requiredScope}" scope` }, 403);
1210
+ }
1211
+ }
757
1212
  const parsedBody = await parseCommandBody(c.req.raw);
758
1213
  if (!parsedBody.ok) {
759
1214
  return c.json({ ok: false, error: parsedBody.error }, 400);
@@ -783,11 +1238,19 @@ async function buildApp(options) {
783
1238
  app.all("/mcp", async (c) => {
784
1239
  const user = c.get("user");
785
1240
  const apiKey2 = c.get("apiKey");
786
- const authInfo = {
1241
+ const oauthToken = c.get("oauthToken");
1242
+ const authInfo = oauthToken ? {
1243
+ token: extractBearerToken(c.req.header("authorization")) ?? "",
1244
+ clientId: oauthToken.clientId,
1245
+ scopes: oauthToken.scopes,
1246
+ expiresAt: oauthToken.expiresAt,
1247
+ resource: new URL(mcpResourceUrl),
1248
+ extra: { credentialType: "oauth", user, oauthToken }
1249
+ } : {
787
1250
  token: apiKey2?.id ?? "",
788
1251
  clientId: apiKey2?.id ?? "unknown",
789
- scopes: [],
790
- extra: { user, apiKey: apiKey2 }
1252
+ scopes: scopesSupported,
1253
+ extra: { credentialType: "api-key", user, apiKey: apiKey2 }
791
1254
  };
792
1255
  return transport.handleRequest(c.req.raw, { parsedBody: c.get("parsedBody"), authInfo });
793
1256
  });
@@ -833,6 +1296,16 @@ function renderStartupBanner(input) {
833
1296
  lines.push("");
834
1297
  lines.push(" Add this server to an MCP client:");
835
1298
  lines.push(` ${renderMcpAddCommand(input.host, input.port, input.freshToken)}`);
1299
+ if (input.publicUrl) {
1300
+ lines.push("");
1301
+ lines.push(" OAuth 2.1 (MCP spec rev 2026-07-28, RC-hardened; also accepted alongside API keys):");
1302
+ lines.push(` issuer: ${input.publicUrl}`);
1303
+ lines.push(` protected resource: ${input.publicUrl}/.well-known/oauth-protected-resource/mcp`);
1304
+ lines.push(` login page: ${input.publicUrl}/login`);
1305
+ if (input.oauthScopes && input.oauthScopes.length > 0) {
1306
+ lines.push(` scopes: ${input.oauthScopes.join(", ")}`);
1307
+ }
1308
+ }
836
1309
  return lines.join(`
837
1310
  `);
838
1311
  }
@@ -855,7 +1328,23 @@ var OBSIDIAN_NOT_FOUND_HINT = "Could not run the `obsidian` CLI. Make sure the O
855
1328
  async function runServe(options) {
856
1329
  const dataDir = resolveDataDir(process.env, options.dataDir);
857
1330
  mkdirSync2(dataDir, { recursive: true });
858
- const auth = createAuth({ dbPath: join2(dataDir, "auth.db"), baseURL: `http://${options.host}:${options.port}` });
1331
+ const publicUrl = (options.publicUrl ?? `http://${options.host}:${options.port}`).replace(/\/$/, "");
1332
+ const allowed = computeAllowedClassifications(options.allow);
1333
+ const scopes = exposedScopes(allowed);
1334
+ const auth = createAuth({
1335
+ dbPath: join2(dataDir, "auth.db"),
1336
+ baseURL: publicUrl,
1337
+ oauth: {
1338
+ resource: `${publicUrl}/mcp`,
1339
+ scopes,
1340
+ sendMagicLink: (data) => {
1341
+ console.log("");
1342
+ console.log("A sign-in link was requested for the OAuth login page. Open it to finish signing in:");
1343
+ console.log(` ${data.url}`);
1344
+ console.log("");
1345
+ }
1346
+ }
1347
+ });
859
1348
  await migrate(auth);
860
1349
  const ensured = await ensureOwnerAndKey(auth);
861
1350
  let client;
@@ -873,15 +1362,19 @@ async function runServe(options) {
873
1362
  }
874
1363
  throw err;
875
1364
  }
876
- const allowed = computeAllowedClassifications(options.allow);
877
1365
  const { app, warnings } = await buildApp({
878
1366
  client,
879
1367
  auth,
880
1368
  allowed,
881
1369
  host: options.host,
882
1370
  allowedHosts: options.allowedHosts,
883
- allowedOrigins: options.allowedOrigins
1371
+ allowedOrigins: options.allowedOrigins,
1372
+ publicUrl,
1373
+ allowInsecureCimdHosts: options.allowInsecureCimdHosts
884
1374
  });
1375
+ if (!options.publicUrl && publicUrl.startsWith("http://")) {
1376
+ warnings.push(`No --public-url was given; OAuth discovery defaults to "${publicUrl}", which is only reachable ` + "locally. Most remote MCP clients (including claude.ai) require HTTPS - pass --public-url " + "https://<host> once this server is reachable at one.");
1377
+ }
885
1378
  for (const warning of warnings)
886
1379
  console.warn(`warning: ${warning}`);
887
1380
  const banner = renderStartupBanner({
@@ -892,7 +1385,9 @@ async function runServe(options) {
892
1385
  exposedCounts: countExposedByClassification(client.schema.commands, allowed),
893
1386
  totalExposed: exposedCommands(client.schema.commands, allowed).length,
894
1387
  totalCommands: client.schema.commands.length,
895
- freshToken: ensured.created ? ensured.key : undefined
1388
+ freshToken: ensured.created ? ensured.key : undefined,
1389
+ publicUrl,
1390
+ oauthScopes: scopes
896
1391
  });
897
1392
  console.log(banner);
898
1393
  const server = Bun.serve({ fetch: app.fetch, port: options.port, hostname: options.host });
@@ -909,7 +1404,11 @@ async function tokenMint(options) {
909
1404
  const auth = authForDataDir(options.dataDir);
910
1405
  await migrate(auth);
911
1406
  const owner = await bootstrap(auth);
912
- const minted = await mintKey(auth, { name: options.name, expiresDays: options.expiresDays, userId: owner.id });
1407
+ const minted = await mintKey(auth, {
1408
+ name: options.name,
1409
+ expiresDays: options.expiresDays,
1410
+ userId: owner.id
1411
+ });
913
1412
  return { id: minted.id, key: minted.key, expiresAt: minted.expiresAt };
914
1413
  }
915
1414
  async function tokenList(options = {}) {
@@ -936,7 +1435,7 @@ function formatKeySummary(key) {
936
1435
  }
937
1436
 
938
1437
  // apps/server/src/version.ts
939
- var VERSION = "0.0.1";
1438
+ var VERSION = "0.0.2";
940
1439
 
941
1440
  // apps/server/src/cli.ts
942
1441
  var USAGE = [
@@ -946,7 +1445,12 @@ var USAGE = [
946
1445
  " serve [--vault <name>] [--port 3000] [--host 127.0.0.1]",
947
1446
  " [--allow destructive] [--allow app-control]",
948
1447
  " [--data-dir <path>] [--binary <path>] [--timeout-ms <n>]",
1448
+ " [--public-url https://host[:port]]",
1449
+ " [--dangerously-allow-insecure-cimd-hosts]",
949
1450
  " Start the MCP + REST server for a vault.",
1451
+ " --public-url is the canonical OAuth issuer / MCP resource URL - the",
1452
+ " URL a remote client actually reaches this server through. Defaults to",
1453
+ " http://<host>:<port>, which only works for local clients.",
950
1454
  "",
951
1455
  " token mint --name <label> [--expires-days <n>] [--data-dir <path>]",
952
1456
  " Mint a new API key (printed once).",
@@ -985,7 +1489,9 @@ async function main() {
985
1489
  allow: { type: "string", multiple: true, default: [] },
986
1490
  "data-dir": { type: "string" },
987
1491
  binary: { type: "string" },
988
- "timeout-ms": { type: "string" }
1492
+ "timeout-ms": { type: "string" },
1493
+ "public-url": { type: "string" },
1494
+ "dangerously-allow-insecure-cimd-hosts": { type: "boolean", default: false }
989
1495
  },
990
1496
  allowPositionals: false
991
1497
  });
@@ -1018,7 +1524,9 @@ async function main() {
1018
1524
  allow: values.allow ?? [],
1019
1525
  dataDir: values["data-dir"],
1020
1526
  binary: values.binary,
1021
- timeoutMs
1527
+ timeoutMs,
1528
+ publicUrl: values["public-url"],
1529
+ allowInsecureCimdHosts: values["dangerously-allow-insecure-cimd-hosts"]
1022
1530
  });
1023
1531
  return;
1024
1532
  }
@@ -1047,7 +1555,11 @@ async function main() {
1047
1555
  process.exitCode = 1;
1048
1556
  return;
1049
1557
  }
1050
- const minted = await tokenMint({ name: values.name, expiresDays, dataDir: values["data-dir"] });
1558
+ const minted = await tokenMint({
1559
+ name: values.name,
1560
+ expiresDays,
1561
+ dataDir: values["data-dir"]
1562
+ });
1051
1563
  console.log("API key (shown once - it cannot be recovered later):");
1052
1564
  console.log(minted.key);
1053
1565
  console.log(`id: ${minted.id}`);
package/launcher.cjs ADDED
@@ -0,0 +1,182 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+
4
+ /**
5
+ * Node-compatible launcher shim for `@nathanld/obsidian-server`'s `bin`.
6
+ *
7
+ * Plain CommonJS, zero dependencies, no Bun APIs - must run on Node >=18
8
+ * (the "no Bun required" floor this whole binaries setup exists for).
9
+ * Mirrors the esbuild/biome/turbo pattern:
10
+ *
11
+ * 1. Work out which `@nathanld/obsidian-server-<os>-<cpu>` platform
12
+ * package (an optionalDependency of this package, see
13
+ * scripts/build-binaries.ts) matches the current platform, and try to
14
+ * `require.resolve` it as an installed sibling package.
15
+ * 2. If found, spawn its compiled binary directly with argv passthrough,
16
+ * inherited stdio, and the child's exit code/signal mirrored back.
17
+ * 3. If not found (unsupported platform, or optionalDependencies were
18
+ * skipped e.g. via `npm install --no-optional` / `--omit=optional`),
19
+ * fall back to `bun <this dir>/cli.js` if `bun` is on PATH.
20
+ * 4. If neither works, exit with a clear, actionable error.
21
+ *
22
+ * KEEP THIS FILE IN SYNC with `scripts/platform-targets.ts`'s PLATFORM_TARGETS
23
+ * if the supported platform/arch matrix ever changes - it's duplicated here
24
+ * (rather than imported) because this file must stay plain, dependency-free
25
+ * CommonJS that Node can run with no build step.
26
+ */
27
+
28
+ const path = require("node:path");
29
+ const fs = require("node:fs");
30
+ const { spawn } = require("node:child_process");
31
+
32
+ /** @type {Record<string, string>} "<process.platform> <process.arch>" -> platform package name suffix */
33
+ const PLATFORM_PACKAGE_DIRS = {
34
+ "linux x64": "linux-x64",
35
+ "linux arm64": "linux-arm64",
36
+ "darwin x64": "darwin-x64",
37
+ "darwin arm64": "darwin-arm64",
38
+ "win32 x64": "win32-x64",
39
+ };
40
+
41
+ const PACKAGE_NAME = "@nathanld/obsidian-server";
42
+
43
+ function platformKey() {
44
+ return `${process.platform} ${process.arch}`;
45
+ }
46
+
47
+ function binaryFileName() {
48
+ return process.platform === "win32" ? "obsidian-server.exe" : "obsidian-server";
49
+ }
50
+
51
+ /**
52
+ * Resolve the installed platform package's directory, or undefined if it
53
+ * isn't installed (unsupported platform, or optionalDependencies skipped).
54
+ *
55
+ * Uses plain `require.resolve` first (relies on Node's normal node_modules
56
+ * directory walk from this file's location, which is how npm/yarn/pnpm all
57
+ * lay out optionalDependencies as an installable sibling of this package),
58
+ * then retries with an explicit `paths` list covering a few layouts
59
+ * (bunx's isolated install dirs, global installs) where the natural walk
60
+ * from `__dirname` alone might not reach the sibling package.
61
+ */
62
+ function resolvePlatformPackageDir(pkgName) {
63
+ const candidate = `${pkgName}/package.json`;
64
+ try {
65
+ return path.dirname(require.resolve(candidate));
66
+ } catch {
67
+ // fall through to the explicit-paths retry below
68
+ }
69
+
70
+ const extraRoots = [
71
+ __dirname,
72
+ path.join(__dirname, ".."),
73
+ path.join(__dirname, "..", ".."),
74
+ path.join(__dirname, "..", "..", ".."),
75
+ process.cwd(),
76
+ ];
77
+ try {
78
+ return path.dirname(require.resolve(candidate, { paths: extraRoots }));
79
+ } catch {
80
+ return undefined;
81
+ }
82
+ }
83
+
84
+ /** Minimal, dependency-free `which`: search PATH for an executable. */
85
+ function findOnPath(command) {
86
+ const pathEnv = process.env.PATH || process.env.Path || "";
87
+ const dirs = pathEnv.split(path.delimiter).filter(Boolean);
88
+ const candidates =
89
+ process.platform === "win32"
90
+ ? (process.env.PATHEXT || ".EXE;.CMD;.BAT").split(";").map((ext) => command + ext)
91
+ : [command];
92
+
93
+ for (const dir of dirs) {
94
+ for (const name of candidates) {
95
+ const full = path.join(dir, name);
96
+ try {
97
+ fs.accessSync(full, fs.constants.X_OK);
98
+ return full;
99
+ } catch {
100
+ // not here, keep looking
101
+ }
102
+ }
103
+ }
104
+ return undefined;
105
+ }
106
+
107
+ function installHint() {
108
+ const suffix = PLATFORM_PACKAGE_DIRS[platformKey()];
109
+ const platformPkgLine = suffix
110
+ ? `The platform package "${PACKAGE_NAME}-${suffix}" was not found (it may have been skipped by --no-optional / --omit=optional).`
111
+ : `"${platformKey()}" has no prebuilt binary (supported: ${Object.keys(PLATFORM_PACKAGE_DIRS).join(", ")}).`;
112
+
113
+ return [
114
+ `${PACKAGE_NAME}: could not find a native binary to run.`,
115
+ "",
116
+ platformPkgLine,
117
+ "No fallback either: `bun` was not found on PATH.",
118
+ "",
119
+ "To fix this, either:",
120
+ " - reinstall without skipping optional dependencies, e.g. `npm install`, or",
121
+ " - install Bun (https://bun.sh) so the bundled cli.js fallback can run.",
122
+ "",
123
+ ].join("\n");
124
+ }
125
+
126
+ /**
127
+ * Spawn `command`/`args`, inherit stdio, forward SIGINT/SIGTERM to the
128
+ * child, and mirror the child's exit code/signal back onto this process
129
+ * once it exits.
130
+ */
131
+ function runAndExit(command, args) {
132
+ const child = spawn(command, args, { stdio: "inherit" });
133
+
134
+ const forward = (signal) => {
135
+ if (!child.killed) child.kill(signal);
136
+ };
137
+ process.on("SIGINT", forward);
138
+ process.on("SIGTERM", forward);
139
+
140
+ child.on("error", (err) => {
141
+ console.error(`${PACKAGE_NAME}: failed to run "${command}": ${err.message}`);
142
+ process.exit(1);
143
+ });
144
+
145
+ child.on("exit", (code, signal) => {
146
+ if (signal) {
147
+ // Re-raise the same signal on ourselves so our own exit status/signal
148
+ // matches what killed the child, instead of guessing an exit code.
149
+ process.kill(process.pid, signal);
150
+ return;
151
+ }
152
+ process.exit(code === null ? 1 : code);
153
+ });
154
+ }
155
+
156
+ function main() {
157
+ const args = process.argv.slice(2);
158
+
159
+ const suffix = PLATFORM_PACKAGE_DIRS[platformKey()];
160
+ if (suffix) {
161
+ const platformDir = resolvePlatformPackageDir(`${PACKAGE_NAME}-${suffix}`);
162
+ if (platformDir) {
163
+ const binPath = path.join(platformDir, binaryFileName());
164
+ if (fs.existsSync(binPath)) {
165
+ runAndExit(binPath, args);
166
+ return;
167
+ }
168
+ }
169
+ }
170
+
171
+ const bunPath = findOnPath("bun");
172
+ if (bunPath) {
173
+ const cliPath = path.join(__dirname, "cli.js");
174
+ runAndExit(bunPath, [cliPath, ...args]);
175
+ return;
176
+ }
177
+
178
+ console.error(installHint());
179
+ process.exit(1);
180
+ }
181
+
182
+ main();
package/package.json CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "name": "@nathanld/obsidian-server",
3
- "version": "0.0.1",
3
+ "version": "0.0.2",
4
4
  "description": "Point any Obsidian vault at an MCP server + REST API with Bearer-token auth out of the box, backed by the official Obsidian CLI.",
5
5
  "type": "module",
6
6
  "bin": {
7
- "obsidian-server": "cli.js"
7
+ "obsidian-server": "launcher.cjs"
8
8
  },
9
9
  "dependencies": {
10
10
  "@better-auth/api-key": "1.6.23",
@@ -14,16 +14,23 @@
14
14
  "hono": "^4.12.29",
15
15
  "zod": "^4.4.3"
16
16
  },
17
+ "optionalDependencies": {
18
+ "@nathanld/obsidian-server-linux-x64": "0.0.2",
19
+ "@nathanld/obsidian-server-linux-arm64": "0.0.2",
20
+ "@nathanld/obsidian-server-darwin-x64": "0.0.2",
21
+ "@nathanld/obsidian-server-darwin-arm64": "0.0.2",
22
+ "@nathanld/obsidian-server-win32-x64": "0.0.2"
23
+ },
17
24
  "engines": {
18
- "bun": ">=1.2.0"
25
+ "node": ">=18"
19
26
  },
20
27
  "repository": {
21
28
  "type": "git",
22
- "url": "git+https://github.com/nathanld/obsidian-server.git"
29
+ "url": "git+https://github.com/UpToNathan/obsidian-server.git"
23
30
  },
24
- "homepage": "https://github.com/nathanld/obsidian-server#readme",
31
+ "homepage": "https://github.com/UpToNathan/obsidian-server#readme",
25
32
  "bugs": {
26
- "url": "https://github.com/nathanld/obsidian-server/issues"
33
+ "url": "https://github.com/UpToNathan/obsidian-server/issues"
27
34
  },
28
35
  "license": "MIT",
29
36
  "keywords": [
@@ -37,6 +44,7 @@
37
44
  ],
38
45
  "files": [
39
46
  "cli.js",
47
+ "launcher.cjs",
40
48
  "README.md",
41
49
  "LICENSE"
42
50
  ],