diff --git a/README.md b/README.md index 64454c1..9e3612b 100644 --- a/README.md +++ b/README.md @@ -255,6 +255,7 @@ Without `MCP_AUTH_TOKEN`, the server runs without authentication — suitable fo | `DATA_DIR` | Optional | `~/.obsidian-mcp` | Directory for persisted data (metadata index, auth tokens) | | `LOG_LEVEL` | Optional | — | Set to `debug` for verbose logging (library logs, change feed, index sync) | | `MCP_REFRESH_DAYS` | Optional | `14` | Days before auth session expires | +| `READ_ONLY` | Optional | `false` | Set to `true` to disable all write tools (`write_note`, `edit_note`, `delete_note`, `move_note`). Only read tools are exposed via MCP. Useful when sharing the server with multiple AI clients and write access should be opt-in. | Set `VAULT_PATH` for filesystem mode or `COUCHDB_URL` for CouchDB mode. diff --git a/src/main.ts b/src/main.ts index 255dfcd..56ededf 100644 --- a/src/main.ts +++ b/src/main.ts @@ -32,6 +32,7 @@ const VAULT_NAME = process.env.VAULT_NAME ?? "MyVault"; const PORT = parseInt(process.env.PORT ?? "8787"); const BASE_URL = process.env.BASE_URL ?? `http://localhost:${PORT}`; const AUTH_TOKEN = process.env.MCP_AUTH_TOKEN; +const READ_ONLY = process.env.READ_ONLY === "true"; // --- Initialize vault (local or remote) --- import type { VaultBackend } from "./vault-backend.js"; @@ -239,7 +240,7 @@ if (AUTH_TOKEN) { } // --- Tools --- -registerTools(server, vault, searchIndex, VAULT_NAME); +registerTools(server, vault, searchIndex, VAULT_NAME, READ_ONLY); // --- Graceful shutdown --- async function shutdown() { diff --git a/src/tools.ts b/src/tools.ts index d46ecf3..77cd6d9 100644 --- a/src/tools.ts +++ b/src/tools.ts @@ -6,12 +6,18 @@ import type { SearchIndex } from "./search.js"; const debugLogging = process.env.LOG_LEVEL === "debug"; +const WRITE_TOOLS = ["write_note", "edit_note", "delete_note", "move_note"] as const; + export function registerTools( server: FastMCP, vault: VaultBackend, searchIndex: SearchIndex, vaultName: string, + readOnly = false, ) { + if (readOnly) { + console.log(`READ_ONLY mode: write tools disabled (${WRITE_TOOLS.join(", ")}).`); + } const _addTool = server.addTool.bind(server); server.addTool = (tool: any) => { const original = tool.execute; @@ -41,7 +47,7 @@ export function registerTools( }, }); - server.addTool({ + if (!readOnly) server.addTool({ name: "write_note", description: "Write or update a note in the Obsidian vault. Creates the note if it doesn't exist. Replaces the entire content if it does — read first if you need to preserve existing content.", @@ -178,7 +184,7 @@ export function registerTools( - server.addTool({ + if (!readOnly) server.addTool({ name: "edit_note", description: "Edit a note without rewriting it. Use 'append' (default) to add content to the end, 'prepend' to add after frontmatter, or 'replace' to swap old_text with new content. For replace, the old_text must match exactly once.", @@ -239,7 +245,7 @@ export function registerTools( }, }); - server.addTool({ + if (!readOnly) server.addTool({ name: "delete_note", description: "Delete a note from the Obsidian vault.", parameters: z.object({ @@ -252,7 +258,7 @@ export function registerTools( }, }); - server.addTool({ + if (!readOnly) server.addTool({ name: "move_note", description: "Move or rename a note. Use this to rename a note within the same folder, move it to a different folder, or both at once. Creates destination folders automatically.", diff --git a/test/e2e.test.ts b/test/e2e.test.ts index 73f81b8..c5c5e21 100644 --- a/test/e2e.test.ts +++ b/test/e2e.test.ts @@ -243,6 +243,27 @@ describe("E2E: delete_note", () => { // --- Restart Test --- +describe("E2E: READ_ONLY mode", () => { + it("hides write tools and rejects write calls when READ_ONLY=true", async () => { + await stopServer(); + await startServer({ VAULT_PATH: vaultDir, VAULT_NAME: "TestVault", READ_ONLY: "true" }); + assert.ok(serverLogs.includes("READ_ONLY mode"), "should log READ_ONLY mode at startup"); + + const list = await mcpCall("tools/list", {}); + const tools: string[] = (list?.result?.tools ?? []).map((t: any) => t.name); + for (const w of ["write_note", "edit_note", "delete_note", "move_note"]) { + assert.ok(!tools.includes(w), `${w} should not be registered in READ_ONLY mode`); + } + for (const r of ["read_note", "list_notes", "list_folders", "list_tags", "get_note_metadata"]) { + assert.ok(tools.includes(r), `${r} should remain available in READ_ONLY mode`); + } + + const resp = await mcpCall("tools/call", { name: "write_note", arguments: { path: "blocked.md", content: "x" } }); + assert.ok(resp?.error, "write_note call should return an error"); + assert.ok(!existsSync(join(vaultDir, "blocked.md")), "no file should be created when write is blocked"); + }); +}); + describe("E2E: cold restart with persisted index", () => { it("picks up changes and removes stale entries after restart", async () => { // Stop server (triggers saveToDisk)