Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
3 changes: 2 additions & 1 deletion src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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() {
Expand Down
14 changes: 10 additions & 4 deletions src/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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.",
Expand Down Expand Up @@ -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.",
Expand Down Expand Up @@ -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({
Expand All @@ -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.",
Expand Down
21 changes: 21 additions & 0 deletions test/e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading