@nathanld/obsidian-server 0.0.1
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/LICENSE +21 -0
- package/README.md +149 -0
- package/cli.js +1090 -0
- package/package.json +46 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Nathan Duarte
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
# @nathanld/obsidian-server
|
|
2
|
+
|
|
3
|
+
Point it at any [Obsidian](https://obsidian.md) vault and get, out of the box:
|
|
4
|
+
|
|
5
|
+
- an **MCP server** (Streamable HTTP, `/mcp`) exposing your vault's commands as tools to Claude and other MCP clients
|
|
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
|
|
8
|
+
|
|
9
|
+
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
|
+
|
|
11
|
+
## Requirements
|
|
12
|
+
|
|
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).
|
|
14
|
+
- 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
|
+
|
|
16
|
+
## Install / run
|
|
17
|
+
|
|
18
|
+
Run it without installing anything:
|
|
19
|
+
|
|
20
|
+
```sh
|
|
21
|
+
bunx @nathanld/obsidian-server serve
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Or install it globally:
|
|
25
|
+
|
|
26
|
+
```sh
|
|
27
|
+
bun add -g @nathanld/obsidian-server
|
|
28
|
+
obsidian-server serve
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## First run: the token flow
|
|
32
|
+
|
|
33
|
+
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:
|
|
34
|
+
|
|
35
|
+
```
|
|
36
|
+
obsidian-server is running
|
|
37
|
+
vault: my-vault
|
|
38
|
+
REST base: http://127.0.0.1:3000
|
|
39
|
+
MCP endpoint: http://127.0.0.1:3000/mcp
|
|
40
|
+
|
|
41
|
+
commands exposed: 65/102
|
|
42
|
+
read: 54
|
|
43
|
+
write: 11
|
|
44
|
+
destructive: 0 (disabled - pass --allow destructive)
|
|
45
|
+
app-control: 0 (disabled - pass --allow app-control)
|
|
46
|
+
|
|
47
|
+
A new API key was minted (shown ONLY ONCE - copy it now):
|
|
48
|
+
<secret key>
|
|
49
|
+
|
|
50
|
+
Add this server to an MCP client:
|
|
51
|
+
claude mcp add --transport http obsidian http://127.0.0.1:3000/mcp --header "Authorization: Bearer <secret key>"
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Copy the key now — it's hashed at rest and cannot be recovered later. On every subsequent `serve`, the existing key is reused and the banner just reminds you to mint a new one if you need it:
|
|
55
|
+
|
|
56
|
+
```sh
|
|
57
|
+
obsidian-server token mint --name my-new-key
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
### Connecting Claude Code
|
|
61
|
+
|
|
62
|
+
```sh
|
|
63
|
+
claude mcp add --transport http obsidian http://127.0.0.1:3000/mcp --header "Authorization: Bearer <token>"
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
(the exact command, with your real token filled in, is printed in the startup banner on first run).
|
|
67
|
+
|
|
68
|
+
## CLI reference
|
|
69
|
+
|
|
70
|
+
```
|
|
71
|
+
obsidian-server <command> [flags]
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
| Flag | Global |
|
|
75
|
+
|---|---|
|
|
76
|
+
| `--version`, `-v` | Print the version and exit. |
|
|
77
|
+
| `--help`, `-h` | Print usage and exit. |
|
|
78
|
+
|
|
79
|
+
### `serve`
|
|
80
|
+
|
|
81
|
+
```
|
|
82
|
+
obsidian-server serve [--vault <name>] [--port 3000] [--host 127.0.0.1]
|
|
83
|
+
[--allow destructive] [--allow app-control]
|
|
84
|
+
[--data-dir <path>] [--binary <path>] [--timeout-ms <n>]
|
|
85
|
+
```
|
|
86
|
+
|
|
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. |
|
|
97
|
+
|
|
98
|
+
### `token`
|
|
99
|
+
|
|
100
|
+
```
|
|
101
|
+
obsidian-server token mint --name <label> [--expires-days <n>] [--data-dir <path>]
|
|
102
|
+
obsidian-server token list [--data-dir <path>]
|
|
103
|
+
obsidian-server token revoke --id <id> [--data-dir <path>]
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
- `mint` prints the new key's plaintext **once** — it is hashed before being stored and cannot be displayed again.
|
|
107
|
+
- `list` prints metadata only (id, name, enabled, created/expires/last-used timestamps) — never a key's secret or hash.
|
|
108
|
+
- `revoke` disables a key by id (soft-delete; it stays in `list` for an audit trail, just as `enabled=false`).
|
|
109
|
+
|
|
110
|
+
## REST endpoints
|
|
111
|
+
|
|
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. |
|
|
116
|
+
| `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
|
+
|
|
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).
|
|
119
|
+
|
|
120
|
+
## MCP
|
|
121
|
+
|
|
122
|
+
`/mcp` (Bearer-protected, Streamable HTTP) exposes one MCP tool per exposed Obsidian CLI command, plus one discovery tool:
|
|
123
|
+
|
|
124
|
+
- **`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
|
+
- **`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
|
+
|
|
127
|
+
## Safety gating model
|
|
128
|
+
|
|
129
|
+
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
|
+
|
|
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. |
|
|
137
|
+
|
|
138
|
+
`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
|
+
|
|
140
|
+
## Security
|
|
141
|
+
|
|
142
|
+
- **Binds to `127.0.0.1` by default.** Only requests from the same machine can reach it.
|
|
143
|
+
- **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.
|
|
146
|
+
|
|
147
|
+
## License
|
|
148
|
+
|
|
149
|
+
MIT — see [LICENSE](./LICENSE).
|
package/cli.js
ADDED
|
@@ -0,0 +1,1090 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
// @bun
|
|
3
|
+
|
|
4
|
+
// apps/server/src/cli.ts
|
|
5
|
+
import { parseArgs } from "util";
|
|
6
|
+
|
|
7
|
+
// apps/server/src/gating.ts
|
|
8
|
+
var DEFAULT_CLASSIFICATIONS = ["read", "write"];
|
|
9
|
+
var GATED_CLASSIFICATIONS = ["destructive", "app-control"];
|
|
10
|
+
|
|
11
|
+
class UnknownAllowFlagError extends Error {
|
|
12
|
+
value;
|
|
13
|
+
constructor(value) {
|
|
14
|
+
super(`Unknown --allow value "${value}" (expected one of: ${GATED_CLASSIFICATIONS.join(", ")})`);
|
|
15
|
+
this.value = value;
|
|
16
|
+
this.name = "UnknownAllowFlagError";
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
function isGatedClassification(value) {
|
|
20
|
+
return GATED_CLASSIFICATIONS.includes(value);
|
|
21
|
+
}
|
|
22
|
+
function computeAllowedClassifications(allowFlags) {
|
|
23
|
+
const allowed = new Set(DEFAULT_CLASSIFICATIONS);
|
|
24
|
+
for (const flag of allowFlags) {
|
|
25
|
+
if (!isGatedClassification(flag))
|
|
26
|
+
throw new UnknownAllowFlagError(flag);
|
|
27
|
+
allowed.add(flag);
|
|
28
|
+
}
|
|
29
|
+
return allowed;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// apps/server/src/serve.ts
|
|
33
|
+
import { mkdirSync as mkdirSync2 } from "fs";
|
|
34
|
+
import { join as join2 } from "path";
|
|
35
|
+
|
|
36
|
+
// packages/auth/src/config.ts
|
|
37
|
+
import { mkdirSync } from "fs";
|
|
38
|
+
import { dirname } from "path";
|
|
39
|
+
import { Database } from "bun:sqlite";
|
|
40
|
+
import { betterAuth } from "better-auth";
|
|
41
|
+
import { apiKey } from "@better-auth/api-key";
|
|
42
|
+
|
|
43
|
+
// packages/auth/src/bearer.ts
|
|
44
|
+
var BEARER_PATTERN = /^Bearer\s+(.+)$/i;
|
|
45
|
+
function extractBearerToken(headerValue) {
|
|
46
|
+
if (!headerValue)
|
|
47
|
+
return null;
|
|
48
|
+
const match = BEARER_PATTERN.exec(headerValue.trim());
|
|
49
|
+
const token = match?.[1]?.trim();
|
|
50
|
+
return token ? token : null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// packages/auth/src/config.ts
|
|
54
|
+
function ensureParentDirExists(dbPath) {
|
|
55
|
+
if (dbPath === ":memory:" || dbPath.startsWith("file::memory:"))
|
|
56
|
+
return;
|
|
57
|
+
mkdirSync(dirname(dbPath), { recursive: true });
|
|
58
|
+
}
|
|
59
|
+
function createAuth(options) {
|
|
60
|
+
ensureParentDirExists(options.dbPath);
|
|
61
|
+
const database = new Database(options.dbPath, { create: true });
|
|
62
|
+
return betterAuth({
|
|
63
|
+
database,
|
|
64
|
+
baseURL: options.baseURL,
|
|
65
|
+
secret: options.secret,
|
|
66
|
+
emailAndPassword: {
|
|
67
|
+
enabled: false,
|
|
68
|
+
disableSignUp: true
|
|
69
|
+
},
|
|
70
|
+
plugins: [
|
|
71
|
+
apiKey({
|
|
72
|
+
requireName: true,
|
|
73
|
+
rateLimit: { enabled: false },
|
|
74
|
+
customAPIKeyGetter: (ctx) => extractBearerToken(ctx.headers?.get("authorization") ?? null)
|
|
75
|
+
})
|
|
76
|
+
]
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
// packages/auth/src/migrate.ts
|
|
80
|
+
async function migrate(auth) {
|
|
81
|
+
const ctx = await auth.$context;
|
|
82
|
+
await ctx.runMigrations();
|
|
83
|
+
}
|
|
84
|
+
// packages/auth/src/owner.ts
|
|
85
|
+
var DEFAULT_OWNER_EMAIL = "owner@obsidian-server.local";
|
|
86
|
+
var DEFAULT_OWNER_NAME = "Owner";
|
|
87
|
+
async function bootstrap(auth, options = {}) {
|
|
88
|
+
await migrate(auth);
|
|
89
|
+
const ctx = await auth.$context;
|
|
90
|
+
const existing = await ctx.adapter.findMany({ model: "user", limit: 1 });
|
|
91
|
+
const existingUser = existing[0];
|
|
92
|
+
if (existingUser) {
|
|
93
|
+
return { id: existingUser.id, email: existingUser.email, name: existingUser.name, created: false };
|
|
94
|
+
}
|
|
95
|
+
const email = options.email ?? DEFAULT_OWNER_EMAIL;
|
|
96
|
+
const name = options.name ?? DEFAULT_OWNER_NAME;
|
|
97
|
+
const user = await ctx.internalAdapter.createUser({
|
|
98
|
+
email,
|
|
99
|
+
name,
|
|
100
|
+
emailVerified: true
|
|
101
|
+
});
|
|
102
|
+
return { id: user.id, email: user.email, name: user.name, created: true };
|
|
103
|
+
}
|
|
104
|
+
async function resolveOwnerId(auth) {
|
|
105
|
+
const ctx = await auth.$context;
|
|
106
|
+
const existing = await ctx.adapter.findMany({ model: "user", limit: 1 });
|
|
107
|
+
const owner = existing[0];
|
|
108
|
+
if (!owner) {
|
|
109
|
+
throw new Error("No owner user found. Run bootstrap() (or the `bootstrap` CLI command) first.");
|
|
110
|
+
}
|
|
111
|
+
return owner.id;
|
|
112
|
+
}
|
|
113
|
+
// packages/auth/src/keys.ts
|
|
114
|
+
var SECONDS_PER_DAY = 24 * 60 * 60;
|
|
115
|
+
async function mintKey(auth, options) {
|
|
116
|
+
const userId = options.userId ?? await resolveOwnerId(auth);
|
|
117
|
+
const expiresIn = options.expiresDays !== undefined ? options.expiresDays * SECONDS_PER_DAY : undefined;
|
|
118
|
+
const created = await auth.api.createApiKey({
|
|
119
|
+
body: {
|
|
120
|
+
userId,
|
|
121
|
+
name: options.name,
|
|
122
|
+
expiresIn
|
|
123
|
+
}
|
|
124
|
+
});
|
|
125
|
+
return {
|
|
126
|
+
id: created.id,
|
|
127
|
+
key: created.key,
|
|
128
|
+
name: created.name,
|
|
129
|
+
expiresAt: created.expiresAt
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
var SAFE_KEY_COLUMNS = ["id", "name", "start", "enabled", "createdAt", "expiresAt", "lastRequest", "referenceId"];
|
|
133
|
+
async function listKeys(auth, options = {}) {
|
|
134
|
+
const userId = options.userId ?? await resolveOwnerId(auth);
|
|
135
|
+
const ctx = await auth.$context;
|
|
136
|
+
const rows = await ctx.adapter.findMany({
|
|
137
|
+
model: "apikey",
|
|
138
|
+
where: [{ field: "referenceId", value: userId }],
|
|
139
|
+
sortBy: { field: "createdAt", direction: "desc" },
|
|
140
|
+
select: SAFE_KEY_COLUMNS
|
|
141
|
+
});
|
|
142
|
+
return rows.map((row) => ({
|
|
143
|
+
id: row.id,
|
|
144
|
+
name: row.name,
|
|
145
|
+
start: row.start,
|
|
146
|
+
enabled: row.enabled,
|
|
147
|
+
createdAt: row.createdAt,
|
|
148
|
+
expiresAt: row.expiresAt,
|
|
149
|
+
lastRequest: row.lastRequest
|
|
150
|
+
}));
|
|
151
|
+
}
|
|
152
|
+
async function revokeKey(auth, options) {
|
|
153
|
+
const ctx = await auth.$context;
|
|
154
|
+
const updated = await ctx.adapter.update({
|
|
155
|
+
model: "apikey",
|
|
156
|
+
where: [{ field: "id", value: options.id }],
|
|
157
|
+
update: { enabled: false }
|
|
158
|
+
});
|
|
159
|
+
if (!updated) {
|
|
160
|
+
throw new Error(`No API key found with id "${options.id}".`);
|
|
161
|
+
}
|
|
162
|
+
return { id: options.id, revoked: true };
|
|
163
|
+
}
|
|
164
|
+
async function ensureOwnerAndKey(auth, options = {}) {
|
|
165
|
+
const owner = await bootstrap(auth, { name: options.name, email: options.email });
|
|
166
|
+
const existingKeys = await listKeys(auth, { userId: owner.id });
|
|
167
|
+
const now = Date.now();
|
|
168
|
+
const active = existingKeys.find((k) => k.enabled && (k.expiresAt === null || k.expiresAt.getTime() > now));
|
|
169
|
+
if (active) {
|
|
170
|
+
return { created: false, keyId: active.id };
|
|
171
|
+
}
|
|
172
|
+
const minted = await mintKey(auth, { userId: owner.id, name: options.keyName ?? "bootstrap" });
|
|
173
|
+
return { created: true, key: minted.key, keyId: minted.id };
|
|
174
|
+
}
|
|
175
|
+
// packages/auth/src/middleware.ts
|
|
176
|
+
function unauthorized(c) {
|
|
177
|
+
c.header("WWW-Authenticate", "Bearer");
|
|
178
|
+
return c.json({ error: "unauthorized" }, 401);
|
|
179
|
+
}
|
|
180
|
+
function requireAuth(auth) {
|
|
181
|
+
return async (c, next) => {
|
|
182
|
+
const token = extractBearerToken(c.req.header("authorization"));
|
|
183
|
+
if (!token) {
|
|
184
|
+
return unauthorized(c);
|
|
185
|
+
}
|
|
186
|
+
let result;
|
|
187
|
+
try {
|
|
188
|
+
result = await auth.api.verifyApiKey({ body: { key: token } });
|
|
189
|
+
} catch {
|
|
190
|
+
return unauthorized(c);
|
|
191
|
+
}
|
|
192
|
+
if (!result.valid || !result.key) {
|
|
193
|
+
return unauthorized(c);
|
|
194
|
+
}
|
|
195
|
+
const ctx = await auth.$context;
|
|
196
|
+
const user = await ctx.internalAdapter.findUserById(result.key.referenceId);
|
|
197
|
+
if (!user) {
|
|
198
|
+
return unauthorized(c);
|
|
199
|
+
}
|
|
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();
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
// packages/obsidian-cli/src/classify.ts
|
|
206
|
+
var CLASSIFICATION = {
|
|
207
|
+
aliases: "read",
|
|
208
|
+
backlinks: "read",
|
|
209
|
+
"base:query": "read",
|
|
210
|
+
"base:views": "read",
|
|
211
|
+
bases: "read",
|
|
212
|
+
bookmarks: "read",
|
|
213
|
+
commands: "read",
|
|
214
|
+
"daily:path": "read",
|
|
215
|
+
"daily:read": "read",
|
|
216
|
+
deadends: "read",
|
|
217
|
+
diff: "read",
|
|
218
|
+
file: "read",
|
|
219
|
+
files: "read",
|
|
220
|
+
folder: "read",
|
|
221
|
+
folders: "read",
|
|
222
|
+
help: "read",
|
|
223
|
+
history: "read",
|
|
224
|
+
"history:list": "read",
|
|
225
|
+
"history:read": "read",
|
|
226
|
+
hotkey: "read",
|
|
227
|
+
hotkeys: "read",
|
|
228
|
+
links: "read",
|
|
229
|
+
orphans: "read",
|
|
230
|
+
outline: "read",
|
|
231
|
+
plugin: "read",
|
|
232
|
+
plugins: "read",
|
|
233
|
+
"plugins:enabled": "read",
|
|
234
|
+
properties: "read",
|
|
235
|
+
"property:read": "read",
|
|
236
|
+
"random:read": "read",
|
|
237
|
+
read: "read",
|
|
238
|
+
recents: "read",
|
|
239
|
+
search: "read",
|
|
240
|
+
"search:context": "read",
|
|
241
|
+
snippets: "read",
|
|
242
|
+
"snippets:enabled": "read",
|
|
243
|
+
"sync:deleted": "read",
|
|
244
|
+
"sync:history": "read",
|
|
245
|
+
"sync:read": "read",
|
|
246
|
+
"sync:status": "read",
|
|
247
|
+
tabs: "read",
|
|
248
|
+
tag: "read",
|
|
249
|
+
tags: "read",
|
|
250
|
+
tasks: "read",
|
|
251
|
+
"template:read": "read",
|
|
252
|
+
templates: "read",
|
|
253
|
+
theme: "read",
|
|
254
|
+
themes: "read",
|
|
255
|
+
unresolved: "read",
|
|
256
|
+
vault: "read",
|
|
257
|
+
vaults: "read",
|
|
258
|
+
version: "read",
|
|
259
|
+
wordcount: "read",
|
|
260
|
+
workspace: "read",
|
|
261
|
+
append: "write",
|
|
262
|
+
"base:create": "write",
|
|
263
|
+
create: "write",
|
|
264
|
+
"daily:append": "write",
|
|
265
|
+
"daily:prepend": "write",
|
|
266
|
+
move: "write",
|
|
267
|
+
prepend: "write",
|
|
268
|
+
"property:set": "write",
|
|
269
|
+
rename: "write",
|
|
270
|
+
task: "write",
|
|
271
|
+
"template:insert": "write",
|
|
272
|
+
delete: "destructive",
|
|
273
|
+
"history:restore": "destructive",
|
|
274
|
+
"property:remove": "destructive",
|
|
275
|
+
"sync:restore": "destructive",
|
|
276
|
+
bookmark: "app-control",
|
|
277
|
+
command: "app-control",
|
|
278
|
+
daily: "app-control",
|
|
279
|
+
"history:open": "app-control",
|
|
280
|
+
open: "app-control",
|
|
281
|
+
"plugin:disable": "app-control",
|
|
282
|
+
"plugin:enable": "app-control",
|
|
283
|
+
"plugin:install": "app-control",
|
|
284
|
+
"plugin:reload": "app-control",
|
|
285
|
+
"plugin:uninstall": "app-control",
|
|
286
|
+
"plugins:restrict": "app-control",
|
|
287
|
+
random: "app-control",
|
|
288
|
+
reload: "app-control",
|
|
289
|
+
restart: "app-control",
|
|
290
|
+
"search:open": "app-control",
|
|
291
|
+
"snippet:disable": "app-control",
|
|
292
|
+
"snippet:enable": "app-control",
|
|
293
|
+
sync: "app-control",
|
|
294
|
+
"sync:open": "app-control",
|
|
295
|
+
"tab:open": "app-control",
|
|
296
|
+
"theme:install": "app-control",
|
|
297
|
+
"theme:set": "app-control",
|
|
298
|
+
"theme:uninstall": "app-control",
|
|
299
|
+
"dev:cdp": "app-control",
|
|
300
|
+
"dev:console": "app-control",
|
|
301
|
+
"dev:css": "app-control",
|
|
302
|
+
"dev:debug": "app-control",
|
|
303
|
+
"dev:dom": "app-control",
|
|
304
|
+
"dev:errors": "app-control",
|
|
305
|
+
"dev:mobile": "app-control",
|
|
306
|
+
"dev:screenshot": "app-control",
|
|
307
|
+
devtools: "app-control",
|
|
308
|
+
eval: "app-control"
|
|
309
|
+
};
|
|
310
|
+
function classifyCommand(name) {
|
|
311
|
+
return CLASSIFICATION[name] ?? "app-control";
|
|
312
|
+
}
|
|
313
|
+
// packages/obsidian-cli/src/help-parser.ts
|
|
314
|
+
function parseOptionToken(token) {
|
|
315
|
+
const kv = /^([^\s=]+)=(.+)$/.exec(token);
|
|
316
|
+
if (kv) {
|
|
317
|
+
const name = kv[1] ?? "";
|
|
318
|
+
const hintRaw = kv[2] ?? "";
|
|
319
|
+
const quoted = /^"(.*)"$/.exec(hintRaw);
|
|
320
|
+
const valueHint = quoted ? quoted[1] ?? "" : hintRaw;
|
|
321
|
+
return { name, kind: "string", valueHint };
|
|
322
|
+
}
|
|
323
|
+
const positional = /^<(.+)>$/.exec(token);
|
|
324
|
+
if (positional) {
|
|
325
|
+
return { name: positional[1] ?? "", kind: "string", valueHint: token };
|
|
326
|
+
}
|
|
327
|
+
return { name: token, kind: "boolean" };
|
|
328
|
+
}
|
|
329
|
+
function splitRequired(rawDescription) {
|
|
330
|
+
const trimmed = rawDescription.trim();
|
|
331
|
+
const match = /^(.*?)\s*\(required\)\s*$/.exec(trimmed);
|
|
332
|
+
if (match) {
|
|
333
|
+
return { description: (match[1] ?? "").trim(), required: true };
|
|
334
|
+
}
|
|
335
|
+
return { description: trimmed, required: false };
|
|
336
|
+
}
|
|
337
|
+
function parseLabeledLine(trimmedLine, dashSeparated) {
|
|
338
|
+
const pattern = dashSeparated ? /^(\S+)\s+-\s+(.*)$/ : /^(\S+)\s{2,}(.*)$/;
|
|
339
|
+
const match = pattern.exec(trimmedLine);
|
|
340
|
+
if (!match)
|
|
341
|
+
return null;
|
|
342
|
+
return { token: match[1] ?? "", rest: match[2] ?? "" };
|
|
343
|
+
}
|
|
344
|
+
function parseHelp(helpText) {
|
|
345
|
+
const lines = helpText.split(/\r?\n/);
|
|
346
|
+
const globalOptions = [];
|
|
347
|
+
const commands = [];
|
|
348
|
+
let section = "none";
|
|
349
|
+
let current = null;
|
|
350
|
+
for (const rawLine of lines) {
|
|
351
|
+
if (rawLine.trim() === "")
|
|
352
|
+
continue;
|
|
353
|
+
const leading = rawLine.length - rawLine.trimStart().length;
|
|
354
|
+
const trimmed = rawLine.trim();
|
|
355
|
+
if (leading === 0) {
|
|
356
|
+
if (trimmed === "Options:")
|
|
357
|
+
section = "options";
|
|
358
|
+
else if (trimmed === "Notes:")
|
|
359
|
+
section = "notes";
|
|
360
|
+
else if (trimmed === "Commands:")
|
|
361
|
+
section = "commands";
|
|
362
|
+
else if (trimmed === "Developer:")
|
|
363
|
+
section = "commands";
|
|
364
|
+
else
|
|
365
|
+
section = "none";
|
|
366
|
+
current = null;
|
|
367
|
+
continue;
|
|
368
|
+
}
|
|
369
|
+
if (section === "options" && leading === 2) {
|
|
370
|
+
const parsed = parseLabeledLine(trimmed, false);
|
|
371
|
+
if (!parsed)
|
|
372
|
+
continue;
|
|
373
|
+
const { description, required } = splitRequired(parsed.rest);
|
|
374
|
+
globalOptions.push({ ...parseOptionToken(parsed.token), description, required });
|
|
375
|
+
continue;
|
|
376
|
+
}
|
|
377
|
+
if (section !== "commands")
|
|
378
|
+
continue;
|
|
379
|
+
if (leading === 2) {
|
|
380
|
+
const parsed = parseLabeledLine(trimmed, false);
|
|
381
|
+
if (!parsed)
|
|
382
|
+
continue;
|
|
383
|
+
const { description } = splitRequired(parsed.rest);
|
|
384
|
+
current = { name: parsed.token, description, options: [] };
|
|
385
|
+
commands.push(current);
|
|
386
|
+
continue;
|
|
387
|
+
}
|
|
388
|
+
if (leading >= 4 && current) {
|
|
389
|
+
const parsed = parseLabeledLine(trimmed, true);
|
|
390
|
+
if (!parsed)
|
|
391
|
+
continue;
|
|
392
|
+
const { description, required } = splitRequired(parsed.rest);
|
|
393
|
+
current.options.push({ ...parseOptionToken(parsed.token), description, required });
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
return { commands, globalOptions };
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// packages/obsidian-cli/src/errors.ts
|
|
400
|
+
class ObsidianNotFoundError extends Error {
|
|
401
|
+
binary;
|
|
402
|
+
constructor(binary, cause) {
|
|
403
|
+
super(`Obsidian CLI binary not found or not executable: ${binary}`);
|
|
404
|
+
this.name = "ObsidianNotFoundError";
|
|
405
|
+
this.binary = binary;
|
|
406
|
+
if (cause !== undefined)
|
|
407
|
+
this.cause = cause;
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
class ObsidianCommandError extends Error {
|
|
412
|
+
command;
|
|
413
|
+
argv;
|
|
414
|
+
exitCode;
|
|
415
|
+
stdout;
|
|
416
|
+
stderr;
|
|
417
|
+
constructor(command, argv, exitCode, stdout, stderr) {
|
|
418
|
+
const detail = (stderr.trim() || stdout.trim() || "no output").slice(0, 500);
|
|
419
|
+
super(`obsidian ${command} failed (exit ${exitCode}): ${detail}`);
|
|
420
|
+
this.name = "ObsidianCommandError";
|
|
421
|
+
this.command = command;
|
|
422
|
+
this.argv = argv;
|
|
423
|
+
this.exitCode = exitCode;
|
|
424
|
+
this.stdout = stdout;
|
|
425
|
+
this.stderr = stderr;
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
class ObsidianTimeoutError extends Error {
|
|
430
|
+
command;
|
|
431
|
+
timeoutMs;
|
|
432
|
+
constructor(command, timeoutMs) {
|
|
433
|
+
super(`obsidian ${command} timed out after ${timeoutMs}ms`);
|
|
434
|
+
this.name = "ObsidianTimeoutError";
|
|
435
|
+
this.command = command;
|
|
436
|
+
this.timeoutMs = timeoutMs;
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
class UnknownCommandError extends Error {
|
|
441
|
+
command;
|
|
442
|
+
constructor(command) {
|
|
443
|
+
super(`Unknown obsidian command: ${command}`);
|
|
444
|
+
this.name = "UnknownCommandError";
|
|
445
|
+
this.command = command;
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
class UnknownOptionError extends Error {
|
|
450
|
+
command;
|
|
451
|
+
option;
|
|
452
|
+
constructor(command, option) {
|
|
453
|
+
super(`Unknown option "${option}" for command "${command}"`);
|
|
454
|
+
this.name = "UnknownOptionError";
|
|
455
|
+
this.command = command;
|
|
456
|
+
this.option = option;
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
class MissingRequiredOptionError extends Error {
|
|
461
|
+
command;
|
|
462
|
+
option;
|
|
463
|
+
constructor(command, option) {
|
|
464
|
+
super(`Missing required option "${option}" for command "${command}"`);
|
|
465
|
+
this.name = "MissingRequiredOptionError";
|
|
466
|
+
this.command = command;
|
|
467
|
+
this.option = option;
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
// packages/obsidian-cli/src/serialize.ts
|
|
472
|
+
function escapeValue(value) {
|
|
473
|
+
return value.replace(/\n/g, "\\n").replace(/\t/g, "\\t");
|
|
474
|
+
}
|
|
475
|
+
function serializeOption(opt, value) {
|
|
476
|
+
if (typeof value === "boolean") {
|
|
477
|
+
return value ? opt.name : null;
|
|
478
|
+
}
|
|
479
|
+
const stringValue = typeof value === "number" ? String(value) : escapeValue(value);
|
|
480
|
+
if (opt.valueHint === `<${opt.name}>`) {
|
|
481
|
+
return stringValue;
|
|
482
|
+
}
|
|
483
|
+
return `${opt.name}=${stringValue}`;
|
|
484
|
+
}
|
|
485
|
+
function buildArgv(cmd, options = {}, opts = {}) {
|
|
486
|
+
const optionsByName = new Map(cmd.options.map((option) => [option.name, option]));
|
|
487
|
+
for (const key of Object.keys(options)) {
|
|
488
|
+
if (!optionsByName.has(key)) {
|
|
489
|
+
throw new UnknownOptionError(cmd.name, key);
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
for (const option of cmd.options) {
|
|
493
|
+
if (option.required && options[option.name] === undefined) {
|
|
494
|
+
throw new MissingRequiredOptionError(cmd.name, option.name);
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
const argv = [cmd.name];
|
|
498
|
+
if (opts.vault !== undefined) {
|
|
499
|
+
argv.push(`vault=${escapeValue(opts.vault)}`);
|
|
500
|
+
}
|
|
501
|
+
for (const [key, value] of Object.entries(options)) {
|
|
502
|
+
const option = optionsByName.get(key);
|
|
503
|
+
if (!option)
|
|
504
|
+
continue;
|
|
505
|
+
const serialized = serializeOption(option, value);
|
|
506
|
+
if (serialized !== null)
|
|
507
|
+
argv.push(serialized);
|
|
508
|
+
}
|
|
509
|
+
return argv;
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
// packages/obsidian-cli/src/client.ts
|
|
513
|
+
var DEFAULT_BINARY = "obsidian";
|
|
514
|
+
var DEFAULT_TIMEOUT_MS = 15000;
|
|
515
|
+
var schemaCache = new Map;
|
|
516
|
+
async function spawnObsidian(binary, argv, timeoutMs) {
|
|
517
|
+
let proc;
|
|
518
|
+
try {
|
|
519
|
+
proc = Bun.spawn([binary, ...argv], { stdout: "pipe", stderr: "pipe", stdin: "ignore" });
|
|
520
|
+
} catch (err) {
|
|
521
|
+
throw new ObsidianNotFoundError(binary, err);
|
|
522
|
+
}
|
|
523
|
+
let timedOut = false;
|
|
524
|
+
const timer = setTimeout(() => {
|
|
525
|
+
timedOut = true;
|
|
526
|
+
proc.kill();
|
|
527
|
+
}, timeoutMs);
|
|
528
|
+
let stdout;
|
|
529
|
+
let stderr;
|
|
530
|
+
let exitCode;
|
|
531
|
+
try {
|
|
532
|
+
[stdout, stderr, exitCode] = await Promise.all([
|
|
533
|
+
new Response(proc.stdout).text(),
|
|
534
|
+
new Response(proc.stderr).text(),
|
|
535
|
+
proc.exited
|
|
536
|
+
]);
|
|
537
|
+
} finally {
|
|
538
|
+
clearTimeout(timer);
|
|
539
|
+
}
|
|
540
|
+
const label = argv[0] ?? binary;
|
|
541
|
+
if (timedOut) {
|
|
542
|
+
throw new ObsidianTimeoutError(label, timeoutMs);
|
|
543
|
+
}
|
|
544
|
+
if (exitCode !== 0) {
|
|
545
|
+
throw new ObsidianCommandError(label, argv, exitCode, stdout, stderr);
|
|
546
|
+
}
|
|
547
|
+
if (stdout.trimStart().startsWith("Error:")) {
|
|
548
|
+
throw new ObsidianCommandError(label, argv, exitCode, stdout, stderr);
|
|
549
|
+
}
|
|
550
|
+
return { stdout, stderr, exitCode };
|
|
551
|
+
}
|
|
552
|
+
async function loadSchema(binary = DEFAULT_BINARY, opts = {}) {
|
|
553
|
+
if (!opts.fresh) {
|
|
554
|
+
const cached = schemaCache.get(binary);
|
|
555
|
+
if (cached)
|
|
556
|
+
return cached;
|
|
557
|
+
}
|
|
558
|
+
const { stdout } = await spawnObsidian(binary, ["help"], DEFAULT_TIMEOUT_MS);
|
|
559
|
+
const schema = parseHelp(stdout);
|
|
560
|
+
schemaCache.set(binary, schema);
|
|
561
|
+
return schema;
|
|
562
|
+
}
|
|
563
|
+
async function createObsidianClient(opts = {}) {
|
|
564
|
+
const binary = opts.binary ?? DEFAULT_BINARY;
|
|
565
|
+
const vault = opts.vault;
|
|
566
|
+
const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
567
|
+
let schema = await loadSchema(binary);
|
|
568
|
+
async function refreshSchema() {
|
|
569
|
+
schema = await loadSchema(binary, { fresh: true });
|
|
570
|
+
return schema;
|
|
571
|
+
}
|
|
572
|
+
async function run(command, options = {}) {
|
|
573
|
+
const cmd = schema.commands.find((candidate) => candidate.name === command);
|
|
574
|
+
if (!cmd)
|
|
575
|
+
throw new UnknownCommandError(command);
|
|
576
|
+
const argv = buildArgv(cmd, options, { vault });
|
|
577
|
+
return spawnObsidian(binary, argv, timeoutMs);
|
|
578
|
+
}
|
|
579
|
+
return {
|
|
580
|
+
binary,
|
|
581
|
+
vault,
|
|
582
|
+
get schema() {
|
|
583
|
+
return schema;
|
|
584
|
+
},
|
|
585
|
+
refreshSchema,
|
|
586
|
+
run
|
|
587
|
+
};
|
|
588
|
+
}
|
|
589
|
+
// packages/obsidian-cli/src/zod.ts
|
|
590
|
+
import { z } from "zod";
|
|
591
|
+
function zodForOption(option) {
|
|
592
|
+
const base = option.kind === "boolean" ? z.boolean() : z.string();
|
|
593
|
+
const described = option.description ? base.describe(option.description) : base;
|
|
594
|
+
return option.required ? described : described.optional();
|
|
595
|
+
}
|
|
596
|
+
function commandToZodShape(cmd) {
|
|
597
|
+
const shape = {};
|
|
598
|
+
for (const option of cmd.options) {
|
|
599
|
+
shape[option.name] = zodForOption(option);
|
|
600
|
+
}
|
|
601
|
+
return z.object(shape);
|
|
602
|
+
}
|
|
603
|
+
// apps/server/src/app.ts
|
|
604
|
+
import { createMcpHonoApp } from "@modelcontextprotocol/hono";
|
|
605
|
+
import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/server";
|
|
606
|
+
|
|
607
|
+
// apps/server/src/exposed-commands.ts
|
|
608
|
+
function exposedCommands(commands, allowed) {
|
|
609
|
+
return commands.map((command) => ({ command, classification: classifyCommand(command.name) })).filter((entry) => allowed.has(entry.classification));
|
|
610
|
+
}
|
|
611
|
+
function countExposedByClassification(commands, allowed) {
|
|
612
|
+
const counts = { read: 0, write: 0, destructive: 0, "app-control": 0 };
|
|
613
|
+
for (const entry of exposedCommands(commands, allowed)) {
|
|
614
|
+
counts[entry.classification] += 1;
|
|
615
|
+
}
|
|
616
|
+
return counts;
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
// apps/server/src/mcp.ts
|
|
620
|
+
import { McpServer } from "@modelcontextprotocol/server";
|
|
621
|
+
import { z as z2 } from "zod";
|
|
622
|
+
|
|
623
|
+
// apps/server/src/naming.ts
|
|
624
|
+
function commandToToolName(command) {
|
|
625
|
+
return `obsidian_${command.replace(/:/g, "_")}`;
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
// apps/server/src/mcp.ts
|
|
629
|
+
var SERVER_NAME = "obsidian-server";
|
|
630
|
+
var SERVER_VERSION = "0.1.0";
|
|
631
|
+
function buildMcpServer(options) {
|
|
632
|
+
const { client, allowed } = options;
|
|
633
|
+
const server = new McpServer({ name: SERVER_NAME, version: SERVER_VERSION });
|
|
634
|
+
const registered = new Set;
|
|
635
|
+
for (const { command, classification } of exposedCommands(client.schema.commands, allowed)) {
|
|
636
|
+
const toolName = commandToToolName(command.name);
|
|
637
|
+
if (registered.has(toolName)) {
|
|
638
|
+
console.warn(`skipping command "${command.name}": tool name ${toolName} already registered`);
|
|
639
|
+
continue;
|
|
640
|
+
}
|
|
641
|
+
registered.add(toolName);
|
|
642
|
+
const description = `${command.description} [${classification}]`;
|
|
643
|
+
const inputSchema = commandToZodShape(command);
|
|
644
|
+
server.registerTool(toolName, { title: command.name, description, inputSchema }, async (args) => {
|
|
645
|
+
try {
|
|
646
|
+
const result = await client.run(command.name, args);
|
|
647
|
+
return { content: [{ type: "text", text: result.stdout || "(empty)" }] };
|
|
648
|
+
} catch (err) {
|
|
649
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
650
|
+
return { content: [{ type: "text", text: message }], isError: true };
|
|
651
|
+
}
|
|
652
|
+
});
|
|
653
|
+
}
|
|
654
|
+
server.registerTool("server_commands", {
|
|
655
|
+
title: "server_commands",
|
|
656
|
+
description: "List every obsidian command exposed by this server, with its classification and options.",
|
|
657
|
+
inputSchema: z2.object({})
|
|
658
|
+
}, async () => {
|
|
659
|
+
const commands = exposedCommands(client.schema.commands, allowed).map(({ command, classification }) => ({
|
|
660
|
+
name: command.name,
|
|
661
|
+
toolName: commandToToolName(command.name),
|
|
662
|
+
description: command.description,
|
|
663
|
+
classification,
|
|
664
|
+
options: command.options
|
|
665
|
+
}));
|
|
666
|
+
return {
|
|
667
|
+
content: [{ type: "text", text: JSON.stringify({ vault: client.vault, commands }, null, 2) }]
|
|
668
|
+
};
|
|
669
|
+
});
|
|
670
|
+
return server;
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
// apps/server/src/network.ts
|
|
674
|
+
var LOCALHOST_HOSTS = new Set(["127.0.0.1", "localhost", "::1"]);
|
|
675
|
+
function isLocalhostClass(host) {
|
|
676
|
+
return LOCALHOST_HOSTS.has(host);
|
|
677
|
+
}
|
|
678
|
+
function needsDnsRebindingWarning(host, allowedHosts) {
|
|
679
|
+
return !isLocalhostClass(host) && (!allowedHosts || allowedHosts.length === 0);
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
// apps/server/src/app.ts
|
|
683
|
+
function isOptionValue(value) {
|
|
684
|
+
return typeof value === "string" || typeof value === "number" || typeof value === "boolean";
|
|
685
|
+
}
|
|
686
|
+
async function parseCommandBody(req) {
|
|
687
|
+
const raw = await req.text();
|
|
688
|
+
if (raw.trim().length === 0)
|
|
689
|
+
return { ok: true, body: {} };
|
|
690
|
+
let parsed;
|
|
691
|
+
try {
|
|
692
|
+
parsed = JSON.parse(raw);
|
|
693
|
+
} catch {
|
|
694
|
+
return { ok: false, error: "Invalid JSON body" };
|
|
695
|
+
}
|
|
696
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
697
|
+
return { ok: false, error: "Request body must be a JSON object of option values" };
|
|
698
|
+
}
|
|
699
|
+
const body = {};
|
|
700
|
+
for (const [key, value] of Object.entries(parsed)) {
|
|
701
|
+
if (!isOptionValue(value)) {
|
|
702
|
+
return { ok: false, error: `Option "${key}" must be a string, number, or boolean` };
|
|
703
|
+
}
|
|
704
|
+
body[key] = value;
|
|
705
|
+
}
|
|
706
|
+
return { ok: true, body };
|
|
707
|
+
}
|
|
708
|
+
async function buildApp(options) {
|
|
709
|
+
const { client, auth, allowed } = options;
|
|
710
|
+
const warnings = [];
|
|
711
|
+
if (needsDnsRebindingWarning(options.host, options.allowedHosts)) {
|
|
712
|
+
warnings.push(`Binding to "${options.host}" without --allow-hosts/--allow-origins leaves DNS-rebinding protection ` + "disabled. Pass allowedHosts/allowedOrigins when binding beyond localhost.");
|
|
713
|
+
}
|
|
714
|
+
const app = createMcpHonoApp({
|
|
715
|
+
host: options.host,
|
|
716
|
+
allowedHosts: options.allowedHosts,
|
|
717
|
+
allowedOrigins: options.allowedOrigins
|
|
718
|
+
});
|
|
719
|
+
app.get("/health", async (c) => {
|
|
720
|
+
let reachable = true;
|
|
721
|
+
try {
|
|
722
|
+
await client.refreshSchema();
|
|
723
|
+
} catch {
|
|
724
|
+
reachable = false;
|
|
725
|
+
}
|
|
726
|
+
return c.json({ ok: true, obsidian: reachable, vault: client.vault ?? null });
|
|
727
|
+
});
|
|
728
|
+
const authMiddleware = requireAuth(auth);
|
|
729
|
+
app.use("/api/*", authMiddleware);
|
|
730
|
+
app.use("/mcp", authMiddleware);
|
|
731
|
+
app.get("/api/commands", (c) => {
|
|
732
|
+
const commands = exposedCommands(client.schema.commands, allowed).map(({ command, classification }) => ({
|
|
733
|
+
name: command.name,
|
|
734
|
+
description: command.description,
|
|
735
|
+
classification,
|
|
736
|
+
options: command.options
|
|
737
|
+
}));
|
|
738
|
+
return c.json({
|
|
739
|
+
total: client.schema.commands.length,
|
|
740
|
+
exposed: commands.length,
|
|
741
|
+
commands
|
|
742
|
+
});
|
|
743
|
+
});
|
|
744
|
+
app.post("/api/commands/:name", async (c) => {
|
|
745
|
+
const name = c.req.param("name");
|
|
746
|
+
const command = client.schema.commands.find((candidate) => candidate.name === name);
|
|
747
|
+
if (!command) {
|
|
748
|
+
return c.json({ ok: false, error: `Unknown obsidian command: ${name}` }, 404);
|
|
749
|
+
}
|
|
750
|
+
const classification = classifyCommand(name);
|
|
751
|
+
if (!allowed.has(classification)) {
|
|
752
|
+
return c.json({
|
|
753
|
+
ok: false,
|
|
754
|
+
error: `Command "${name}" is classified "${classification}" and is not exposed. Restart the server with --allow ${classification} to enable it.`
|
|
755
|
+
}, 403);
|
|
756
|
+
}
|
|
757
|
+
const parsedBody = await parseCommandBody(c.req.raw);
|
|
758
|
+
if (!parsedBody.ok) {
|
|
759
|
+
return c.json({ ok: false, error: parsedBody.error }, 400);
|
|
760
|
+
}
|
|
761
|
+
try {
|
|
762
|
+
const result = await client.run(name, parsedBody.body);
|
|
763
|
+
return c.json({ ok: true, stdout: result.stdout });
|
|
764
|
+
} catch (err) {
|
|
765
|
+
if (err instanceof UnknownCommandError) {
|
|
766
|
+
return c.json({ ok: false, error: err.message }, 404);
|
|
767
|
+
}
|
|
768
|
+
if (err instanceof UnknownOptionError || err instanceof MissingRequiredOptionError) {
|
|
769
|
+
return c.json({ ok: false, error: err.message }, 400);
|
|
770
|
+
}
|
|
771
|
+
if (err instanceof ObsidianTimeoutError) {
|
|
772
|
+
return c.json({ ok: false, error: err.message }, 504);
|
|
773
|
+
}
|
|
774
|
+
if (err instanceof ObsidianCommandError) {
|
|
775
|
+
return c.json({ ok: false, error: err.message }, 502);
|
|
776
|
+
}
|
|
777
|
+
throw err;
|
|
778
|
+
}
|
|
779
|
+
});
|
|
780
|
+
const mcpServer = buildMcpServer({ client, allowed });
|
|
781
|
+
const transport = new WebStandardStreamableHTTPServerTransport({ sessionIdGenerator: undefined });
|
|
782
|
+
await mcpServer.connect(transport);
|
|
783
|
+
app.all("/mcp", async (c) => {
|
|
784
|
+
const user = c.get("user");
|
|
785
|
+
const apiKey2 = c.get("apiKey");
|
|
786
|
+
const authInfo = {
|
|
787
|
+
token: apiKey2?.id ?? "",
|
|
788
|
+
clientId: apiKey2?.id ?? "unknown",
|
|
789
|
+
scopes: [],
|
|
790
|
+
extra: { user, apiKey: apiKey2 }
|
|
791
|
+
};
|
|
792
|
+
return transport.handleRequest(c.req.raw, { parsedBody: c.get("parsedBody"), authInfo });
|
|
793
|
+
});
|
|
794
|
+
return { app, warnings };
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
// apps/server/src/banner.ts
|
|
798
|
+
function restBaseUrl(host, port) {
|
|
799
|
+
return `http://${host}:${port}`;
|
|
800
|
+
}
|
|
801
|
+
function mcpUrl(host, port) {
|
|
802
|
+
return `${restBaseUrl(host, port)}/mcp`;
|
|
803
|
+
}
|
|
804
|
+
function renderMcpAddCommand(host, port, freshToken) {
|
|
805
|
+
const tokenPlaceholder = freshToken ?? "<token>";
|
|
806
|
+
return `claude mcp add --transport http obsidian ${mcpUrl(host, port)} --header "Authorization: Bearer ${tokenPlaceholder}"`;
|
|
807
|
+
}
|
|
808
|
+
var CLASSIFICATION_ORDER = [
|
|
809
|
+
"read",
|
|
810
|
+
"write",
|
|
811
|
+
"destructive",
|
|
812
|
+
"app-control"
|
|
813
|
+
];
|
|
814
|
+
function renderStartupBanner(input) {
|
|
815
|
+
const lines = [];
|
|
816
|
+
lines.push("obsidian-server is running");
|
|
817
|
+
lines.push(` vault: ${input.vault ?? "(active vault in the running Obsidian app)"}`);
|
|
818
|
+
lines.push(` REST base: ${restBaseUrl(input.host, input.port)}`);
|
|
819
|
+
lines.push(` MCP endpoint: ${mcpUrl(input.host, input.port)}`);
|
|
820
|
+
lines.push("");
|
|
821
|
+
lines.push(` commands exposed: ${input.totalExposed}/${input.totalCommands}`);
|
|
822
|
+
for (const classification of CLASSIFICATION_ORDER) {
|
|
823
|
+
const allowedMark = input.allowed.has(classification) ? "" : " (disabled - pass --allow " + classification + ")";
|
|
824
|
+
lines.push(` ${classification}: ${input.exposedCounts[classification]}${allowedMark}`);
|
|
825
|
+
}
|
|
826
|
+
lines.push("");
|
|
827
|
+
if (input.freshToken) {
|
|
828
|
+
lines.push(" A new API key was minted (shown ONLY ONCE - copy it now):");
|
|
829
|
+
lines.push(` ${input.freshToken}`);
|
|
830
|
+
} else {
|
|
831
|
+
lines.push(" Using an existing API key. Run `obsidian-server token mint --name <label>` for a new one.");
|
|
832
|
+
}
|
|
833
|
+
lines.push("");
|
|
834
|
+
lines.push(" Add this server to an MCP client:");
|
|
835
|
+
lines.push(` ${renderMcpAddCommand(input.host, input.port, input.freshToken)}`);
|
|
836
|
+
return lines.join(`
|
|
837
|
+
`);
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
// apps/server/src/paths.ts
|
|
841
|
+
import { join } from "path";
|
|
842
|
+
function resolveDataDir(env, override) {
|
|
843
|
+
if (override)
|
|
844
|
+
return override;
|
|
845
|
+
if (env.XDG_DATA_HOME)
|
|
846
|
+
return join(env.XDG_DATA_HOME, "obsidian-server");
|
|
847
|
+
if (!env.HOME) {
|
|
848
|
+
throw new Error("Cannot resolve a data directory: neither --data-dir, $XDG_DATA_HOME, nor $HOME is set.");
|
|
849
|
+
}
|
|
850
|
+
return join(env.HOME, ".local", "share", "obsidian-server");
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
// apps/server/src/serve.ts
|
|
854
|
+
var OBSIDIAN_NOT_FOUND_HINT = "Could not run the `obsidian` CLI. Make sure the Obsidian desktop app is running and its CLI companion " + "is installed and on PATH (or pass --binary <path>). See https://obsidian.md for the CLI plugin/binary.";
|
|
855
|
+
async function runServe(options) {
|
|
856
|
+
const dataDir = resolveDataDir(process.env, options.dataDir);
|
|
857
|
+
mkdirSync2(dataDir, { recursive: true });
|
|
858
|
+
const auth = createAuth({ dbPath: join2(dataDir, "auth.db"), baseURL: `http://${options.host}:${options.port}` });
|
|
859
|
+
await migrate(auth);
|
|
860
|
+
const ensured = await ensureOwnerAndKey(auth);
|
|
861
|
+
let client;
|
|
862
|
+
try {
|
|
863
|
+
client = await createObsidianClient({
|
|
864
|
+
binary: options.binary,
|
|
865
|
+
vault: options.vault,
|
|
866
|
+
timeoutMs: options.timeoutMs
|
|
867
|
+
});
|
|
868
|
+
} catch (err) {
|
|
869
|
+
if (err instanceof ObsidianNotFoundError) {
|
|
870
|
+
console.error(OBSIDIAN_NOT_FOUND_HINT);
|
|
871
|
+
process.exitCode = 1;
|
|
872
|
+
throw err;
|
|
873
|
+
}
|
|
874
|
+
throw err;
|
|
875
|
+
}
|
|
876
|
+
const allowed = computeAllowedClassifications(options.allow);
|
|
877
|
+
const { app, warnings } = await buildApp({
|
|
878
|
+
client,
|
|
879
|
+
auth,
|
|
880
|
+
allowed,
|
|
881
|
+
host: options.host,
|
|
882
|
+
allowedHosts: options.allowedHosts,
|
|
883
|
+
allowedOrigins: options.allowedOrigins
|
|
884
|
+
});
|
|
885
|
+
for (const warning of warnings)
|
|
886
|
+
console.warn(`warning: ${warning}`);
|
|
887
|
+
const banner = renderStartupBanner({
|
|
888
|
+
vault: client.vault,
|
|
889
|
+
host: options.host,
|
|
890
|
+
port: options.port,
|
|
891
|
+
allowed,
|
|
892
|
+
exposedCounts: countExposedByClassification(client.schema.commands, allowed),
|
|
893
|
+
totalExposed: exposedCommands(client.schema.commands, allowed).length,
|
|
894
|
+
totalCommands: client.schema.commands.length,
|
|
895
|
+
freshToken: ensured.created ? ensured.key : undefined
|
|
896
|
+
});
|
|
897
|
+
console.log(banner);
|
|
898
|
+
const server = Bun.serve({ fetch: app.fetch, port: options.port, hostname: options.host });
|
|
899
|
+
return { server, app, client, auth };
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
// apps/server/src/token.ts
|
|
903
|
+
import { join as join3 } from "path";
|
|
904
|
+
function authForDataDir(dataDir) {
|
|
905
|
+
const dir = resolveDataDir(process.env, dataDir);
|
|
906
|
+
return createAuth({ dbPath: join3(dir, "auth.db"), baseURL: "http://localhost" });
|
|
907
|
+
}
|
|
908
|
+
async function tokenMint(options) {
|
|
909
|
+
const auth = authForDataDir(options.dataDir);
|
|
910
|
+
await migrate(auth);
|
|
911
|
+
const owner = await bootstrap(auth);
|
|
912
|
+
const minted = await mintKey(auth, { name: options.name, expiresDays: options.expiresDays, userId: owner.id });
|
|
913
|
+
return { id: minted.id, key: minted.key, expiresAt: minted.expiresAt };
|
|
914
|
+
}
|
|
915
|
+
async function tokenList(options = {}) {
|
|
916
|
+
const auth = authForDataDir(options.dataDir);
|
|
917
|
+
await migrate(auth);
|
|
918
|
+
await bootstrap(auth);
|
|
919
|
+
return listKeys(auth);
|
|
920
|
+
}
|
|
921
|
+
async function tokenRevoke(options) {
|
|
922
|
+
const auth = authForDataDir(options.dataDir);
|
|
923
|
+
await migrate(auth);
|
|
924
|
+
const result = await revokeKey(auth, { id: options.id });
|
|
925
|
+
return { id: result.id };
|
|
926
|
+
}
|
|
927
|
+
function formatKeySummary(key) {
|
|
928
|
+
return [
|
|
929
|
+
`id=${key.id}`,
|
|
930
|
+
`name=${key.name ?? "(unnamed)"}`,
|
|
931
|
+
`enabled=${key.enabled}`,
|
|
932
|
+
`created=${key.createdAt.toISOString()}`,
|
|
933
|
+
`expires=${key.expiresAt ? key.expiresAt.toISOString() : "never"}`,
|
|
934
|
+
`lastUsed=${key.lastRequest ? key.lastRequest.toISOString() : "never"}`
|
|
935
|
+
].join("\t");
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
// apps/server/src/version.ts
|
|
939
|
+
var VERSION = "0.0.1";
|
|
940
|
+
|
|
941
|
+
// apps/server/src/cli.ts
|
|
942
|
+
var USAGE = [
|
|
943
|
+
"Usage: obsidian-server <command> [flags]",
|
|
944
|
+
"",
|
|
945
|
+
"Commands:",
|
|
946
|
+
" serve [--vault <name>] [--port 3000] [--host 127.0.0.1]",
|
|
947
|
+
" [--allow destructive] [--allow app-control]",
|
|
948
|
+
" [--data-dir <path>] [--binary <path>] [--timeout-ms <n>]",
|
|
949
|
+
" Start the MCP + REST server for a vault.",
|
|
950
|
+
"",
|
|
951
|
+
" token mint --name <label> [--expires-days <n>] [--data-dir <path>]",
|
|
952
|
+
" Mint a new API key (printed once).",
|
|
953
|
+
" token list [--data-dir <path>]",
|
|
954
|
+
" List API keys (no secrets).",
|
|
955
|
+
" token revoke --id <id> [--data-dir <path>]",
|
|
956
|
+
" Revoke (disable) an API key.",
|
|
957
|
+
"",
|
|
958
|
+
" --version, -v Print the obsidian-server version.",
|
|
959
|
+
" --help, -h Show this help."
|
|
960
|
+
].join(`
|
|
961
|
+
`);
|
|
962
|
+
function printUsage(stream = "stderr") {
|
|
963
|
+
if (stream === "stdout")
|
|
964
|
+
console.log(USAGE);
|
|
965
|
+
else
|
|
966
|
+
console.error(USAGE);
|
|
967
|
+
}
|
|
968
|
+
async function main() {
|
|
969
|
+
const [command, ...rest] = process.argv.slice(2);
|
|
970
|
+
if (command === "--version" || command === "-v") {
|
|
971
|
+
console.log(VERSION);
|
|
972
|
+
return;
|
|
973
|
+
}
|
|
974
|
+
if (command === "--help" || command === "-h" || command === "help") {
|
|
975
|
+
printUsage("stdout");
|
|
976
|
+
return;
|
|
977
|
+
}
|
|
978
|
+
if (command === "serve") {
|
|
979
|
+
const { values } = parseArgs({
|
|
980
|
+
args: rest,
|
|
981
|
+
options: {
|
|
982
|
+
vault: { type: "string" },
|
|
983
|
+
port: { type: "string", default: "3000" },
|
|
984
|
+
host: { type: "string", default: "127.0.0.1" },
|
|
985
|
+
allow: { type: "string", multiple: true, default: [] },
|
|
986
|
+
"data-dir": { type: "string" },
|
|
987
|
+
binary: { type: "string" },
|
|
988
|
+
"timeout-ms": { type: "string" }
|
|
989
|
+
},
|
|
990
|
+
allowPositionals: false
|
|
991
|
+
});
|
|
992
|
+
const port = Number(values.port);
|
|
993
|
+
if (!Number.isInteger(port) || port < 0) {
|
|
994
|
+
console.error(`--port must be a non-negative integer, got "${values.port}"`);
|
|
995
|
+
process.exitCode = 1;
|
|
996
|
+
return;
|
|
997
|
+
}
|
|
998
|
+
let timeoutMs;
|
|
999
|
+
if (values["timeout-ms"] !== undefined) {
|
|
1000
|
+
timeoutMs = Number(values["timeout-ms"]);
|
|
1001
|
+
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
|
|
1002
|
+
console.error(`--timeout-ms must be a positive number, got "${values["timeout-ms"]}"`);
|
|
1003
|
+
process.exitCode = 1;
|
|
1004
|
+
return;
|
|
1005
|
+
}
|
|
1006
|
+
}
|
|
1007
|
+
for (const flag of values.allow ?? []) {
|
|
1008
|
+
if (!GATED_CLASSIFICATIONS.includes(flag)) {
|
|
1009
|
+
console.error(`--allow must be one of: ${GATED_CLASSIFICATIONS.join(", ")} (got "${flag}")`);
|
|
1010
|
+
process.exitCode = 1;
|
|
1011
|
+
return;
|
|
1012
|
+
}
|
|
1013
|
+
}
|
|
1014
|
+
await runServe({
|
|
1015
|
+
vault: values.vault,
|
|
1016
|
+
port,
|
|
1017
|
+
host: values.host ?? "127.0.0.1",
|
|
1018
|
+
allow: values.allow ?? [],
|
|
1019
|
+
dataDir: values["data-dir"],
|
|
1020
|
+
binary: values.binary,
|
|
1021
|
+
timeoutMs
|
|
1022
|
+
});
|
|
1023
|
+
return;
|
|
1024
|
+
}
|
|
1025
|
+
if (command === "token") {
|
|
1026
|
+
const [action, ...tokenRest] = rest;
|
|
1027
|
+
const { values } = parseArgs({
|
|
1028
|
+
args: tokenRest,
|
|
1029
|
+
options: {
|
|
1030
|
+
name: { type: "string" },
|
|
1031
|
+
"expires-days": { type: "string" },
|
|
1032
|
+
"data-dir": { type: "string" },
|
|
1033
|
+
id: { type: "string" }
|
|
1034
|
+
},
|
|
1035
|
+
allowPositionals: false
|
|
1036
|
+
});
|
|
1037
|
+
switch (action) {
|
|
1038
|
+
case "mint": {
|
|
1039
|
+
if (!values.name) {
|
|
1040
|
+
console.error("token mint requires --name <label>");
|
|
1041
|
+
process.exitCode = 1;
|
|
1042
|
+
return;
|
|
1043
|
+
}
|
|
1044
|
+
const expiresDays = values["expires-days"] !== undefined ? Number(values["expires-days"]) : undefined;
|
|
1045
|
+
if (expiresDays !== undefined && !Number.isFinite(expiresDays)) {
|
|
1046
|
+
console.error("--expires-days must be a number");
|
|
1047
|
+
process.exitCode = 1;
|
|
1048
|
+
return;
|
|
1049
|
+
}
|
|
1050
|
+
const minted = await tokenMint({ name: values.name, expiresDays, dataDir: values["data-dir"] });
|
|
1051
|
+
console.log("API key (shown once - it cannot be recovered later):");
|
|
1052
|
+
console.log(minted.key);
|
|
1053
|
+
console.log(`id: ${minted.id}`);
|
|
1054
|
+
console.log(`expires: ${minted.expiresAt ? minted.expiresAt.toISOString() : "never"}`);
|
|
1055
|
+
return;
|
|
1056
|
+
}
|
|
1057
|
+
case "list": {
|
|
1058
|
+
const keys = await tokenList({ dataDir: values["data-dir"] });
|
|
1059
|
+
if (keys.length === 0) {
|
|
1060
|
+
console.log("No API keys.");
|
|
1061
|
+
return;
|
|
1062
|
+
}
|
|
1063
|
+
for (const key of keys)
|
|
1064
|
+
console.log(formatKeySummary(key));
|
|
1065
|
+
return;
|
|
1066
|
+
}
|
|
1067
|
+
case "revoke": {
|
|
1068
|
+
if (!values.id) {
|
|
1069
|
+
console.error("token revoke requires --id <keyId>");
|
|
1070
|
+
process.exitCode = 1;
|
|
1071
|
+
return;
|
|
1072
|
+
}
|
|
1073
|
+
const revoked = await tokenRevoke({ id: values.id, dataDir: values["data-dir"] });
|
|
1074
|
+
console.log(`Revoked key ${revoked.id}`);
|
|
1075
|
+
return;
|
|
1076
|
+
}
|
|
1077
|
+
default: {
|
|
1078
|
+
printUsage();
|
|
1079
|
+
process.exitCode = 1;
|
|
1080
|
+
return;
|
|
1081
|
+
}
|
|
1082
|
+
}
|
|
1083
|
+
}
|
|
1084
|
+
printUsage();
|
|
1085
|
+
process.exitCode = command ? 1 : 0;
|
|
1086
|
+
}
|
|
1087
|
+
main().catch((error) => {
|
|
1088
|
+
console.error(error instanceof Error ? error.message : error);
|
|
1089
|
+
process.exitCode = 1;
|
|
1090
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@nathanld/obsidian-server",
|
|
3
|
+
"version": "0.0.1",
|
|
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
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"obsidian-server": "cli.js"
|
|
8
|
+
},
|
|
9
|
+
"dependencies": {
|
|
10
|
+
"@better-auth/api-key": "1.6.23",
|
|
11
|
+
"@modelcontextprotocol/hono": "^2.0.0-beta.2",
|
|
12
|
+
"@modelcontextprotocol/server": "^2.0.0-beta.3",
|
|
13
|
+
"better-auth": "^1.6.23",
|
|
14
|
+
"hono": "^4.12.29",
|
|
15
|
+
"zod": "^4.4.3"
|
|
16
|
+
},
|
|
17
|
+
"engines": {
|
|
18
|
+
"bun": ">=1.2.0"
|
|
19
|
+
},
|
|
20
|
+
"repository": {
|
|
21
|
+
"type": "git",
|
|
22
|
+
"url": "git+https://github.com/nathanld/obsidian-server.git"
|
|
23
|
+
},
|
|
24
|
+
"homepage": "https://github.com/nathanld/obsidian-server#readme",
|
|
25
|
+
"bugs": {
|
|
26
|
+
"url": "https://github.com/nathanld/obsidian-server/issues"
|
|
27
|
+
},
|
|
28
|
+
"license": "MIT",
|
|
29
|
+
"keywords": [
|
|
30
|
+
"obsidian",
|
|
31
|
+
"mcp",
|
|
32
|
+
"model-context-protocol",
|
|
33
|
+
"claude",
|
|
34
|
+
"rest-api",
|
|
35
|
+
"cli",
|
|
36
|
+
"bun"
|
|
37
|
+
],
|
|
38
|
+
"files": [
|
|
39
|
+
"cli.js",
|
|
40
|
+
"README.md",
|
|
41
|
+
"LICENSE"
|
|
42
|
+
],
|
|
43
|
+
"publishConfig": {
|
|
44
|
+
"access": "public"
|
|
45
|
+
}
|
|
46
|
+
}
|