@jacklika/dsh-memory-mcp 0.1.0
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 +41 -0
- package/README.zh.md +41 -0
- package/package.json +33 -0
- package/src/server.d.ts +1 -0
- package/src/server.mjs +372 -0
package/README.md
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# @jacklika/dsh-memory-mcp
|
|
2
|
+
|
|
3
|
+
Zero-dependency MCP stdio server that exposes the memory vault to any MCP client (Claude Code, Cursor, Obsidian bridges, `dsh-mcp-client`). One process owns every write, so clients serialize through the server instead of negotiating filesystem locks — this is the "single writer" deployment of the memory suite.
|
|
4
|
+
|
|
5
|
+
## Run
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
# from the workspace root
|
|
9
|
+
node packages/memory-mcp/src/server.mjs --vault /path/to/vault
|
|
10
|
+
|
|
11
|
+
# or after install
|
|
12
|
+
npx dsh-memory-mcp --vault /path/to/vault
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
`--vault` defaults to `<cwd>/.dsh/memory/`; `--max-link-depth N` controls `wiki_read` link following (default 1).
|
|
16
|
+
|
|
17
|
+
## Surface
|
|
18
|
+
|
|
19
|
+
Tools: `wiki_read`, `wiki_search`, `wiki_write`, `wiki_graph` — same contract as `@jacklika/dsh-tool-memory-filesystem` / `-graph`, including `baseVersion` optimistic concurrency on writes. Resources: `note:///<id>` over `resources/list` / `resources/read`.
|
|
20
|
+
|
|
21
|
+
## Client configuration
|
|
22
|
+
|
|
23
|
+
Any MCP client that speaks stdio JSON-RPC can mount it:
|
|
24
|
+
|
|
25
|
+
```json
|
|
26
|
+
{
|
|
27
|
+
"mcpServers": {
|
|
28
|
+
"memory": {
|
|
29
|
+
"command": "node",
|
|
30
|
+
"args": ["/path/to/jacklika-agent-plugins-repo/packages/memory-mcp/src/server.mjs", "--vault", "/path/to/vault"]
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## Coordination contract
|
|
37
|
+
|
|
38
|
+
- One server process serializes **its own** writes; that is the robustness story.
|
|
39
|
+
- Independent server processes or direct filesystem writers are not serialized — `baseVersion` conflict errors and atomic rename still protect correctness, and the dsh `memory-git` plugin covers audit/history on the dsh tool-calling path.
|
|
40
|
+
- Writes through MCP do **not** produce git commits; mount `dsh-memory-git` on the dsh side or commit the vault externally if you need history for MCP-origin writes.
|
|
41
|
+
- Everything is local: stdio only, no network listener, no telemetry.
|
package/README.zh.md
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# @jacklika/dsh-memory-mcp
|
|
2
|
+
|
|
3
|
+
零依赖的 MCP stdio 服务器,把 memory vault 暴露给任意 MCP 客户端(Claude Code、Cursor、Obsidian 桥、`dsh-mcp-client`)。单一进程持有所有写操作,因此客户端通过服务器串行化,而不是自己协商文件系统锁——这是 memory 套件的“单写者”部署形态。
|
|
4
|
+
|
|
5
|
+
## 运行
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
# 从 workspace 根目录
|
|
9
|
+
node packages/memory-mcp/src/server.mjs --vault /path/to/vault
|
|
10
|
+
|
|
11
|
+
# 或安装后
|
|
12
|
+
npx dsh-memory-mcp --vault /path/to/vault
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
`--vault` 默认为 `<cwd>/.dsh/memory/`;`--max-link-depth N` 控制 `wiki_read` 的链接展开深度(默认 1)。
|
|
16
|
+
|
|
17
|
+
## 暴露面
|
|
18
|
+
|
|
19
|
+
工具:`wiki_read`、`wiki_search`、`wiki_write`、`wiki_graph` —— 与 `@jacklika/dsh-tool-memory-filesystem` / `-graph` 的契约相同,写操作带 `baseVersion` 乐观并发控制。资源:通过 `resources/list` / `resources/read` 访问 `note:///<id>`。
|
|
20
|
+
|
|
21
|
+
## 客户端配置
|
|
22
|
+
|
|
23
|
+
任何会说 stdio JSON-RPC 的 MCP 客户端都能挂载:
|
|
24
|
+
|
|
25
|
+
```json
|
|
26
|
+
{
|
|
27
|
+
"mcpServers": {
|
|
28
|
+
"memory": {
|
|
29
|
+
"command": "node",
|
|
30
|
+
"args": ["/path/to/jacklika-agent-plugins-repo/packages/memory-mcp/src/server.mjs", "--vault", "/path/to/vault"]
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## 协调约定
|
|
37
|
+
|
|
38
|
+
- 单个服务器进程串行化**自身**的写入;这是主要的健壮性保证。
|
|
39
|
+
- 独立的服务器进程或直接文件系统写入者不会被串行化——`baseVersion` 冲突错误与原子重命名仍能保护正确性,而 dsh 侧的 `memory-git` 插件会在 dsh 工具调用路径上覆盖审计/历史。
|
|
40
|
+
- 通过 MCP 的写入**不会**产生 git 提交;如果需要记录 MCP 来源写入的历史,请在 dsh 侧挂载 `dsh-memory-git` 或在外部提交 vault。
|
|
41
|
+
- 完全本地:仅 stdio,无网络监听,无遥测。
|
package/package.json
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@jacklika/dsh-memory-mcp",
|
|
3
|
+
"description": "Zero-dependency MCP stdio server exposing the memory vault to any MCP client — one process owns all writes.",
|
|
4
|
+
"version": "0.1.0",
|
|
5
|
+
"publishConfig": {
|
|
6
|
+
"access": "public"
|
|
7
|
+
},
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/JackLiKa/jacklika-agent-plugins-repo.git",
|
|
11
|
+
"directory": "packages/memory-mcp"
|
|
12
|
+
},
|
|
13
|
+
"type": "module",
|
|
14
|
+
"engines": {
|
|
15
|
+
"node": "^22.19.0 || >=24.0.0"
|
|
16
|
+
},
|
|
17
|
+
"main": "src/server.mjs",
|
|
18
|
+
"types": "src/server.d.ts",
|
|
19
|
+
"bin": {
|
|
20
|
+
"dsh-memory-mcp": "src/server.mjs"
|
|
21
|
+
},
|
|
22
|
+
"exports": {
|
|
23
|
+
".": "./src/server.mjs",
|
|
24
|
+
"./package.json": "./package.json"
|
|
25
|
+
},
|
|
26
|
+
"files": [
|
|
27
|
+
"src/server.mjs",
|
|
28
|
+
"src/server.d.ts",
|
|
29
|
+
"README.md",
|
|
30
|
+
"README.zh.md"
|
|
31
|
+
],
|
|
32
|
+
"license": "MIT"
|
|
33
|
+
}
|
package/src/server.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {}
|
package/src/server.mjs
ADDED
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* @jacklika/dsh-memory-mcp — a zero-dependency MCP stdio server exposing the
|
|
4
|
+
* memory vault to any MCP client. One process owns all writes, so clients
|
|
5
|
+
* serialize through the server instead of negotiating filesystem locks.
|
|
6
|
+
*
|
|
7
|
+
* Usage: dsh-memory-mcp [--vault <path>] [--max-link-depth N]
|
|
8
|
+
* --vault defaults to <cwd>/.dsh/memory/
|
|
9
|
+
*/
|
|
10
|
+
import { createHash, randomUUID } from 'node:crypto'
|
|
11
|
+
import { mkdir, readdir, readFile, realpath, rename, rm, writeFile } from 'node:fs/promises'
|
|
12
|
+
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path'
|
|
13
|
+
import process from 'node:process'
|
|
14
|
+
import readline from 'node:readline'
|
|
15
|
+
|
|
16
|
+
const PROTOCOL_VERSION = '2024-11-05'
|
|
17
|
+
const SERVER_INFO = { name: 'dsh-memory-mcp', version: '0.1.0' }
|
|
18
|
+
|
|
19
|
+
// ── CLI args ────────────────────────────────────────────────────────────────
|
|
20
|
+
|
|
21
|
+
const args = process.argv.slice(2)
|
|
22
|
+
function argValue(flag, fallback) {
|
|
23
|
+
const i = args.indexOf(flag)
|
|
24
|
+
return i >= 0 && args[i + 1] !== undefined ? args[i + 1] : fallback
|
|
25
|
+
}
|
|
26
|
+
const VAULT = resolve(argValue('--vault', join(process.cwd(), '.dsh', 'memory')))
|
|
27
|
+
const MAX_LINK_DEPTH = Number(argValue('--max-link-depth', '1'))
|
|
28
|
+
const EXTENSIONS = ['.md']
|
|
29
|
+
const EXCLUDE_DIRS = new Set(['.git', 'node_modules', '.obsidian'])
|
|
30
|
+
|
|
31
|
+
/** Return a POSIX-style vault-relative id from an absolute path. */
|
|
32
|
+
function vaultRelativeId(absolutePath) {
|
|
33
|
+
return relative(VAULT, absolutePath).replace(/\\/g, '/')
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// ── Vault operations (mirrors @jacklika/dsh-tool-memory-filesystem) ──────────
|
|
37
|
+
|
|
38
|
+
function containedPath(candidate) {
|
|
39
|
+
const absolute = resolve(VAULT, candidate)
|
|
40
|
+
const remainder = relative(VAULT, absolute)
|
|
41
|
+
if (remainder === '..' || remainder.startsWith(`..${sep}`) || isAbsolute(remainder)) {
|
|
42
|
+
throw new Error(`path ${candidate} is outside vault root ${VAULT}`)
|
|
43
|
+
}
|
|
44
|
+
return absolute
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function containedPathReal(candidate) {
|
|
48
|
+
const absolute = containedPath(candidate)
|
|
49
|
+
const canonicalRoot = await realpath(VAULT)
|
|
50
|
+
let ancestor = absolute
|
|
51
|
+
for (;;) {
|
|
52
|
+
try {
|
|
53
|
+
const canonicalAncestor = await realpath(ancestor)
|
|
54
|
+
const remainder = relative(canonicalRoot, canonicalAncestor)
|
|
55
|
+
if (remainder === '..' || remainder.startsWith(`..${sep}`) || isAbsolute(remainder)) {
|
|
56
|
+
throw new Error(`path ${candidate} resolves outside vault root ${VAULT}`)
|
|
57
|
+
}
|
|
58
|
+
return absolute
|
|
59
|
+
} catch (error) {
|
|
60
|
+
if (error?.code !== 'ENOENT') throw error
|
|
61
|
+
const parent = dirname(ancestor)
|
|
62
|
+
if (parent === ancestor) throw error
|
|
63
|
+
ancestor = parent
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function splitFrontmatter(text) {
|
|
69
|
+
if (!text.startsWith('---\n')) return { frontmatter: {}, body: text }
|
|
70
|
+
const end = text.indexOf('\n---\n', 4)
|
|
71
|
+
if (end < 0) return { frontmatter: {}, body: text }
|
|
72
|
+
const frontmatter = {}
|
|
73
|
+
for (const line of text.slice(4, end).split('\n')) {
|
|
74
|
+
const m = /^([A-Za-z0-9_-]+):\s*(.*)$/.exec(line)
|
|
75
|
+
if (m) frontmatter[m[1]] = m[2]
|
|
76
|
+
}
|
|
77
|
+
return { frontmatter, body: text.slice(end + 5) }
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function noteVersion(text) {
|
|
81
|
+
return createHash('sha1').update(text).digest('hex')
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function extractLinks(text) {
|
|
85
|
+
const links = []
|
|
86
|
+
for (const m of text.matchAll(/\[\[([^\]|]+)(?:\|[^\]]*)?\]\]/g)) links.push(m[1].trim())
|
|
87
|
+
return links
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async function listNotePaths(dir = VAULT) {
|
|
91
|
+
const results = []
|
|
92
|
+
let entries
|
|
93
|
+
try {
|
|
94
|
+
entries = await readdir(dir, { withFileTypes: true })
|
|
95
|
+
} catch {
|
|
96
|
+
return results
|
|
97
|
+
}
|
|
98
|
+
for (const entry of entries) {
|
|
99
|
+
if (entry.name.startsWith('.') || EXCLUDE_DIRS.has(entry.name)) continue
|
|
100
|
+
const full = join(dir, entry.name)
|
|
101
|
+
if (entry.isDirectory()) results.push(...await listNotePaths(full))
|
|
102
|
+
else if (entry.isFile() && EXTENSIONS.some(ext => entry.name.endsWith(ext))) results.push(full)
|
|
103
|
+
}
|
|
104
|
+
return results
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async function resolveLinkTarget(link) {
|
|
108
|
+
for (const candidate of [link, ...EXTENSIONS.map(ext => `${link}${ext}`)]) {
|
|
109
|
+
try {
|
|
110
|
+
const p = await containedPathReal(candidate)
|
|
111
|
+
await readFile(p, 'utf8')
|
|
112
|
+
return p
|
|
113
|
+
} catch (error) {
|
|
114
|
+
if (error?.code !== 'ENOENT') throw error
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
// search by basename among indexed notes
|
|
118
|
+
const want = basename(link).replace(/\.[^.]+$/, '')
|
|
119
|
+
for (const p of await listNotePaths()) {
|
|
120
|
+
if (basename(p).replace(/\.[^.]+$/, '') === want) return p
|
|
121
|
+
}
|
|
122
|
+
return undefined
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async function readNote(absolutePath, depth, visited = new Set()) {
|
|
126
|
+
const id = vaultRelativeId(absolutePath)
|
|
127
|
+
if (visited.has(absolutePath)) {
|
|
128
|
+
return { id, frontmatter: {}, body: '', links: [], version: '', linkedNotes: [] }
|
|
129
|
+
}
|
|
130
|
+
const next = new Set(visited)
|
|
131
|
+
next.add(absolutePath)
|
|
132
|
+
const text = await readFile(absolutePath, 'utf8')
|
|
133
|
+
const { frontmatter, body } = splitFrontmatter(text)
|
|
134
|
+
const links = extractLinks(text)
|
|
135
|
+
const linkedNotes = []
|
|
136
|
+
if (depth > 0) {
|
|
137
|
+
for (const link of links) {
|
|
138
|
+
const target = await resolveLinkTarget(link)
|
|
139
|
+
if (target !== undefined) linkedNotes.push(await readNote(target, depth - 1, next))
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return { id, frontmatter, body, links, version: noteVersion(text), linkedNotes }
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async function searchNotes(query, maxResults = 20) {
|
|
146
|
+
const terms = query.toLowerCase().split(/\s+/).filter(Boolean)
|
|
147
|
+
const notes = []
|
|
148
|
+
for (const path of await listNotePaths()) {
|
|
149
|
+
const text = await readFile(path, 'utf8')
|
|
150
|
+
const { body } = splitFrontmatter(text)
|
|
151
|
+
const id = vaultRelativeId(path)
|
|
152
|
+
const title = /^#\s+(.+)$/m.exec(body)?.[1]?.trim() ?? id.replace(/\.[^.]+$/, '')
|
|
153
|
+
notes.push({ id, title, links: extractLinks(text), body })
|
|
154
|
+
}
|
|
155
|
+
const hits = notes.filter(n => {
|
|
156
|
+
const haystack = `${n.id} ${n.title} ${n.body}`.toLowerCase()
|
|
157
|
+
return terms.every(t => haystack.includes(t))
|
|
158
|
+
})
|
|
159
|
+
hits.sort((a, b) => a.id.localeCompare(b.id))
|
|
160
|
+
return hits.slice(0, maxResults).map(({ id, title }) => ({ id, title }))
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
async function writeNote(id, content, mode = 'append', baseVersion) {
|
|
164
|
+
if (!EXTENSIONS.some(ext => id.endsWith(ext))) {
|
|
165
|
+
throw new Error(`note id must end with one of ${EXTENSIONS.join(', ')}`)
|
|
166
|
+
}
|
|
167
|
+
await mkdir(VAULT, { recursive: true })
|
|
168
|
+
const absolutePath = await containedPathReal(id)
|
|
169
|
+
await mkdir(dirname(absolutePath), { recursive: true })
|
|
170
|
+
await containedPathReal(id)
|
|
171
|
+
let existing
|
|
172
|
+
try {
|
|
173
|
+
existing = await readFile(absolutePath, 'utf8')
|
|
174
|
+
} catch { /* new note */ }
|
|
175
|
+
if (baseVersion !== undefined && noteVersion(existing ?? '') !== baseVersion) {
|
|
176
|
+
throw new Error(`note ${id} changed since it was read; re-read it before writing`)
|
|
177
|
+
}
|
|
178
|
+
let finalBody
|
|
179
|
+
if (mode === 'overwrite') {
|
|
180
|
+
finalBody = content
|
|
181
|
+
} else {
|
|
182
|
+
const { frontmatter, body } = splitFrontmatter(existing ?? '')
|
|
183
|
+
const fm = Object.keys(frontmatter).length > 0
|
|
184
|
+
? `---\n${Object.entries(frontmatter).map(([k, v]) => `${k}: ${v}`).join('\n')}\n---\n\n`
|
|
185
|
+
: ''
|
|
186
|
+
finalBody = `${fm}${body}\n\n## ${new Date().toISOString()}\n\n${content}\n`
|
|
187
|
+
}
|
|
188
|
+
const tmp = `${absolutePath}.tmp-${process.pid}-${randomUUID()}`
|
|
189
|
+
try {
|
|
190
|
+
await writeFile(tmp, finalBody, 'utf8')
|
|
191
|
+
await rename(tmp, absolutePath)
|
|
192
|
+
} catch (error) {
|
|
193
|
+
await rm(tmp, { force: true }).catch(() => undefined)
|
|
194
|
+
throw error
|
|
195
|
+
}
|
|
196
|
+
return { id: vaultRelativeId(absolutePath), mode, bytes: Buffer.byteLength(finalBody, 'utf8') }
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
async function buildGraph(id, depth = 1, maxNodes = 200) {
|
|
200
|
+
const idOf = p => vaultRelativeId(p)
|
|
201
|
+
const title = async p => {
|
|
202
|
+
const { body } = splitFrontmatter(await readFile(p, 'utf8'))
|
|
203
|
+
return /^#\s+(.+)$/m.exec(body)?.[1]?.trim() ?? idOf(p).replace(/\.[^.]+$/, '')
|
|
204
|
+
}
|
|
205
|
+
let paths
|
|
206
|
+
if (id === undefined) {
|
|
207
|
+
paths = await listNotePaths()
|
|
208
|
+
} else {
|
|
209
|
+
const start = await containedPathReal(id)
|
|
210
|
+
const seen = new Map([[idOf(start), start]])
|
|
211
|
+
let frontier = [start]
|
|
212
|
+
for (let level = 0; level < depth && frontier.length > 0; level += 1) {
|
|
213
|
+
const next = []
|
|
214
|
+
for (const p of frontier) {
|
|
215
|
+
for (const link of extractLinks(await readFile(p, 'utf8'))) {
|
|
216
|
+
const t = await resolveLinkTarget(link)
|
|
217
|
+
if (t !== undefined && !seen.has(idOf(t))) { seen.set(idOf(t), t); next.push(t) }
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
frontier = next
|
|
221
|
+
}
|
|
222
|
+
paths = [...seen.values()]
|
|
223
|
+
}
|
|
224
|
+
const truncated = paths.length > maxNodes
|
|
225
|
+
const capped = paths.slice(0, maxNodes)
|
|
226
|
+
const idSet = new Set(capped.map(idOf))
|
|
227
|
+
const nodes = []
|
|
228
|
+
const edges = []
|
|
229
|
+
for (const p of capped) {
|
|
230
|
+
nodes.push({ id: idOf(p), title: await title(p) })
|
|
231
|
+
for (const link of extractLinks(await readFile(p, 'utf8'))) {
|
|
232
|
+
const t = await resolveLinkTarget(link)
|
|
233
|
+
if (t !== undefined && idSet.has(idOf(t))) edges.push({ from: idOf(p), to: idOf(t) })
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
return { nodes, edges, truncated }
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// ── MCP tool surface ────────────────────────────────────────────────────────
|
|
240
|
+
|
|
241
|
+
const TOOLS = [
|
|
242
|
+
{
|
|
243
|
+
name: 'wiki_read',
|
|
244
|
+
description: 'Read one Markdown note from the wiki vault, following Obsidian-style [[link]] references up to the configured depth.',
|
|
245
|
+
inputSchema: {
|
|
246
|
+
type: 'object',
|
|
247
|
+
properties: { id: { type: 'string', description: 'Vault-relative path of the note (e.g. "concepts/RAG.md").' } },
|
|
248
|
+
required: ['id'],
|
|
249
|
+
},
|
|
250
|
+
},
|
|
251
|
+
{
|
|
252
|
+
name: 'wiki_search',
|
|
253
|
+
description: 'Search the wiki vault by note title or body keyword. Returns matching note ids and titles.',
|
|
254
|
+
inputSchema: {
|
|
255
|
+
type: 'object',
|
|
256
|
+
properties: { query: { type: 'string', description: 'Keyword or phrase to match.' } },
|
|
257
|
+
required: ['query'],
|
|
258
|
+
},
|
|
259
|
+
},
|
|
260
|
+
{
|
|
261
|
+
name: 'wiki_write',
|
|
262
|
+
description: 'Create a new note or append to an existing note. Pass baseVersion from wiki_read to fail loudly when the note changed since the read.',
|
|
263
|
+
inputSchema: {
|
|
264
|
+
type: 'object',
|
|
265
|
+
properties: {
|
|
266
|
+
id: { type: 'string', description: 'Vault-relative note path ending in .md.' },
|
|
267
|
+
content: { type: 'string', description: 'Markdown content to write or append.' },
|
|
268
|
+
mode: { type: 'string', enum: ['append', 'overwrite'], description: 'append (default) or overwrite.' },
|
|
269
|
+
baseVersion: { type: 'string', description: 'Optional version from wiki_read; the write fails if the note changed.' },
|
|
270
|
+
},
|
|
271
|
+
required: ['id', 'content'],
|
|
272
|
+
},
|
|
273
|
+
},
|
|
274
|
+
{
|
|
275
|
+
name: 'wiki_graph',
|
|
276
|
+
description: 'Return the vault [[link]] graph, or the subgraph reachable from one note within depth hops.',
|
|
277
|
+
inputSchema: {
|
|
278
|
+
type: 'object',
|
|
279
|
+
properties: {
|
|
280
|
+
id: { type: 'string', description: 'Optional center note id.' },
|
|
281
|
+
depth: { type: 'integer', description: 'Max link hops from the center (default 1).' },
|
|
282
|
+
},
|
|
283
|
+
},
|
|
284
|
+
},
|
|
285
|
+
]
|
|
286
|
+
|
|
287
|
+
async function callTool(name, a = {}) {
|
|
288
|
+
switch (name) {
|
|
289
|
+
case 'wiki_read':
|
|
290
|
+
return readNote(await containedPathReal(a.id), MAX_LINK_DEPTH)
|
|
291
|
+
case 'wiki_search':
|
|
292
|
+
return searchNotes(a.query ?? '')
|
|
293
|
+
case 'wiki_write':
|
|
294
|
+
return writeNote(a.id, a.content ?? '', a.mode ?? 'append', a.baseVersion)
|
|
295
|
+
case 'wiki_graph':
|
|
296
|
+
return buildGraph(a.id, a.depth ?? 1)
|
|
297
|
+
default:
|
|
298
|
+
throw new Error(`unknown tool ${name}`)
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// ── JSON-RPC stdio loop ─────────────────────────────────────────────────────
|
|
303
|
+
|
|
304
|
+
function send(message) {
|
|
305
|
+
process.stdout.write(`${JSON.stringify(message)}\n`)
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
const rl = readline.createInterface({ input: process.stdin })
|
|
309
|
+
let requests = Promise.resolve()
|
|
310
|
+
rl.on('line', line => {
|
|
311
|
+
requests = requests.then(async () => {
|
|
312
|
+
let msg
|
|
313
|
+
try {
|
|
314
|
+
msg = JSON.parse(line)
|
|
315
|
+
} catch {
|
|
316
|
+
return
|
|
317
|
+
}
|
|
318
|
+
const { id, method, params } = msg
|
|
319
|
+
if (method === undefined || method.startsWith('notifications/')) return
|
|
320
|
+
try {
|
|
321
|
+
let result
|
|
322
|
+
switch (method) {
|
|
323
|
+
case 'initialize':
|
|
324
|
+
result = {
|
|
325
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
326
|
+
capabilities: { tools: {}, resources: {} },
|
|
327
|
+
serverInfo: SERVER_INFO,
|
|
328
|
+
}
|
|
329
|
+
break
|
|
330
|
+
case 'ping':
|
|
331
|
+
result = {}
|
|
332
|
+
break
|
|
333
|
+
case 'tools/list':
|
|
334
|
+
result = { tools: TOOLS }
|
|
335
|
+
break
|
|
336
|
+
case 'tools/call': {
|
|
337
|
+
const value = await callTool(params?.name, params?.arguments)
|
|
338
|
+
result = { content: [{ type: 'text', text: JSON.stringify(value, null, 2) }] }
|
|
339
|
+
break
|
|
340
|
+
}
|
|
341
|
+
case 'resources/list': {
|
|
342
|
+
const paths = await listNotePaths()
|
|
343
|
+
result = {
|
|
344
|
+
resources: paths.map(p => {
|
|
345
|
+
const id = vaultRelativeId(p)
|
|
346
|
+
return {
|
|
347
|
+
uri: `note:///${id}`,
|
|
348
|
+
name: id,
|
|
349
|
+
mimeType: 'text/markdown',
|
|
350
|
+
}
|
|
351
|
+
}),
|
|
352
|
+
}
|
|
353
|
+
break
|
|
354
|
+
}
|
|
355
|
+
case 'resources/read': {
|
|
356
|
+
const uri = params?.uri ?? ''
|
|
357
|
+
const noteId = uri.replace(/^note:\/\/\/?/, '')
|
|
358
|
+
const text = await readFile(await containedPathReal(noteId), 'utf8')
|
|
359
|
+
result = { contents: [{ uri, mimeType: 'text/markdown', text }] }
|
|
360
|
+
break
|
|
361
|
+
}
|
|
362
|
+
default:
|
|
363
|
+
throw Object.assign(new Error(`method not found: ${method}`), { code: -32601 })
|
|
364
|
+
}
|
|
365
|
+
if (id !== undefined) send({ jsonrpc: '2.0', id, result })
|
|
366
|
+
} catch (error) {
|
|
367
|
+
if (id !== undefined) {
|
|
368
|
+
send({ jsonrpc: '2.0', id, error: { code: error.code ?? -32603, message: String(error.message ?? error) } })
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
})
|
|
372
|
+
})
|