@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 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
+ }
@@ -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
+ })