pagedex 0.1.5 → 0.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -18,7 +18,17 @@ By default, `pagedex` serves the current directory on `http://127.0.0.1:4321`:
18
18
 
19
19
  - `/` — React + Tiptap web UI (desktop & mobile with edge-swipe drawers + long-press tree actions)
20
20
  - `/api/v1/*` — REST API for reading, editing, searching, and Git diff/commit
21
- - `/mcp` — MCP endpoint supporting `2026-07-28` (`server/discover`), `2025-11-25` Streamable HTTP, and `2024-11-05` HTTP+SSE
21
+ - `/mcp` — MCP endpoint supporting `2026-07-28` (`server/discover`), `2025-11-25` Streamable HTTP, and `2024-11-05` HTTP+SSE (see [MCP Server Guide](./docs/MCP.md))
22
+
23
+ ## Screenshots
24
+
25
+ | Desktop Preview & Pinned Sidebar | WYSIWYG Editor (Tiptap) |
26
+ |---|---|
27
+ | ![Desktop Preview](./docs/images/desktop-preview.png) | ![Desktop Editor](./docs/images/desktop-edit.png) |
28
+
29
+ | Quick Switcher (`Cmd/Ctrl+K`) | Mobile View & Bottom Bar |
30
+ |---|---|
31
+ | ![Quick Switcher](./docs/images/desktop-switcher.png) | ![Mobile Preview](./docs/images/mobile-preview.png) |
22
32
 
23
33
  ### CLI Options
24
34
 
@@ -27,14 +37,18 @@ Usage:
27
37
  pagedex [dir] [options]
28
38
 
29
39
  Arguments:
30
- dir Directory to serve (defaults to current working directory)
40
+ dir Directory to serve (defaults to current working directory)
31
41
 
32
42
  Options:
33
- -p, --port <port> Port to listen on (default: 4321, or PORT env)
34
- -H, --host <host> Host to bind (default: 127.0.0.1, or HOST env; use 0.0.0.0 for LAN)
35
- -o, --open Open browser after starting
36
- -v, --version Show version
37
- -h, --help Show this help
43
+ -p, --port <port> Port to listen on (default: 4321, or PORT env)
44
+ -H, --host <host> Host to bind (default: 127.0.0.1, or HOST env; use 0.0.0.0 for LAN)
45
+ -o, --open Open browser after starting
46
+ --mcp <mode> MCP mode: read, write, full (default), or off (or PAGEDEX_MCP_MODE)
47
+ --mcp-git Enable MCP git_commit and git_discard tools (or PAGEDEX_MCP_GIT=1)
48
+ --mcp-token-file <path> File containing Bearer token for /mcp (or PAGEDEX_MCP_TOKEN_FILE)
49
+ --read-only Disable all REST & MCP write/delete mutations (or PAGEDEX_READ_ONLY=1)
50
+ -v, --version Show version
51
+ -h, --help Show this help
38
52
  ```
39
53
 
40
54
  ### Examples
@@ -43,8 +57,11 @@ Options:
43
57
  # Serve current folder on default port 4321
44
58
  npx pagedex
45
59
 
46
- # Serve a specific notes folder on LAN/Tailscale
47
- npx pagedex ~/vault --host 0.0.0.0 --port 4321
60
+ # Serve a specific notes folder on LAN/Tailscale with read-only MCP and Bearer token file
61
+ npx pagedex ~/vault --host 0.0.0.0 --mcp read --mcp-token-file ~/.config/pagedex/mcp-token
62
+
63
+ # Allow MCP create/edit/move + local git commit/discard without delete_note
64
+ npx pagedex ~/vault --mcp write --mcp-git
48
65
  ```
49
66
 
50
67
  ## Search Index Cache
@@ -58,5 +75,8 @@ pnpm install
58
75
  pnpm dev # Hono API/MCP on :4321 + Vite HMR on :5173
59
76
  pnpm build # Build production UI into dist/
60
77
  pnpm test # Run unit tests
78
+ pnpm test:playwright # Run Playwright E2E tests
61
79
  pnpm smoke # Run REST + multi-era MCP smoke checks
62
80
  ```
81
+
82
+ See [docs/README.md](./docs/README.md) for full documentation.
package/bin/pagedex.js CHANGED
@@ -16,11 +16,15 @@ Arguments:
16
16
  dir Directory to serve (defaults to current working directory)
17
17
 
18
18
  Options:
19
- -p, --port <port> Port to listen on (default: 4321, or PORT env)
20
- -H, --host <host> Host to bind (default: 127.0.0.1, or HOST env; use 0.0.0.0 for LAN)
21
- -o, --open Open browser after starting
22
- -v, --version Show version
23
- -h, --help Show this help
19
+ -p, --port <port> Port to listen on (default: 4321, or PORT env)
20
+ -H, --host <host> Host to bind (default: 127.0.0.1, or HOST env; use 0.0.0.0 for LAN)
21
+ -o, --open Open browser after starting
22
+ --mcp <mode> MCP mode: read, write, full (default), or off (or PAGEDEX_MCP_MODE)
23
+ --mcp-git Enable MCP git_commit and git_discard tools (or PAGEDEX_MCP_GIT=1)
24
+ --mcp-token-file <path> File containing Bearer token for /mcp (or PAGEDEX_MCP_TOKEN_FILE)
25
+ --read-only Disable all REST & MCP write/delete mutations (or PAGEDEX_READ_ONLY=1)
26
+ -v, --version Show version
27
+ -h, --help Show this help
24
28
  `);
