obsidian-mcp-server 3.1.1 → 3.1.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/CLAUDE.md +10 -1
- package/README.md +28 -1
- package/changelog/3.1.x/3.1.2.md +23 -0
- package/dist/config/server-config.d.ts +5 -2
- package/dist/config/server-config.d.ts.map +1 -1
- package/dist/config/server-config.js +73 -0
- package/dist/config/server-config.js.map +1 -1
- package/dist/index.js +39 -5
- package/dist/index.js.map +1 -1
- package/dist/mcp-server/tools/definitions/index.d.ts +731 -10
- package/dist/mcp-server/tools/definitions/index.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/index.js +15 -9
- package/dist/mcp-server/tools/definitions/index.js.map +1 -1
- package/dist/mcp-server/tools/definitions/obsidian-append-to-note.tool.d.ts +7 -1
- package/dist/mcp-server/tools/definitions/obsidian-append-to-note.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/obsidian-append-to-note.tool.js +9 -0
- package/dist/mcp-server/tools/definitions/obsidian-append-to-note.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/obsidian-delete-note.tool.d.ts +5 -0
- package/dist/mcp-server/tools/definitions/obsidian-delete-note.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/obsidian-delete-note.tool.js +6 -0
- package/dist/mcp-server/tools/definitions/obsidian-delete-note.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/obsidian-get-note.tool.d.ts +5 -0
- package/dist/mcp-server/tools/definitions/obsidian-get-note.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/obsidian-get-note.tool.js +6 -0
- package/dist/mcp-server/tools/definitions/obsidian-get-note.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/obsidian-list-notes.tool.d.ts +5 -0
- package/dist/mcp-server/tools/definitions/obsidian-list-notes.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/obsidian-list-notes.tool.js +15 -2
- package/dist/mcp-server/tools/definitions/obsidian-list-notes.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/obsidian-manage-frontmatter.tool.d.ts +5 -0
- package/dist/mcp-server/tools/definitions/obsidian-manage-frontmatter.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/obsidian-manage-frontmatter.tool.js +6 -0
- package/dist/mcp-server/tools/definitions/obsidian-manage-frontmatter.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/obsidian-manage-tags.tool.d.ts +5 -0
- package/dist/mcp-server/tools/definitions/obsidian-manage-tags.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/obsidian-manage-tags.tool.js +6 -0
- package/dist/mcp-server/tools/definitions/obsidian-manage-tags.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/obsidian-open-in-ui.tool.d.ts +5 -0
- package/dist/mcp-server/tools/definitions/obsidian-open-in-ui.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/obsidian-open-in-ui.tool.js +6 -0
- package/dist/mcp-server/tools/definitions/obsidian-open-in-ui.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/obsidian-patch-note.tool.d.ts +7 -2
- package/dist/mcp-server/tools/definitions/obsidian-patch-note.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/obsidian-patch-note.tool.js +6 -0
- package/dist/mcp-server/tools/definitions/obsidian-patch-note.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/obsidian-replace-in-note.tool.d.ts +5 -0
- package/dist/mcp-server/tools/definitions/obsidian-replace-in-note.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/obsidian-replace-in-note.tool.js +6 -0
- package/dist/mcp-server/tools/definitions/obsidian-replace-in-note.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/obsidian-search-notes.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/obsidian-search-notes.tool.js +14 -4
- package/dist/mcp-server/tools/definitions/obsidian-search-notes.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/obsidian-write-note.tool.d.ts +5 -0
- package/dist/mcp-server/tools/definitions/obsidian-write-note.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/obsidian-write-note.tool.js +6 -0
- package/dist/mcp-server/tools/definitions/obsidian-write-note.tool.js.map +1 -1
- package/dist/services/obsidian/obsidian-service.d.ts +7 -0
- package/dist/services/obsidian/obsidian-service.d.ts.map +1 -1
- package/dist/services/obsidian/obsidian-service.js +114 -17
- package/dist/services/obsidian/obsidian-service.js.map +1 -1
- package/dist/services/obsidian/path-policy.d.ts +56 -0
- package/dist/services/obsidian/path-policy.d.ts.map +1 -0
- package/dist/services/obsidian/path-policy.js +154 -0
- package/dist/services/obsidian/path-policy.js.map +1 -0
- package/package.json +7 -6
- package/server.json +41 -3
package/CLAUDE.md
CHANGED
|
@@ -34,7 +34,8 @@ Tailor suggestions to what's actually missing or stale — don't recite the full
|
|
|
34
34
|
- **Check `ctx.elicit`** for presence before calling — used by `obsidian_delete_note` to confirm destructive ops.
|
|
35
35
|
- **All Obsidian access goes through `getObsidianService()`.** No direct `fetch()` calls to the Local REST API in tools/resources — the service centralizes auth, TLS, timeouts, and `ctx.signal` propagation.
|
|
36
36
|
- **Secrets in env vars only.** `OBSIDIAN_API_KEY` is required; never hardcoded.
|
|
37
|
-
- **Command-palette tools are opt-in.** `obsidian_list_commands` and `obsidian_execute_command` are
|
|
37
|
+
- **Command-palette tools are opt-in.** `obsidian_list_commands` and `obsidian_execute_command` are callable only when `OBSIDIAN_ENABLE_COMMANDS=true` — Obsidian commands are opaque and can be destructive. When the flag is unset, the entry point wraps both with `disabledTool()` so they're absent from `tools/list` (LLM can't invoke) but visible in the operator-facing manifest with a hint to enable them.
|
|
38
|
+
- **Path-policy gating goes through `PathPolicy`.** Every path-taking method on `ObsidianService` calls `policy.assertReadable` / `assertWritable` before the upstream HTTP call; `obsidian_search_notes` post-filters hits via `svc.policy.filterReadable`. Don't bypass this — `OBSIDIAN_READ_PATHS` / `OBSIDIAN_WRITE_PATHS` / `OBSIDIAN_READ_ONLY` are the single chokepoint, and `path_forbidden` is declared on every path-taking tool's `errors[]` contract.
|
|
38
39
|
|
|
39
40
|
---
|
|
40
41
|
|
|
@@ -138,6 +139,10 @@ const ServerConfigSchema = z.object({
|
|
|
138
139
|
verifySsl: envBoolean.default(false),
|
|
139
140
|
requestTimeoutMs: z.coerce.number().int().positive().default(30_000),
|
|
140
141
|
enableCommands: envBoolean.default(false),
|
|
142
|
+
/** Path-policy allowlists — comma-separated, prefix-based, case-insensitive. Unset = full vault. */
|
|
143
|
+
readPaths: envPathList,
|
|
144
|
+
writePaths: envPathList,
|
|
145
|
+
readOnly: envBoolean.default(false),
|
|
141
146
|
});
|
|
142
147
|
|
|
143
148
|
let _config: z.infer<typeof ServerConfigSchema> | undefined;
|
|
@@ -148,6 +153,9 @@ export function getServerConfig() {
|
|
|
148
153
|
verifySsl: 'OBSIDIAN_VERIFY_SSL',
|
|
149
154
|
requestTimeoutMs: 'OBSIDIAN_REQUEST_TIMEOUT_MS',
|
|
150
155
|
enableCommands: 'OBSIDIAN_ENABLE_COMMANDS',
|
|
156
|
+
readPaths: 'OBSIDIAN_READ_PATHS',
|
|
157
|
+
writePaths: 'OBSIDIAN_WRITE_PATHS',
|
|
158
|
+
readOnly: 'OBSIDIAN_READ_ONLY',
|
|
151
159
|
});
|
|
152
160
|
return _config;
|
|
153
161
|
}
|
|
@@ -287,6 +295,7 @@ Available skills:
|
|
|
287
295
|
| `report-issue-framework` | File a bug or feature request against `@cyanheads/mcp-ts-core` via `gh` CLI |
|
|
288
296
|
| `report-issue-local` | File a bug or feature request against this server's own repo via `gh` CLI |
|
|
289
297
|
| `api-auth` | Auth modes, scopes, JWT/OAuth |
|
|
298
|
+
| `api-canvas` | DataCanvas SQL workspace (Tier 3, DuckDB) — not used by this server |
|
|
290
299
|
| `api-config` | AppConfig, parseConfig, env vars |
|
|
291
300
|
| `api-context` | Context interface, logger, state, progress |
|
|
292
301
|
| `api-errors` | McpError, JsonRpcErrorCode, error patterns |
|
package/README.md
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
<div align="center">
|
|
9
9
|
|
|
10
|
-
[](https://www.npmjs.com/package/obsidian-mcp-server) [](https://www.npmjs.com/package/obsidian-mcp-server) [](./CHANGELOG.md) [](https://www.npmjs.com/package/@cyanheads/mcp-ts-core) [](https://modelcontextprotocol.io/)
|
|
11
11
|
|
|
12
12
|
[](./LICENSE) [](https://www.typescriptlang.org/) [](https://bun.sh/)
|
|
13
13
|
|
|
@@ -126,6 +126,29 @@ Dispatch an Obsidian command-palette command by ID (discoverable via `obsidian_l
|
|
|
126
126
|
|
|
127
127
|
---
|
|
128
128
|
|
|
129
|
+
## Path policy (folder-scoped permissions)
|
|
130
|
+
|
|
131
|
+
Three optional env vars gate which vault paths each tool can target. **Default unset = full vault** for both reads and writes — backwards compatible.
|
|
132
|
+
|
|
133
|
+
| Goal | Config |
|
|
134
|
+
|:---|:---|
|
|
135
|
+
| Default (current behavior) | all unset |
|
|
136
|
+
| Read everywhere, write only in `projects/` and `scratch/` | `OBSIDIAN_WRITE_PATHS=projects/,scratch/` |
|
|
137
|
+
| Read only `public/`, write only `public/inbox/` | `OBSIDIAN_READ_PATHS=public/`, `OBSIDIAN_WRITE_PATHS=public/inbox/` |
|
|
138
|
+
| Read-only deployment — no writes anywhere | `OBSIDIAN_READ_ONLY=true` |
|
|
139
|
+
|
|
140
|
+
**Matching is prefix-based with implicit recursion**, case-insensitive, with trailing slashes normalized. `projects/` matches `projects/a.md`, `projects/sub/b.md`, etc.
|
|
141
|
+
|
|
142
|
+
**Write paths are implicitly readable** — you can't sanely edit what you can't see. So a read passes when the target matches `READ_PATHS` *or* `WRITE_PATHS`.
|
|
143
|
+
|
|
144
|
+
**`OBSIDIAN_READ_ONLY=true` short-circuits before the path checks** — every write is denied regardless of `WRITE_PATHS`, and the command-palette pair is unregistered regardless of `OBSIDIAN_ENABLE_COMMANDS` (commands can mutate).
|
|
145
|
+
|
|
146
|
+
Denies are typed `path_forbidden` (JSON-RPC code `Forbidden`) with the active scope echoed back in `data.recovery.hint` and `data.activeScope`, so the LLM can self-correct without inspecting server logs. Search results from `obsidian_search_notes` are filtered against `READ_PATHS` silently — surfacing a "we hid N hits" indicator would defeat the gate.
|
|
147
|
+
|
|
148
|
+
The startup banner logs the active scope so operators can verify their config at boot.
|
|
149
|
+
|
|
150
|
+
---
|
|
151
|
+
|
|
129
152
|
## Resources
|
|
130
153
|
|
|
131
154
|
| Type | URI | Description |
|
|
@@ -155,6 +178,7 @@ Obsidian-specific:
|
|
|
155
178
|
- Tag reconciliation across both representations: frontmatter `tags:` array and inline `#tag` syntax (skipping fenced code blocks)
|
|
156
179
|
- Search across three modes: text, Dataview DQL, JSONLogic — with overflow indicator when results exceed the 100-hit cap
|
|
157
180
|
- Optional human-in-the-loop confirmation for destructive deletes via `ctx.elicit`
|
|
181
|
+
- Folder-scoped read/write permissions via `OBSIDIAN_READ_PATHS` / `OBSIDIAN_WRITE_PATHS` and a global `OBSIDIAN_READ_ONLY` kill switch — denies are typed `path_forbidden` with the active scope echoed back in the error data
|
|
158
182
|
- Opt-in command-palette pair (`obsidian_list_commands` + `obsidian_execute_command`) — registered only when `OBSIDIAN_ENABLE_COMMANDS=true`
|
|
159
183
|
- Forgiving path resolution on `obsidian_get_note` and `obsidian_open_in_ui` — silently retries case-mismatched paths against the canonical filename, throws `Conflict` on ambiguous case matches, and enriches `NotFound` with `Did you mean: …?` suggestions when only near-matches exist. `obsidian_delete_note` is deliberately excluded — a destructive op shouldn't silently rewrite the target path.
|
|
160
184
|
|
|
@@ -247,6 +271,9 @@ MCP_TRANSPORT_TYPE=http OBSIDIAN_API_KEY=... bun run start:http
|
|
|
247
271
|
| `OBSIDIAN_VERIFY_SSL` | Verify the TLS certificate. Default `false` because the plugin uses a self-signed cert. On Node, the dispatcher's `rejectUnauthorized` option handles this without any process-wide change. On Bun, the runtime ignores that option, so the service additionally sets `NODE_TLS_REJECT_UNAUTHORIZED=0` — that fallback is scoped to Bun only. | `false` |
|
|
248
272
|
| `OBSIDIAN_REQUEST_TIMEOUT_MS` | Per-request timeout in milliseconds. | `30000` |
|
|
249
273
|
| `OBSIDIAN_ENABLE_COMMANDS` | Opt-in flag for the command-palette pair (`obsidian_list_commands` + `obsidian_execute_command`). Off by default — Obsidian commands are opaque and can be destructive. | `false` |
|
|
274
|
+
| `OBSIDIAN_READ_PATHS` | Comma-separated vault-relative folder allowlist for read operations. Prefix-based with implicit recursion; case-insensitive; trailing slashes normalized. Unset = full vault. Write paths are implicitly readable. | unset |
|
|
275
|
+
| `OBSIDIAN_WRITE_PATHS` | Comma-separated vault-relative folder allowlist for write operations. Same syntax as `OBSIDIAN_READ_PATHS`. Unset = full vault. | unset |
|
|
276
|
+
| `OBSIDIAN_READ_ONLY` | Global kill switch. When `true`, denies every write regardless of `OBSIDIAN_WRITE_PATHS`, and suppresses the `OBSIDIAN_ENABLE_COMMANDS` pair (commands can mutate). | `false` |
|
|
250
277
|
| `MCP_TRANSPORT_TYPE` | Transport: `stdio` or `http`. | `stdio` |
|
|
251
278
|
| `MCP_HTTP_HOST` | Host for the HTTP server. | `127.0.0.1` |
|
|
252
279
|
| `MCP_HTTP_PORT` | Port for the HTTP server. | `3010` |
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
---
|
|
2
|
+
summary: Folder-scoped read/write permissions and a global read-only kill switch — three opt-in env vars (OBSIDIAN_READ_PATHS, OBSIDIAN_WRITE_PATHS, OBSIDIAN_READ_ONLY) gate every path-taking tool and resource, with a new path_forbidden error reason.
|
|
3
|
+
breaking: false
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# 3.1.2 — 2026-05-03
|
|
7
|
+
|
|
8
|
+
Three opt-in env vars now scope which vault paths read and write tools may target. Closes [#40](https://github.com/cyanheads/obsidian-mcp-server/issues/40); resolves the original ask in [discussion #15](https://github.com/cyanheads/obsidian-mcp-server/discussions/15#discussioncomment-13918526). Backwards compatible — all three default unset / false, preserving current full-vault behavior.
|
|
9
|
+
|
|
10
|
+
## Added
|
|
11
|
+
|
|
12
|
+
- **`OBSIDIAN_READ_PATHS` / `OBSIDIAN_WRITE_PATHS`** — comma-separated vault-relative folder allowlists. Prefix-based with implicit recursion (`projects/` matches `projects/a.md`, `projects/sub/b.md`, …); case-insensitive; trailing slashes normalized; deduplicated. Empty / whitespace-only input falls back to unset; separator-only input (`,`, `,,,`) and absolute paths or `..` traversal throw a `ConfigurationError` at startup naming the offending env var.
|
|
13
|
+
- **`OBSIDIAN_READ_ONLY`** — global kill switch. When `true`, denies every write regardless of `OBSIDIAN_WRITE_PATHS`, and suppresses the `OBSIDIAN_ENABLE_COMMANDS` pair (commands can mutate). Useful for shared or public-facing deployments.
|
|
14
|
+
- **`path_forbidden` error reason** declared on every path-taking tool's `errors[]` contract — mapped to `JsonRpcErrorCode.Forbidden`. The wire payload carries `{ path, op, subreason, activeScope, recovery }` where `subreason` is one of `outside_read_paths`, `outside_write_paths`, or `read_only_mode`, and the recovery hint echoes the active scope so the LLM can self-correct without inspecting server logs.
|
|
15
|
+
- **Startup banner** — `core.logger.info('Path policy', …)` echoes the active scope and command-palette enable state at boot, so operators can verify their config. Emits a warning when `OBSIDIAN_READ_ONLY=true` AND `OBSIDIAN_WRITE_PATHS` is non-empty (the boolean wins; the paths are ignored).
|
|
16
|
+
- **`PathPolicy`** module (`src/services/obsidian/path-policy.ts`) — single chokepoint that decides per path: `isReadable`, `isWritable`, `assertReadable`, `assertWritable`, plus a silent `filterReadable` for search post-filtering.
|
|
17
|
+
|
|
18
|
+
## Changed
|
|
19
|
+
|
|
20
|
+
- **`ObsidianService` gates every path-taking method** before the upstream HTTP call: `getNoteContent`, `getNoteJson`, `getDocumentMap`, `listFiles`, `openInUi` (read), and `writeNote`, `appendToNote`, `patchNote`, `deleteNote` (write). For non-`path` targets (`active`, `periodic`) under restrictions, the service routes through a JSON resolution fetch first to learn the path, then gates on the resolved path — costs one extra fetch only for users who configured a path scope.
|
|
21
|
+
- **`obsidian_search_notes` post-filters hits against `OBSIDIAN_READ_PATHS`** silently — surfacing a "we hid N hits" indicator would defeat the gate. The `excluded` overflow indicator continues to count hits trimmed by the 100-hit cap, not policy-dropped hits.
|
|
22
|
+
- **Tool registration in `src/index.ts`** splits read-only tools from write tools at the barrel and wraps the write set with `disabledTool()` when `OBSIDIAN_READ_ONLY=true`. The command-palette pair stays wrapped with `disabledTool()` whenever it's not callable (either `OBSIDIAN_ENABLE_COMMANDS=false` or `OBSIDIAN_READ_ONLY=true`), with a `hint` explaining how to enable it. The LLM never sees disabled tools in `tools/list`; they remain visible in the operator-facing manifest.
|
|
23
|
+
- **`obsidian_list_notes` listing semantics** — the vault root always lists, even with `OBSIDIAN_READ_PATHS` set. Specific subdirectories outside the read scope throw `path_forbidden`. Children aren't filtered at the service level; the per-file read gate on subsequent reads (`obsidian_get_note`, etc.) still catches access to out-of-scope notes.
|
|
@@ -7,9 +7,12 @@ import { z } from '@cyanheads/mcp-ts-core';
|
|
|
7
7
|
declare const ServerConfigSchema: z.ZodObject<{
|
|
8
8
|
apiKey: z.ZodString;
|
|
9
9
|
baseUrl: z.ZodDefault<z.ZodString>;
|
|
10
|
-
verifySsl: z.ZodDefault<z.
|
|
10
|
+
verifySsl: z.ZodDefault<z.ZodPreprocess<z.ZodBoolean>>;
|
|
11
11
|
requestTimeoutMs: z.ZodDefault<z.ZodCoercedNumber<unknown>>;
|
|
12
|
-
enableCommands: z.ZodDefault<z.
|
|
12
|
+
enableCommands: z.ZodDefault<z.ZodPreprocess<z.ZodBoolean>>;
|
|
13
|
+
readPaths: z.ZodPreprocess<z.ZodPipe<z.ZodOptional<z.ZodArray<z.ZodString>>, z.ZodTransform<string[] | undefined, string[] | undefined>>>;
|
|
14
|
+
writePaths: z.ZodPreprocess<z.ZodPipe<z.ZodOptional<z.ZodArray<z.ZodString>>, z.ZodTransform<string[] | undefined, string[] | undefined>>>;
|
|
15
|
+
readOnly: z.ZodDefault<z.ZodPreprocess<z.ZodBoolean>>;
|
|
13
16
|
}, z.core.$strip>;
|
|
14
17
|
export type ServerConfig = z.infer<typeof ServerConfigSchema>;
|
|
15
18
|
export declare function getServerConfig(): ServerConfig;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"server-config.d.ts","sourceRoot":"","sources":["../../src/config/server-config.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,EAAE,CAAC,EAAE,MAAM,wBAAwB,CAAC;
|
|
1
|
+
{"version":3,"file":"server-config.d.ts","sourceRoot":"","sources":["../../src/config/server-config.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,EAAE,CAAC,EAAE,MAAM,wBAAwB,CAAC;AA4E3C,QAAA,MAAM,kBAAkB;;;;;;;;;iBAyCtB,CAAC;AAEH,MAAM,MAAM,YAAY,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,kBAAkB,CAAC,CAAC;AAI9D,wBAAgB,eAAe,IAAI,YAAY,CAY9C;AAED,iEAAiE;AACjE,wBAAgB,iBAAiB,IAAI,IAAI,CAExC"}
|
|
@@ -14,6 +14,71 @@ const envBoolean = z.preprocess((val) => {
|
|
|
14
14
|
}
|
|
15
15
|
return val;
|
|
16
16
|
}, z.boolean());
|
|
17
|
+
/**
|
|
18
|
+
* Comma-separated path-list preprocessor. Semantics (see issue #40):
|
|
19
|
+
* - undefined / `''` / whitespace-only → undefined (treat as unset; default = full vault)
|
|
20
|
+
* - `,` / `,,,` (separators only, no path content) → throw ZodError → ConfigurationError
|
|
21
|
+
* - mixed empties (`a,,b`) → drop empties → `['a', 'b']`
|
|
22
|
+
* - absolute path / `..` traversal → throw
|
|
23
|
+
* - valid: lower-case + trim trailing slash + dedupe (preserves first occurrence order)
|
|
24
|
+
*/
|
|
25
|
+
const envPathList = z
|
|
26
|
+
.preprocess((val) => {
|
|
27
|
+
if (val === undefined || val === null)
|
|
28
|
+
return;
|
|
29
|
+
if (Array.isArray(val))
|
|
30
|
+
return val;
|
|
31
|
+
if (typeof val !== 'string')
|
|
32
|
+
return val;
|
|
33
|
+
if (val.trim() === '')
|
|
34
|
+
return;
|
|
35
|
+
const parts = val
|
|
36
|
+
.split(',')
|
|
37
|
+
.map((s) => s.trim())
|
|
38
|
+
.filter((s) => s.length > 0);
|
|
39
|
+
return parts;
|
|
40
|
+
}, z
|
|
41
|
+
.array(z.string())
|
|
42
|
+
.optional()
|
|
43
|
+
.transform((parts, ctx) => {
|
|
44
|
+
if (parts === undefined)
|
|
45
|
+
return;
|
|
46
|
+
if (parts.length === 0) {
|
|
47
|
+
ctx.addIssue({
|
|
48
|
+
code: 'custom',
|
|
49
|
+
message: 'contained separators but no valid paths after trimming',
|
|
50
|
+
});
|
|
51
|
+
return z.NEVER;
|
|
52
|
+
}
|
|
53
|
+
const seen = new Set();
|
|
54
|
+
const out = [];
|
|
55
|
+
for (const raw of parts) {
|
|
56
|
+
if (raw.startsWith('/') || raw.startsWith('\\')) {
|
|
57
|
+
ctx.addIssue({
|
|
58
|
+
code: 'custom',
|
|
59
|
+
message: `must be vault-relative; got absolute path '${raw}'`,
|
|
60
|
+
});
|
|
61
|
+
return z.NEVER;
|
|
62
|
+
}
|
|
63
|
+
const segments = raw.split(/[\\/]/);
|
|
64
|
+
if (segments.includes('..')) {
|
|
65
|
+
ctx.addIssue({
|
|
66
|
+
code: 'custom',
|
|
67
|
+
message: `must not contain '..' traversal; got '${raw}'`,
|
|
68
|
+
});
|
|
69
|
+
return z.NEVER;
|
|
70
|
+
}
|
|
71
|
+
const normalized = raw.toLowerCase().replace(/[\\/]+$/, '');
|
|
72
|
+
if (normalized.length === 0)
|
|
73
|
+
continue;
|
|
74
|
+
if (seen.has(normalized))
|
|
75
|
+
continue;
|
|
76
|
+
seen.add(normalized);
|
|
77
|
+
out.push(normalized);
|
|
78
|
+
}
|
|
79
|
+
return out.length > 0 ? out : undefined;
|
|
80
|
+
}))
|
|
81
|
+
.describe('Comma-separated list of vault-relative folder prefixes. Empty/whitespace falls back to unset.');
|
|
17
82
|
const ServerConfigSchema = z.object({
|
|
18
83
|
apiKey: z
|
|
19
84
|
.string()
|
|
@@ -36,6 +101,11 @@ const ServerConfigSchema = z.object({
|
|
|
36
101
|
enableCommands: envBoolean
|
|
37
102
|
.default(false)
|
|
38
103
|
.describe('Opt-in flag for the command-palette pair (`obsidian_list_commands` + `obsidian_execute_command`). Off by default — Obsidian commands are opaque and can be destructive.'),
|
|
104
|
+
readPaths: envPathList.describe('Optional vault-relative folder allowlist for read operations. Comma-separated; prefix-based with implicit recursion; case-insensitive; trailing slashes normalized. Unset = full vault.'),
|
|
105
|
+
writePaths: envPathList.describe('Optional vault-relative folder allowlist for write operations. Same syntax as OBSIDIAN_READ_PATHS. Write paths are implicitly readable. Unset = full vault.'),
|
|
106
|
+
readOnly: envBoolean
|
|
107
|
+
.default(false)
|
|
108
|
+
.describe('Global kill switch. When true, denies every write regardless of OBSIDIAN_WRITE_PATHS, and suppresses the OBSIDIAN_ENABLE_COMMANDS pair (commands can mutate). Defaults to false.'),
|
|
39
109
|
});
|
|
40
110
|
let _config;
|
|
41
111
|
export function getServerConfig() {
|
|
@@ -45,6 +115,9 @@ export function getServerConfig() {
|
|
|
45
115
|
verifySsl: 'OBSIDIAN_VERIFY_SSL',
|
|
46
116
|
requestTimeoutMs: 'OBSIDIAN_REQUEST_TIMEOUT_MS',
|
|
47
117
|
enableCommands: 'OBSIDIAN_ENABLE_COMMANDS',
|
|
118
|
+
readPaths: 'OBSIDIAN_READ_PATHS',
|
|
119
|
+
writePaths: 'OBSIDIAN_WRITE_PATHS',
|
|
120
|
+
readOnly: 'OBSIDIAN_READ_ONLY',
|
|
48
121
|
});
|
|
49
122
|
return _config;
|
|
50
123
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"server-config.js","sourceRoot":"","sources":["../../src/config/server-config.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,EAAE,CAAC,EAAE,MAAM,wBAAwB,CAAC;AAC3C,OAAO,EAAE,cAAc,EAAE,MAAM,+BAA+B,CAAC;AAE/D,MAAM,UAAU,GAAG,CAAC,CAAC,UAAU,CAAC,CAAC,GAAG,EAAE,EAAE;IACtC,IAAI,OAAO,GAAG,KAAK,SAAS;QAAE,OAAO,GAAG,CAAC;IACzC,IAAI,OAAO,GAAG,KAAK,QAAQ,EAAE,CAAC;QAC5B,MAAM,GAAG,GAAG,GAAG,CAAC,WAAW,EAAE,CAAC,IAAI,EAAE,CAAC;QACrC,OAAO,GAAG,KAAK,MAAM,IAAI,GAAG,KAAK,GAAG,CAAC;IACvC,CAAC;IACD,OAAO,GAAG,CAAC;AACb,CAAC,EAAE,CAAC,CAAC,OAAO,EAAE,CAAC,CAAC;AAEhB,MAAM,kBAAkB,GAAG,CAAC,CAAC,MAAM,CAAC;IAClC,MAAM,EAAE,CAAC;SACN,MAAM,EAAE;SACR,GAAG,CAAC,CAAC,CAAC;SACN,QAAQ,CACP,sGAAsG,CACvG;IACH,OAAO,EAAE,CAAC;SACP,MAAM,EAAE;SACR,GAAG,EAAE;SACL,OAAO,CAAC,wBAAwB,CAAC;SACjC,QAAQ,CACP,0QAA0Q,CAC3Q;IACH,SAAS,EAAE,UAAU;SAClB,OAAO,CAAC,KAAK,CAAC;SACd,QAAQ,CACP,qXAAqX,CACtX;IACH,gBAAgB,EAAE,CAAC,CAAC,MAAM;SACvB,MAAM,EAAE;SACR,GAAG,EAAE;SACL,QAAQ,EAAE;SACV,OAAO,CAAC,MAAM,CAAC;SACf,QAAQ,CAAC,sCAAsC,CAAC;IACnD,cAAc,EAAE,UAAU;SACvB,OAAO,CAAC,KAAK,CAAC;SACd,QAAQ,CACP,yKAAyK,CAC1K;CACJ,CAAC,CAAC;AAIH,IAAI,OAAiC,CAAC;AAEtC,MAAM,UAAU,eAAe;IAC7B,OAAO,KAAK,cAAc,CAAC,kBAAkB,EAAE;QAC7C,MAAM,EAAE,kBAAkB;QAC1B,OAAO,EAAE,mBAAmB;QAC5B,SAAS,EAAE,qBAAqB;QAChC,gBAAgB,EAAE,6BAA6B;QAC/C,cAAc,EAAE,0BAA0B;
|
|
1
|
+
{"version":3,"file":"server-config.js","sourceRoot":"","sources":["../../src/config/server-config.ts"],"names":[],"mappings":"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"}
|
package/dist/index.js
CHANGED
|
@@ -6,16 +6,34 @@
|
|
|
6
6
|
* @module index
|
|
7
7
|
*/
|
|
8
8
|
import { createApp } from '@cyanheads/mcp-ts-core';
|
|
9
|
+
import { disabledTool } from '@cyanheads/mcp-ts-core/tools';
|
|
10
|
+
import { requestContextService } from '@cyanheads/mcp-ts-core/utils';
|
|
9
11
|
import { getServerConfig } from './config/server-config.js';
|
|
10
12
|
import { allPromptDefinitions } from './mcp-server/prompts/definitions/index.js';
|
|
11
13
|
import { allResourceDefinitions } from './mcp-server/resources/definitions/index.js';
|
|
12
|
-
import {
|
|
14
|
+
import { commandToolDefinitions, readToolDefinitions, writeToolDefinitions, } from './mcp-server/tools/definitions/index.js';
|
|
13
15
|
import { initObsidianService } from './services/obsidian/obsidian-service.js';
|
|
16
|
+
import { PathPolicy } from './services/obsidian/path-policy.js';
|
|
14
17
|
const config = getServerConfig();
|
|
15
|
-
const
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
18
|
+
const policy = new PathPolicy(config);
|
|
19
|
+
const writeTools = config.readOnly
|
|
20
|
+
? writeToolDefinitions.map((def) => disabledTool(def, {
|
|
21
|
+
reason: 'Disabled by OBSIDIAN_READ_ONLY=true.',
|
|
22
|
+
hint: 'Unset OBSIDIAN_READ_ONLY (or set it to false) to enable write tools.',
|
|
23
|
+
}))
|
|
24
|
+
: writeToolDefinitions;
|
|
25
|
+
const commandTools = config.enableCommands && !config.readOnly
|
|
26
|
+
? commandToolDefinitions
|
|
27
|
+
: commandToolDefinitions.map((def) => disabledTool(def, {
|
|
28
|
+
reason: config.readOnly
|
|
29
|
+
? 'Disabled by OBSIDIAN_READ_ONLY=true (commands can mutate).'
|
|
30
|
+
: 'Disabled by default — Obsidian commands are opaque and can be destructive.',
|
|
31
|
+
hint: config.readOnly
|
|
32
|
+
? 'Unset OBSIDIAN_READ_ONLY to allow commands; OBSIDIAN_ENABLE_COMMANDS=true is also required.'
|
|
33
|
+
: 'Set OBSIDIAN_ENABLE_COMMANDS=true to enable obsidian_list_commands and obsidian_execute_command.',
|
|
34
|
+
}));
|
|
35
|
+
const tools = [...readToolDefinitions, ...writeTools, ...commandTools];
|
|
36
|
+
const { services } = await createApp({
|
|
19
37
|
tools,
|
|
20
38
|
resources: allResourceDefinitions,
|
|
21
39
|
prompts: allPromptDefinitions,
|
|
@@ -23,4 +41,20 @@ await createApp({
|
|
|
23
41
|
initObsidianService(config);
|
|
24
42
|
},
|
|
25
43
|
});
|
|
44
|
+
/**
|
|
45
|
+
* Startup banner — emitted after createApp() returns so the framework's
|
|
46
|
+
* `logger.initialize()` has run; calls inside `setup()` happen pre-init and
|
|
47
|
+
* are dropped. Operators check this against their config to verify the active
|
|
48
|
+
* path policy. The active scope on `path_forbidden` errors echoes the same
|
|
49
|
+
* data so the LLM (or operator) can self-correct without scrolling the log.
|
|
50
|
+
*/
|
|
51
|
+
const bannerCtx = requestContextService.createRequestContext({
|
|
52
|
+
operation: 'startup',
|
|
53
|
+
...policy.describe(),
|
|
54
|
+
enableCommands: config.enableCommands && !config.readOnly,
|
|
55
|
+
});
|
|
56
|
+
services.logger.info('Path policy', bannerCtx);
|
|
57
|
+
if (policy.readOnlyShadowsWritePaths) {
|
|
58
|
+
services.logger.warning('OBSIDIAN_WRITE_PATHS is set but ignored because OBSIDIAN_READ_ONLY=true. Unset one of the two to remove the conflict.', bannerCtx);
|
|
59
|
+
}
|
|
26
60
|
//# sourceMappingURL=index.js.map
|
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";AACA;;;;;GAKG;AAEH,OAAO,EAAE,SAAS,EAAE,MAAM,wBAAwB,CAAC;AACnD,OAAO,EAAE,eAAe,EAAE,MAAM,2BAA2B,CAAC;AAC5D,OAAO,EAAE,oBAAoB,EAAE,MAAM,2CAA2C,CAAC;AACjF,OAAO,EAAE,sBAAsB,EAAE,MAAM,6CAA6C,CAAC;AACrF,OAAO,EACL,mBAAmB,EACnB,
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";AACA;;;;;GAKG;AAEH,OAAO,EAAE,SAAS,EAAE,MAAM,wBAAwB,CAAC;AACnD,OAAO,EAAE,YAAY,EAAE,MAAM,8BAA8B,CAAC;AAC5D,OAAO,EAAE,qBAAqB,EAAE,MAAM,8BAA8B,CAAC;AACrE,OAAO,EAAE,eAAe,EAAE,MAAM,2BAA2B,CAAC;AAC5D,OAAO,EAAE,oBAAoB,EAAE,MAAM,2CAA2C,CAAC;AACjF,OAAO,EAAE,sBAAsB,EAAE,MAAM,6CAA6C,CAAC;AACrF,OAAO,EACL,sBAAsB,EACtB,mBAAmB,EACnB,oBAAoB,GACrB,MAAM,yCAAyC,CAAC;AACjD,OAAO,EAAE,mBAAmB,EAAE,MAAM,yCAAyC,CAAC;AAC9E,OAAO,EAAE,UAAU,EAAE,MAAM,oCAAoC,CAAC;AAEhE,MAAM,MAAM,GAAG,eAAe,EAAE,CAAC;AACjC,MAAM,MAAM,GAAG,IAAI,UAAU,CAAC,MAAM,CAAC,CAAC;AAEtC,MAAM,UAAU,GAAG,MAAM,CAAC,QAAQ;IAChC,CAAC,CAAC,oBAAoB,CAAC,GAAG,CAAC,CAAC,GAAG,EAAE,EAAE,CAC/B,YAAY,CAAC,GAAG,EAAE;QAChB,MAAM,EAAE,sCAAsC;QAC9C,IAAI,EAAE,sEAAsE;KAC7E,CAAC,CACH;IACH,CAAC,CAAC,oBAAoB,CAAC;AAEzB,MAAM,YAAY,GAChB,MAAM,CAAC,cAAc,IAAI,CAAC,MAAM,CAAC,QAAQ;IACvC,CAAC,CAAC,sBAAsB;IACxB,CAAC,CAAC,sBAAsB,CAAC,GAAG,CAAC,CAAC,GAAG,EAAE,EAAE,CACjC,YAAY,CAAC,GAAG,EAAE;QAChB,MAAM,EAAE,MAAM,CAAC,QAAQ;YACrB,CAAC,CAAC,4DAA4D;YAC9D,CAAC,CAAC,4EAA4E;QAChF,IAAI,EAAE,MAAM,CAAC,QAAQ;YACnB,CAAC,CAAC,6FAA6F;YAC/F,CAAC,CAAC,kGAAkG;KACvG,CAAC,CACH,CAAC;AAER,MAAM,KAAK,GAAG,CAAC,GAAG,mBAAmB,EAAE,GAAG,UAAU,EAAE,GAAG,YAAY,CAAC,CAAC;AAEvE,MAAM,EAAE,QAAQ,EAAE,GAAG,MAAM,SAAS,CAAC;IACnC,KAAK;IACL,SAAS,EAAE,sBAAsB;IACjC,OAAO,EAAE,oBAAoB;IAC7B,KAAK;QACH,mBAAmB,CAAC,MAAM,CAAC,CAAC;IAC9B,CAAC;CACF,CAAC,CAAC;AAEH;;;;;;GAMG;AACH,MAAM,SAAS,GAAG,qBAAqB,CAAC,oBAAoB,CAAC;IAC3D,SAAS,EAAE,SAAS;IACpB,GAAG,MAAM,CAAC,QAAQ,EAAE;IACpB,cAAc,EAAE,MAAM,CAAC,cAAc,IAAI,CAAC,MAAM,CAAC,QAAQ;CAC1D,CAAC,CAAC;AACH,QAAQ,CAAC,MAAM,CAAC,IAAI,CAAC,aAAa,EAAE,SAAS,CAAC,CAAC;AAC/C,IAAI,MAAM,CAAC,yBAAyB,EAAE,CAAC;IACrC,QAAQ,CAAC,MAAM,CAAC,OAAO,CACrB,uHAAuH,EACvH,SAAS,CACV,CAAC;AACJ,CAAC"}
|