@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.
- package/README.md +135 -30
- package/cli.js +548 -36
- package/launcher.cjs +182 -0
- 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
|
|
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**
|
|
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
|
-
|
|
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
|
|
75
|
-
|
|
98
|
+
| Flag | Global |
|
|
99
|
+
| ----------------- | --------------------------- |
|
|
76
100
|
| `--version`, `-v` | Print the version and exit. |
|
|
77
|
-
| `--help`, `-h`
|
|
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
|
|
88
|
-
|
|
89
|
-
| `--vault <name>`
|
|
90
|
-
| `--port <n>`
|
|
91
|
-
| `--host <host>`
|
|
92
|
-
| `--allow destructive`
|
|
93
|
-
| `--allow app-control`
|
|
94
|
-
| `--data-dir <path>`
|
|
95
|
-
| `--binary <path>`
|
|
96
|
-
| `--timeout-ms <n>`
|
|
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
|
|
113
|
-
|
|
114
|
-
| `GET`
|
|
115
|
-
| `GET`
|
|
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
|
|
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?
|
|
132
|
-
|
|
133
|
-
| `read`
|
|
134
|
-
| `write`
|
|
135
|
-
| `destructive`
|
|
136
|
-
| `app-control`
|
|
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
|
-
- **
|
|
145
|
-
- **
|
|
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 lookup — so 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 = [
|
|
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 {
|
|
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
|
|
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 = [
|
|
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
|
-
|
|
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
|
|
257
|
+
let apiKeyResult;
|
|
187
258
|
try {
|
|
188
|
-
|
|
259
|
+
apiKeyResult = await auth.api.verifyApiKey({ body: { key: token } });
|
|
189
260
|
} catch {
|
|
190
|
-
|
|
261
|
+
apiKeyResult = undefined;
|
|
191
262
|
}
|
|
192
|
-
if (
|
|
193
|
-
|
|
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
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
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
|
-
|
|
201
|
-
|
|
202
|
-
|
|
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 {
|
|
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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
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: [
|
|
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
|
|
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
|
|
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
|
|
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, {
|
|
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.
|
|
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({
|
|
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.
|
|
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": "
|
|
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
|
-
"
|
|
25
|
+
"node": ">=18"
|
|
19
26
|
},
|
|
20
27
|
"repository": {
|
|
21
28
|
"type": "git",
|
|
22
|
-
"url": "git+https://github.com/
|
|
29
|
+
"url": "git+https://github.com/UpToNathan/obsidian-server.git"
|
|
23
30
|
},
|
|
24
|
-
"homepage": "https://github.com/
|
|
31
|
+
"homepage": "https://github.com/UpToNathan/obsidian-server#readme",
|
|
25
32
|
"bugs": {
|
|
26
|
-
"url": "https://github.com/
|
|
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
|
],
|