25
29
  }
26
30
 
@@ -40,6 +44,12 @@ let targetDir = null;
40
44
  let port = process.env.PORT || '4321';
41
45
  let host = process.env.HOST || '127.0.0.1';
42
46
  let shouldOpen = false;
47
+ let mcpMode = process.env.PAGEDEX_MCP_MODE || 'full';
48
+ let mcpGit = process.env.PAGEDEX_MCP_GIT === '1' || process.env.PAGEDEX_MCP_GIT === 'true';
49
+ let mcpTokenFile = process.env.PAGEDEX_MCP_TOKEN_FILE || '';
50
+ let readOnly = process.env.PAGEDEX_READ_ONLY === '1' || process.env.PAGEDEX_READ_ONLY === 'true';
51
+
52
+ const VALID_MCP_MODES = new Set(['read', 'write', 'full', 'off']);
43
53
 
44
54
  for (let i = 0; i < args.length; i++) {
45
55
  const arg = args[i];
@@ -55,6 +65,46 @@ for (let i = 0; i < args.length; i++) {
55
65
  shouldOpen = true;
56
66
  continue;
57
67
  }
68
+ if (arg === '--read-only') {
69
+ readOnly = true;
70
+ continue;
71
+ }
72
+ if (arg === '--mcp-git') {
73
+ mcpGit = true;
74
+ continue;
75
+ }
76
+ if (arg === '--mcp') {
77
+ mcpMode = (args[++i] || '').toLowerCase();
78
+ if (!VALID_MCP_MODES.has(mcpMode)) {
79
+ console.error(`Invalid --mcp mode: ${mcpMode || '(missing)'}. Expected: read, write, full, or off`);
80
+ process.exit(1);
81
+ }
82
+ continue;
83
+ }
84
+ if (arg.startsWith('--mcp=')) {
85
+ mcpMode = arg.slice('--mcp='.length).toLowerCase();
86
+ if (!VALID_MCP_MODES.has(mcpMode)) {
87
+ console.error(`Invalid --mcp mode: ${mcpMode || '(missing)'}. Expected: read, write, full, or off`);
88
+ process.exit(1);
89
+ }
90
+ continue;
91
+ }
92
+ if (arg === '--mcp-token-file') {
93
+ mcpTokenFile = args[++i];
94
+ if (!mcpTokenFile) {
95
+ console.error('Missing value for --mcp-token-file');
96
+ process.exit(1);
97
+ }
98
+ continue;
99
+ }
100
+ if (arg.startsWith('--mcp-token-file=')) {
101
+ mcpTokenFile = arg.slice('--mcp-token-file='.length);
102
+ if (!mcpTokenFile) {
103
+ console.error('Missing value for --mcp-token-file');
104
+ process.exit(1);
105
+ }
106
+ continue;
107
+ }
58
108
  if (arg === '-p' || arg === '--port') {
59
109
  port = args[++i];
60
110
  if (!port) {
@@ -95,10 +145,28 @@ if (!fs.existsSync(resolvedRoot) || !fs.statSync(resolvedRoot).isDirectory()) {
95
145
  process.exit(1);
96
146
  }
97
147
 
148
+ if (!VALID_MCP_MODES.has(mcpMode.toLowerCase())) {
149
+ console.error(`Invalid PAGEDEX_MCP_MODE: ${mcpMode}. Expected: read, write, full, or off`);
150
+ process.exit(1);
151
+ }
152
+
153
+ if (mcpTokenFile) {
154
+ const resolvedTokenFile = path.resolve(mcpTokenFile);
155
+ if (!fs.existsSync(resolvedTokenFile) || !fs.statSync(resolvedTokenFile).isFile()) {
156
+ console.error(`MCP token file not found: ${resolvedTokenFile}`);
157
+ process.exit(1);
158
+ }
159
+ mcpTokenFile = resolvedTokenFile;
160
+ }
161
+
98
162
  process.env.PAGEDEX_ROOT = resolvedRoot;
99
163
  process.env.VAULT_ROOT = resolvedRoot;
100
164
  process.env.PORT = String(port);
101
165
  process.env.HOST = String(host);
166
+ process.env.PAGEDEX_MCP_MODE = mcpMode.toLowerCase();
167
+ if (mcpGit) process.env.PAGEDEX_MCP_GIT = '1';
168
+ if (mcpTokenFile) process.env.PAGEDEX_MCP_TOKEN_FILE = mcpTokenFile;
169
+ if (readOnly) process.env.PAGEDEX_READ_ONLY = '1';
102
170
 
103
171
  await import('../src/server/index.js');
104
172
 
package/docs/MCP.md ADDED
@@ -0,0 +1,107 @@
1
+ # MCP Server Guide (`docs/MCP.md`)
2
+
3
+ `pagedex` includes a built-in **Model Context Protocol (MCP)** server at `/mcp` supporting three protocol eras simultaneously:
4
+ - **`2026-07-28`** (`server/discover` + Streamable HTTP)
5
+ - **`2025-11-25`** (legacy Streamable HTTP `initialize`)
6
+ - **`2024-11-05`** (legacy HTTP+SSE)
7
+
8
+ ## Access Control & CLI Flags
9
+
10
+ | Flag | Env Var | Default | Behavior |
11
+ |---|---|---|---|
12
+ | `--mcp <mode>` | `PAGEDEX_MCP_MODE` | `full` | Controls which MCP tools are exposed (`read`, `write`, `full`, or `off`) |
13
+ | `--mcp-git` | `PAGEDEX_MCP_GIT` | `false` | Opt-in flag to expose `git_commit` and `git_discard` (requires `write` or `full`) |
14
+ | `--mcp-token-file <path>` | `PAGEDEX_MCP_TOKEN_FILE` | — | File containing Bearer token required in `Authorization: Bearer <token>` on `/mcp` |
15
+ | — | `PAGEDEX_MCP_TOKEN` | — | Optional env fallback if `--mcp-token-file` is not set |
16
+ | `--read-only` | `PAGEDEX_READ_ONLY` | `false` | Blocks all REST mutations (`403`) and forces MCP to `read` mode (unless `off`) |
17
+
18
+ ### MCP Modes
19
+
20
+ | Mode | Read Tools | Write / Move Tools | `delete_note` | `git_commit` / `git_discard` |
21
+ |---|---|---|---|---|
22
+ | `read` | Yes | No | No | No |
23
+ | `write` | Yes | Yes | No | Only with `--mcp-git` |
24
+ | `full` *(default)* | Yes | Yes | Yes | Only with `--mcp-git` |
25
+ | `off` | Disabled (`404` on `/mcp`) | No | No | No |
26
+
27
+ ### Examples
28
+
29
+ ```bash
30
+ # Safe read-only MCP for browsing/searching notes
31
+ pagedex ~/vault --mcp read
32
+
33
+ # Allow AI agents to create/edit/move notes, but forbid deletions
34
+ pagedex ~/vault --mcp write
35
+
36
+ # Expose on LAN with Bearer token stored in a 0600 file (not in ps/shell history)
37
+ pagedex ~/vault --host 0.0.0.0 --mcp write --mcp-token-file ~/.config/pagedex/mcp-token
38
+
39
+ # Allow local git commit/discard via MCP (never pushes)
40
+ pagedex ~/vault --mcp write --mcp-git
41
+
42
+ # Lock down both Web UI/REST and MCP completely
43
+ pagedex ~/vault --read-only
44
+ ```
45
+
46
+ ## Client Configuration
47
+
48
+ Without token auth:
49
+
50
+ ```json
51
+ {
52
+ "mcpServers": {
53
+ "pagedex": {
54
+ "url": "http://127.0.0.1:4321/mcp"
55
+ }
56
+ }
57
+ }
58
+ ```
59
+
60
+ With `--mcp-token-file`:
61
+
62
+ ```json
63
+ {
64
+ "mcpServers": {
65
+ "pagedex": {
66
+ "url": "http://127.0.0.1:4321/mcp",
67
+ "headers": {
68
+ "Authorization": "Bearer YOUR_TOKEN_HERE"
69
+ }
70
+ }
71
+ }
72
+ }
73
+ ```
74
+
75
+ ## Exposed Tools
76
+
77
+ ### Read Tools (`read`, `write`, `full`)
78
+
79
+ | Tool | Inputs | Purpose |
80
+ |---|---|---|
81
+ | `list_vault` | `{ folder? }` | List immediate subfolders and files in `/` or a subfolder |
82
+ | `read_note` | `{ path }` | Read a file's contents and incoming backlinks |
83
+ | `search_vault` | `{ query, limit? }` | Run SQLite FTS5 trigram full-text search across filenames & contents |
84
+ | `get_vault_info` | `{ recentLimit? }` | Get total file count and most recently modified files |
85
+ | `get_note_links` | `{ path }` | Get outgoing `[[wikilinks]]`, incoming backlinks, and Markdown headings |
86
+ | `git_status` | `{}` | List uncommitted modified, added, or untracked files |
87
+ | `git_diff` | `{ path }` | Get unified git diff for a specific file |
88
+
89
+ ### Write Tools (`write`, `full`)
90
+
91
+ | Tool | Inputs | Purpose |
92
+ |---|---|---|
93
+ | `write_note` | `{ path, content?, append?, target?, replacement?, replaceAll? }` | Create, overwrite, append, or patch (`target` → `replacement`) a file |
94
+ | `move_path` | `{ oldPath, newPath, type? }` | Rename or move a file or directory (`type`: `auto` \| `file` \| `dir`) |
95
+
96
+ ### Destructive Tool (`full` only)
97
+
98
+ | Tool | Inputs | Purpose |
99
+ |---|---|---|
100
+ | `delete_note` | `{ path }` | Delete a file from disk and remove it from the search index |
101
+
102
+ ### Git Mutation Tools (`--mcp-git` with `write` or `full`)
103
+
104
+ | Tool | Inputs | Purpose |
105
+ |---|---|---|
106
+ | `git_commit` | `{ message, files? }` | Stage and commit changes locally (never pushes) |
107
+ | `git_discard` | `{ path }` | Discard uncommitted changes to a file (or delete an untracked file) |
package/docs/README.md CHANGED
@@ -4,6 +4,7 @@ User, integration, and developer guides for **pagedex**.
4
4
 
5
5
  - [Getting Started](./getting-started.md) — CLI flags, environment variables, UI features, wikilinks, Git sync, and mobile gestures.
6
6
  - [Supported File Types](./supported-files.md) — Markdown/MDX, sandboxed HTML + sibling assets, PDF, audio/video, CSV/TSV, SVG, code, and binaries.
7
- - [MCP & REST API](./mcp-and-api.md) — Connecting AI clients to `/mcp`, the 5 vault tools, REST endpoints, `/raw/*`, and SSE live reload.
7
+ - [MCP Server Guide](./MCP.md) — MCP modes (`read`, `write`, `full`, `off`), `--mcp-token-file`, `--mcp-git`, and all 12 vault tools.
8
+ - [MCP & REST API](./mcp-and-api.md) — Quick reference for `/mcp`, REST endpoints, `/raw/*`, and SSE live reload.
8
9
  - [Security Model](./security.md) — Sandboxed HTML iframes, CSP/CORS behavior, `Origin: null` guard, and path protection.
9
10
  - [Development & Release](./development.md) — Running `pnpm dev`, unit & smoke tests, and npm packaging.
@@ -27,6 +27,10 @@ npx pagedex ~/vault --host 0.0.0.0 --port 4321
27
27
  | `-p, --port <port>` | `PORT` | `4321` | HTTP port |
28
28
  | `-H, --host <host>` | `HOST` | `127.0.0.1` | Bind address (`0.0.0.0` for LAN/Tailscale) |
29
29
  | `-o, --open` | — | `false` | Open browser on startup |
30
+ | `--mcp <mode>` | `PAGEDEX_MCP_MODE` | `full` | MCP mode: `read`, `write`, `full`, or `off` |
31
+ | `--mcp-git` | `PAGEDEX_MCP_GIT` | `false` | Enable `git_commit` and `git_discard` MCP tools |
32
+ | `--mcp-token-file <path>` | `PAGEDEX_MCP_TOKEN_FILE` | — | File containing Bearer token for `/mcp` |
33
+ | `--read-only` | `PAGEDEX_READ_ONLY` | `false` | Disable all REST & MCP write/delete mutations |
30
34
  | `-v, --version` | — | — | Print version |
31
35
  | `-h, --help` | — | — | Print CLI help |
32
36
 
@@ -40,6 +44,8 @@ Override the cache location with:
40
44
 
41
45
  ## Web UI Highlights
42
46
 
47
+ ![Desktop Preview](./images/desktop-preview.png)
48
+
43
49
  - **View Modes (top-right `⋮` menu):**
44
50
  - **Download** — downloads any open file via `/api/v1/file?path=...&download=1`
45
51
  - **Preview** — rendered Markdown, sandboxed HTML, PDF, audio/video, CSV/TSV tables, or images
Binary file
Binary file
Binary file
Binary file
@@ -25,13 +25,14 @@ Point any HTTP-capable MCP client (Claude Code, Cursor, VS Code, etc.) at:
25
25
 
26
26
  ### Exposed MCP Tools
27
27
 
28
- | Tool | Purpose |
28
+ See the full **[MCP Server Guide](./MCP.md)** for CLI access control (`--mcp`, `--mcp-git`, `--mcp-token-file`, `--read-only`) and detailed schemas.
29
+
30
+ | Tier | Tools |
29
31
  |---|---|
30
- | `list_vault` | List files/folders and metadata in the served root |
31
- | `read_note` | Read file contents and backlinks |
32
- | `search_vault` | Run SQLite FTS5 trigram full-text search |
33
- | `write_note` | Create, overwrite, append, or patch (`target` → `replacement`) files |
34
- | `delete_note` | Delete a file from disk and update the search index |
32
+ | Read (`read`, `write`, `full`) | `list_vault`, `read_note`, `search_vault`, `get_vault_info`, `get_note_links`, `git_status`, `git_diff` |
33
+ | Write (`write`, `full`) | `write_note`, `move_path` |
34
+ | Full (`full` default) | `delete_note` |
35
+ | Git mutations (`--mcp-git`) | `git_commit`, `git_discard` |
35
36
 
36
37
  ## REST API (`/api/v1/*`)
37
38
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pagedex",
3
- "version": "0.1.5",
3
+ "version": "0.1.6",
4
4
  "description": "Local-first Markdown knowledge viewer, editor, Git bridge, and MCP v2 server for any directory.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -12,6 +12,7 @@ import { registerEventRoutes, startVaultWatcher } from './routes/events.js';
12
12
 
13
13
  const HOST = process.env.HOST || '127.0.0.1';
14
14
  const PORT = Number(process.env.PORT || 4321);
15
+ const READ_ONLY = process.env.PAGEDEX_READ_ONLY === '1' || process.env.PAGEDEX_READ_ONLY === 'true';
15
16
 
16
17
  try {
17
18
  syncIndex();
@@ -27,6 +28,9 @@ const app = new Hono();
27
28
  app.use('/api/v1/*', async (c, next) => {
28
29
  const method = c.req.method.toUpperCase();
29
30
  if (method !== 'GET' && method !== 'HEAD' && method !== 'OPTIONS') {
31
+ if (READ_ONLY) {
32
+ return c.json({ error: 'Server is running in read-only mode' }, 403);
33
+ }
30
34
  if (c.req.header('origin') === 'null') {
31
35
  return c.json({ error: 'Forbidden origin' }, 403);
32
36
  }
package/src/server/mcp.js CHANGED
@@ -1,9 +1,57 @@
1
- import { randomUUID } from 'node:crypto';
1
+ import { randomUUID, timingSafeEqual } from 'node:crypto';
2
2
  import fs from 'node:fs';
3
3
  import { McpServer, createMcpHandler, parseJSONRPCMessage } from '@modelcontextprotocol/server';
4
4
  import { z } from 'zod';
5
- import { deleteNote, editNote, getCachedVault, resolveSafe, writeNote } from './vault.js';
6
- import { removeFileFromIndex, searchFts, upsertFileInIndex } from './search-index.js';
5
+ import {
6
+ deleteNote,
7
+ discardGitChange,
8
+ editNote,
9
+ getCachedVault,
10
+ getGitChanges,
11
+ getGitDiff,
12
+ gitCommitAndPush,
13
+ renameDir,
14
+ renameNote,
15
+ resolveSafe,
16
+ writeNote
17
+ } from './vault.js';
18
+ import { removeFileFromIndex, searchFts, syncIndex, upsertFileInIndex } from './search-index.js';
19
+
20
+ const VALID_MCP_MODES = new Set(['read', 'write', 'full', 'off']);
21
+
22
+ export function getMcpConfig() {
23
+ const readOnly = process.env.PAGEDEX_READ_ONLY === '1' || process.env.PAGEDEX_READ_ONLY === 'true';
24
+ const rawMode = (process.env.PAGEDEX_MCP_MODE || 'full').toLowerCase();
25
+ const normalized = VALID_MCP_MODES.has(rawMode) ? rawMode : 'full';
26
+ const mode = normalized === 'off' ? 'off' : readOnly ? 'read' : normalized;
27
+ const allowWrite = mode === 'write' || mode === 'full';
28
+ const allowDelete = mode === 'full';
29
+ const allowGit =
30
+ !readOnly &&
31
+ (process.env.PAGEDEX_MCP_GIT === '1' || process.env.PAGEDEX_MCP_GIT === 'true') &&
32
+ allowWrite;
33
+ return { mode, readOnly, allowWrite, allowDelete, allowGit };
34
+ }
35
+
36
+ function readMcpToken() {
37
+ const tokenFile = process.env.PAGEDEX_MCP_TOKEN_FILE;
38
+ if (tokenFile) {
39
+ return fs.readFileSync(tokenFile, 'utf8').trim();
40
+ }
41
+ return (process.env.PAGEDEX_MCP_TOKEN || '').trim();
42
+ }
43
+
44
+ function isAuthorizedMcpRequest(req) {
45
+ const expected = readMcpToken();
46
+ if (!expected) return true;
47
+ const auth = req.headers.get('authorization') || '';
48
+ const match = auth.match(/^Bearer\s+(.+)$/i);
49
+ if (!match) return false;
50
+ const provided = match[1].trim();
51
+ const a = Buffer.from(provided, 'utf8');
52
+ const b = Buffer.from(expected, 'utf8');
53
+ return a.length === b.length && timingSafeEqual(a, b);
54
+ }
7
55
 
8
56
  const sseSessions = new Map();
9
57
  const encoder = new TextEncoder();
@@ -88,7 +136,7 @@ class WebStandardSSEServerTransport {
88
136
  }
89
137
  }
90
138
 
91
- function createVaultMcpServer() {
139
+ function createVaultMcpServer(config = getMcpConfig()) {
92
140
  const server = new McpServer({
93
141
  name: 'vault-mcp',
94
142
  version: '0.1.0'
@@ -233,68 +281,142 @@ function createVaultMcpServer() {
233
281
  );
234
282
 
235
283
  server.registerTool(
236
- 'write_note',
284
+ 'get_vault_info',
237
285
  {
238
- title: 'Write or Edit Note',
239
- description:
240
- 'Create/overwrite/append a note (with content), or partially edit an existing note (with target + replacement).',
286
+ title: 'Get Vault Info',
287
+ description: 'Return vault summary: total indexed files and the most recently modified files.',
241
288
  inputSchema: z.object({
242
- path: z.string(),
243
- content: z.string().optional(),
244
- append: z.boolean().optional(),
245
- target: z.string().optional(),
246
- replacement: z.string().optional(),
247
- replaceAll: z.boolean().optional()
289
+ recentLimit: z.number().int().min(1).max(50).optional()
290
+ }),
291
+ outputSchema: z.object({
292
+ totalFiles: z.number(),
293
+ recentFiles: z.array(
294
+ z.object({
295
+ path: z.string(),
296
+ mtimeMs: z.number()
297
+ })
298
+ )
299
+ })
300
+ },
301
+ async ({ recentLimit }) => {
302
+ const { flatFiles, fileStats } = getCachedVault();
303
+ const limit = recentLimit ?? 15;
304
+ const recentFiles = [...(fileStats || [])]
305
+ .sort((a, b) => (b.mtimeMs || 0) - (a.mtimeMs || 0))
306
+ .slice(0, limit)
307
+ .map((f) => ({ path: f.path, mtimeMs: f.mtimeMs || 0 }));
308
+ const lines = recentFiles.map((f) => f.path);
309
+ return {
310
+ structuredContent: {
311
+ totalFiles: flatFiles.length,
312
+ recentFiles
313
+ },
314
+ content: [
315
+ {
316
+ type: 'text',
317
+ text: `Total files: ${flatFiles.length}\nRecent files:\n${lines.length ? lines.join('\n') : '(none)'}`
318
+ }
319
+ ]
320
+ };
321
+ }
322
+ );
323
+
324
+ server.registerTool(
325
+ 'get_note_links',
326
+ {
327
+ title: 'Get Note Links and Outline',
328
+ description: 'Return outgoing [[wikilinks]], incoming backlinks, and Markdown headings for a note.',
329
+ inputSchema: z.object({
330
+ path: z.string()
248
331
  }),
249
332
  outputSchema: z.object({
250
- ok: z.boolean(),
251
333
  path: z.string(),
252
- mode: z.enum(['write', 'append', 'edit']),
253
- bytes: z.number().optional(),
254
- replacements: z.number().optional()
334
+ outgoingLinks: z.array(z.string()),
335
+ backlinks: z.array(z.string()),
336
+ headings: z.array(
337
+ z.object({
338
+ level: z.number(),
339
+ text: z.string()
340
+ })
341
+ )
255
342
  })
256
343
  },
257
- async ({ path: relPath, content, append, target, replacement, replaceAll }) => {
258
- if (typeof target === 'string' && typeof replacement === 'string') {
259
- const result = editNote(relPath, target, replacement, { replaceAll: Boolean(replaceAll) });
260
- if (result.error) {
261
- return { isError: true, content: [{ type: 'text', text: result.error }] };
262
- }
263
- upsertFileInIndex(result.path);
264
- return {
265
- structuredContent: {
266
- ok: true,
267
- path: result.path,
268
- mode: 'edit',
269
- replacements: result.replacements
270
- },
271
- content: [
272
- {
273
- type: 'text',
274
- text: `Edited ${result.path} (${result.replacements} replacement${result.replacements === 1 ? '' : 's'})`
275
- }
276
- ]
277
- };
344
+ async ({ path: relPath }) => {
345
+ const full = resolveSafe(relPath);
346
+ if (!full || !fs.existsSync(full) || !fs.statSync(full).isFile()) {
347
+ return { isError: true, content: [{ type: 'text', text: `Not found: ${relPath}` }] };
278
348
  }
279
- if (typeof content !== 'string') {
280
- return { isError: true, content: [{ type: 'text', text: 'Provide either content or target + replacement' }] };
349
+ const raw = fs.readFileSync(full, 'utf8');
350
+ const outgoingSet = new Set();
351
+ for (const m of raw.matchAll(/\[\[([^\]|#]+)(?:[|#][^\]]*)?\]\]/g)) {
352
+ const target = m[1].trim();
353
+ if (target) outgoingSet.add(target);
281
354
  }
282
- const result = writeNote(relPath, content, { append: Boolean(append) });
283
- if (!result) {
284
- return { isError: true, content: [{ type: 'text', text: `Invalid or protected path: ${relPath}` }] };
355
+ const headings = [];
356
+ for (const line of raw.split('\n')) {
357
+ const hm = line.match(/^(#{1,6})\s+(.+?)\s*#*$/);
358
+ if (hm) headings.push({ level: hm[1].length, text: hm[2].trim() });
285
359
  }
286
- upsertFileInIndex(result.path);
360
+ const { backlinks } = getCachedVault();
361
+ const outgoingLinks = [...outgoingSet];
362
+ const incoming = backlinks[relPath] || [];
363
+ return {
364
+ structuredContent: {
365
+ path: relPath,
366
+ outgoingLinks,
367
+ backlinks: incoming,
368
+ headings
369
+ },
370
+ content: [
371
+ {
372
+ type: 'text',
373
+ text: [
374
+ `# ${relPath}`,
375
+ `Outgoing: ${outgoingLinks.length ? outgoingLinks.join(', ') : 'none'}`,
376
+ `Backlinks: ${incoming.length ? incoming.join(', ') : 'none'}`,
377
+ `Headings: ${headings.length ? headings.map((h) => `${'#'.repeat(h.level)} ${h.text}`).join(' | ') : 'none'}`
378
+ ].join('\n')
379
+ }
380
+ ]
381
+ };
382
+ }
383
+ );
384
+
385
+ server.registerTool(
386
+ 'git_status',
387
+ {
388
+ title: 'Git Status',
389
+ description: 'List uncommitted modified, added, or untracked files in the vault.',
390
+ inputSchema: z.object({}),
391
+ outputSchema: z.object({
392
+ isGit: z.boolean(),
393
+ count: z.number(),
394
+ changes: z.array(
395
+ z.object({
396
+ status: z.string(),
397
+ path: z.string(),
398
+ mtimeMs: z.number()
399
+ })
400
+ )
401
+ })
402
+ },
403
+ async () => {
404
+ const { isGit, changes } = getGitChanges();
405
+ const lines = changes.map((c) => `[${c.status}] ${c.path}`);
287
406
  return {
288
407
  structuredContent: {
289
- ok: true,
290
- path: result.path,
291
- mode: append ? 'append' : 'write',
292
- bytes: result.bytes
408
+ isGit,
409
+ count: changes.length,
410
+ changes
293
411
  },
294
412
  content: [
295
413
  {
296
414
  type: 'text',
297
- text: `${append ? 'Appended' : 'Wrote'} ${result.path} (${result.bytes} bytes)`
415
+ text: !isGit
416
+ ? 'Not a git repository'
417
+ : lines.length
418
+ ? lines.join('\n')
419
+ : 'Working tree clean'
298
420
  }
299
421
  ]
300
422
  };
@@ -302,40 +424,276 @@ function createVaultMcpServer() {
302
424
  );
303
425
 
304
426
  server.registerTool(
305
- 'delete_note',
427
+ 'git_diff',
306
428
  {
307
- title: 'Delete Note',
308
- description: 'Delete a note/file from the vault by relative path.',
429
+ title: 'Git Diff',
430
+ description: 'Get the unified git diff for a specific file in the vault.',
309
431
  inputSchema: z.object({
310
432
  path: z.string()
311
433
  }),
312
434
  outputSchema: z.object({
313
- ok: z.boolean(),
314
- path: z.string()
435
+ path: z.string(),
436
+ untracked: z.boolean(),
437
+ raw: z.string()
315
438
  })
316
439
  },
317
440
  async ({ path: relPath }) => {
318
- const result = deleteNote(relPath);
319
- if (!result) {
320
- return { isError: true, content: [{ type: 'text', text: `Not found or protected path: ${relPath}` }] };
441
+ const diff = getGitDiff(relPath);
442
+ if (diff.error) {
443
+ return { isError: true, content: [{ type: 'text', text: diff.error }] };
321
444
  }
322
- removeFileFromIndex(result.path);
323
445
  return {
324
446
  structuredContent: {
325
- ok: true,
326
- path: result.path
447
+ path: diff.path,
448
+ untracked: Boolean(diff.untracked),
449
+ raw: diff.raw || ''
327
450
  },
328
- content: [{ type: 'text', text: `Deleted ${result.path}` }]
451
+ content: [
452
+ {
453
+ type: 'text',
454
+ text: diff.raw ? diff.raw : `No changes in ${diff.path}`
455
+ }
456
+ ]
329
457
  };
330
458
  }
331
459
  );
332
460
 
461
+ if (config.allowWrite) {
462
+ server.registerTool(
463
+ 'write_note',
464
+ {
465
+ title: 'Write or Edit Note',
466
+ description:
467
+ 'Create/overwrite/append a note (with content), or partially edit an existing note (with target + replacement).',
468
+ inputSchema: z.object({
469
+ path: z.string(),
470
+ content: z.string().optional(),
471
+ append: z.boolean().optional(),
472
+ target: z.string().optional(),
473
+ replacement: z.string().optional(),
474
+ replaceAll: z.boolean().optional()
475
+ }),
476
+ outputSchema: z.object({
477
+ ok: z.boolean(),
478
+ path: z.string(),
479
+ mode: z.enum(['write', 'append', 'edit']),
480
+ bytes: z.number().optional(),
481
+ replacements: z.number().optional()
482
+ })
483
+ },
484
+ async ({ path: relPath, content, append, target, replacement, replaceAll }) => {
485
+ if (typeof target === 'string' && typeof replacement === 'string') {
486
+ const result = editNote(relPath, target, replacement, { replaceAll: Boolean(replaceAll) });
487
+ if (result.error) {
488
+ return { isError: true, content: [{ type: 'text', text: result.error }] };
489
+ }
490
+ upsertFileInIndex(result.path);
491
+ return {
492
+ structuredContent: {
493
+ ok: true,
494
+ path: result.path,
495
+ mode: 'edit',
496
+ replacements: result.replacements
497
+ },
498
+ content: [
499
+ {
500
+ type: 'text',
501
+ text: `Edited ${result.path} (${result.replacements} replacement${result.replacements === 1 ? '' : 's'})`
502
+ }
503
+ ]
504
+ };
505
+ }
506
+ if (typeof content !== 'string') {
507
+ return { isError: true, content: [{ type: 'text', text: 'Provide either content or target + replacement' }] };
508
+ }
509
+ const result = writeNote(relPath, content, { append: Boolean(append) });
510
+ if (!result) {
511
+ return { isError: true, content: [{ type: 'text', text: `Invalid or protected path: ${relPath}` }] };
512
+ }
513
+ upsertFileInIndex(result.path);
514
+ return {
515
+ structuredContent: {
516
+ ok: true,
517
+ path: result.path,
518
+ mode: append ? 'append' : 'write',
519
+ bytes: result.bytes
520
+ },
521
+ content: [
522
+ {
523
+ type: 'text',
524
+ text: `${append ? 'Appended' : 'Wrote'} ${result.path} (${result.bytes} bytes)`
525
+ }
526
+ ]
527
+ };
528
+ }
529
+ );
530
+
531
+ server.registerTool(
532
+ 'move_path',
533
+ {
534
+ title: 'Move or Rename File/Folder',
535
+ description: 'Rename or move a file or folder within the vault.',
536
+ inputSchema: z.object({
537
+ oldPath: z.string(),
538
+ newPath: z.string(),
539
+ type: z.enum(['auto', 'file', 'dir']).optional()
540
+ }),
541
+ outputSchema: z.object({
542
+ ok: z.boolean(),
543
+ type: z.enum(['file', 'dir']),
544
+ oldPath: z.string(),
545
+ newPath: z.string()
546
+ })
547
+ },
548
+ async ({ oldPath, newPath, type = 'auto' }) => {
549
+ const full = resolveSafe(oldPath);
550
+ if (!full || !fs.existsSync(full)) {
551
+ return { isError: true, content: [{ type: 'text', text: `Source not found: ${oldPath}` }] };
552
+ }
553
+ const isDir = type === 'dir' || (type === 'auto' && fs.statSync(full).isDirectory());
554
+ const result = isDir ? renameDir(oldPath, newPath) : renameNote(oldPath, newPath);
555
+ if (result.error) {
556
+ return { isError: true, content: [{ type: 'text', text: result.error }] };
557
+ }
558
+ if (isDir) {
559
+ syncIndex();
560
+ } else {
561
+ removeFileFromIndex(result.oldPath);
562
+ upsertFileInIndex(result.newPath);
563
+ }
564
+ return {
565
+ structuredContent: {
566
+ ok: true,
567
+ type: isDir ? 'dir' : 'file',
568
+ oldPath: result.oldPath,
569
+ newPath: result.newPath
570
+ },
571
+ content: [{ type: 'text', text: `Moved ${result.oldPath} -> ${result.newPath}` }]
572
+ };
573
+ }
574
+ );
575
+ }
576
+
577
+ if (config.allowDelete) {
578
+ server.registerTool(
579
+ 'delete_note',
580
+ {
581
+ title: 'Delete Note',
582
+ description: 'Delete a note/file from the vault by relative path.',
583
+ inputSchema: z.object({
584
+ path: z.string()
585
+ }),
586
+ outputSchema: z.object({
587
+ ok: z.boolean(),
588
+ path: z.string()
589
+ })
590
+ },
591
+ async ({ path: relPath }) => {
592
+ const result = deleteNote(relPath);
593
+ if (!result) {
594
+ return { isError: true, content: [{ type: 'text', text: `Not found or protected path: ${relPath}` }] };
595
+ }
596
+ removeFileFromIndex(result.path);
597
+ return {
598
+ structuredContent: {
599
+ ok: true,
600
+ path: result.path
601
+ },
602
+ content: [{ type: 'text', text: `Deleted ${result.path}` }]
603
+ };
604
+ }
605
+ );
606
+ }
607
+
608
+ if (config.allowGit) {
609
+ server.registerTool(
610
+ 'git_commit',
611
+ {
612
+ title: 'Git Commit',
613
+ description: 'Stage and commit vault changes locally (never pushes).',
614
+ inputSchema: z.object({
615
+ message: z.string(),
616
+ files: z.array(z.string()).optional()
617
+ }),
618
+ outputSchema: z.object({
619
+ ok: z.boolean(),
620
+ output: z.string()
621
+ })
622
+ },
623
+ async ({ message, files }) => {
624
+ const result = gitCommitAndPush({ message, files, push: false });
625
+ if (result.error) {
626
+ return { isError: true, content: [{ type: 'text', text: result.error }] };
627
+ }
628
+ return {
629
+ structuredContent: {
630
+ ok: true,
631
+ output: result.output || ''
632
+ },
633
+ content: [{ type: 'text', text: result.output || 'Committed' }]
634
+ };
635
+ }
636
+ );
637
+
638
+ server.registerTool(
639
+ 'git_discard',
640
+ {
641
+ title: 'Git Discard File Changes',
642
+ description: 'Discard all uncommitted changes to a file (or remove an untracked file).',
643
+ inputSchema: z.object({
644
+ path: z.string()
645
+ }),
646
+ outputSchema: z.object({
647
+ ok: z.boolean(),
648
+ path: z.string(),
649
+ discarded: z.string()
650
+ })
651
+ },
652
+ async ({ path: relPath }) => {
653
+ const result = discardGitChange({ path: relPath });
654
+ if (result.error) {
655
+ return { isError: true, content: [{ type: 'text', text: result.error }] };
656
+ }
657
+ syncIndex();
658
+ return {
659
+ structuredContent: {
660
+ ok: true,
661
+ path: result.path,
662
+ discarded: result.discarded || 'file'
663
+ },
664
+ content: [{ type: 'text', text: `Discarded changes in ${result.path} (${result.discarded})` }]
665
+ };
666
+ }
667
+ );
668
+ }
669
+
333
670
  return server;
334
671
  }
335
672
 
336
- const mcpHandler = createMcpHandler(createVaultMcpServer);
673
+ const mcpHandlers = new Map();
674
+
675
+ function getMcpHandler(config) {
676
+ const key = `${config.mode}:${config.allowGit}`;
677
+ let handler = mcpHandlers.get(key);
678
+ if (!handler) {
679
+ handler = createMcpHandler(() => createVaultMcpServer(config));
680
+ mcpHandlers.set(key, handler);
681
+ }
682
+ return handler;
683
+ }
337
684
 
338
685
  export async function handleMcpRequest(req) {
686
+ const config = getMcpConfig();
687
+ if (config.mode === 'off') {
688
+ return Response.json({ error: 'MCP endpoint is disabled' }, { status: 404 });
689
+ }
690
+ if (!isAuthorizedMcpRequest(req)) {
691
+ return Response.json(
692
+ { error: 'Unauthorized' },
693
+ { status: 401, headers: { 'WWW-Authenticate': 'Bearer' } }
694
+ );
695
+ }
696
+
339
697
  const url = new URL(req.url, 'http://localhost');
340
698
  const sessionId = url.searchParams.get('sessionId');
341
699
  const isStreamableHttpGet =
@@ -345,7 +703,7 @@ export async function handleMcpRequest(req) {
345
703
  if (req.method === 'GET' && !sessionId && !isStreamableHttpGet) {
346
704
  const transport = new WebStandardSSEServerTransport(url.pathname);
347
705
  sseSessions.set(transport.sessionId, transport);
348
- const server = createVaultMcpServer();
706
+ const server = createVaultMcpServer(config);
349
707
  await server.connect(transport);
350
708
  return transport.createResponse(req.signal);
351
709
  }
@@ -360,5 +718,5 @@ export async function handleMcpRequest(req) {
360
718
  }
361
719
 
362
720
  // 3. Official MCP v2 handler: supports BOTH Modern (2026-07-28 server/discover) and Legacy Streamable HTTP (2025-11-25 initialize)
363
- return mcpHandler.fetch(req);
721
+ return getMcpHandler(config).fetch(req);
364
722
  }