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 +29 -9
- package/bin/pagedex.js +73 -5
- package/docs/MCP.md +107 -0
- package/docs/README.md +2 -1
- package/docs/getting-started.md +6 -0
- package/docs/images/desktop-edit.png +0 -0
- package/docs/images/desktop-preview.png +0 -0
- package/docs/images/desktop-switcher.png +0 -0
- package/docs/images/mobile-preview.png +0 -0
- package/docs/mcp-and-api.md +7 -6
- package/package.json +1 -1
- package/src/server/index.js +4 -0
- package/src/server/mcp.js +423 -65
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
|
+
|  |  |
|
|
28
|
+
|
|
29
|
+
| Quick Switcher (`Cmd/Ctrl+K`) | Mobile View & Bottom Bar |
|
|
30
|
+
|---|---|
|
|
31
|
+
|  |  |
|
|
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
|
|
40
|
+
dir Directory to serve (defaults to current working directory)
|
|
31
41
|
|
|
32
42
|
Options:
|
|
33
|
-
-p, --port <port>
|
|
34
|
-
-H, --host <host>
|
|
35
|
-
-o, --open
|
|
36
|
-
|
|
37
|
-
-
|
|
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 --
|
|
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>
|
|
20
|
-
-H, --host <host>
|
|
21
|
-
-o, --open
|
|
22
|
-
|
|
23
|
-
-
|
|
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
|
|
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.
|
package/docs/getting-started.md
CHANGED
|
@@ -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
|
+

|
|
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
|
package/docs/mcp-and-api.md
CHANGED
|
@@ -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
|
-
|
|
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
|
-
| `
|
|
31
|
-
| `
|
|
32
|
-
| `
|
|
33
|
-
|
|
|
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
package/src/server/index.js
CHANGED
|
@@ -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 {
|
|
6
|
-
|
|
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
|
-
'
|
|
284
|
+
'get_vault_info',
|
|
237
285
|
{
|
|
238
|
-
title: '
|
|
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
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
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
|
-
|
|
253
|
-
|
|
254
|
-
|
|
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
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
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
|
-
|
|
280
|
-
|
|
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
|
|
283
|
-
|
|
284
|
-
|
|
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
|
-
|
|
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
|
-
|
|
290
|
-
|
|
291
|
-
|
|
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:
|
|
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
|
-
'
|
|
427
|
+
'git_diff',
|
|
306
428
|
{
|
|
307
|
-
title: '
|
|
308
|
-
description: '
|
|
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
|
-
|
|
314
|
-
|
|
435
|
+
path: z.string(),
|
|
436
|
+
untracked: z.boolean(),
|
|
437
|
+
raw: z.string()
|
|
315
438
|
})
|
|
316
439
|
},
|
|
317
440
|
async ({ path: relPath }) => {
|
|
318
|
-
const
|
|
319
|
-
if (
|
|
320
|
-
return { isError: true, content: [{ type: 'text', text:
|
|
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
|
-
|
|
326
|
-
|
|
447
|
+
path: diff.path,
|
|
448
|
+
untracked: Boolean(diff.untracked),
|
|
449
|
+
raw: diff.raw || ''
|
|
327
450
|
},
|
|
328
|
-
content: [
|
|
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
|
|
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
|
|
721
|
+
return getMcpHandler(config).fetch(req);
|
|
364
722
|
}
|