@syndash/research-vault-mcp 0.2.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 +143 -0
- package/package.json +46 -0
- package/server.ts +913 -0
- package/tsconfig.json +13 -0
- package/types.ts +27 -0
package/README.md
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
# research-vault MCP server
|
|
2
|
+
|
|
3
|
+
MCP server exposing a local research-vault checkout to any MCP client over **stdio** or **Streamable HTTP**.
|
|
4
|
+
|
|
5
|
+
Wave 2D — see `docs/superpowers/plans/2026-04-19-wave2d-research-vault-mcp.md`.
|
|
6
|
+
|
|
7
|
+
## Architecture
|
|
8
|
+
|
|
9
|
+
MCP client → this server → local `VAULT_ROOT` checkout → optional BGE-M3-compatible embedding endpoint.
|
|
10
|
+
|
|
11
|
+
Phase 1: substring search over `.meta/registry.jsonl`, exact-ID read via `vault_get`, plus `vault_status` and `vault_taxonomy`. Phase 2: real BGE-M3 cosine over `.meta/embeddings.jsonl`.
|
|
12
|
+
|
|
13
|
+
## Tools (v0)
|
|
14
|
+
|
|
15
|
+
| Tool | Args | Returns |
|
|
16
|
+
|------------------|--------------------------------------------|--------------------------------------------------------|
|
|
17
|
+
| `vault_search` | `query` (str), `top_k?` (1-50), `mode?` | Ranked hits with separated readability/index/analysis verdicts |
|
|
18
|
+
| `vault_get` | `id` (exact str), `include_content?` | Authoritative exact-ID metadata/content read |
|
|
19
|
+
| `vault_status` | — | Registry counts, decay summary, last maintenance run |
|
|
20
|
+
| `vault_taxonomy` | — | `knowledge/_taxonomy.md` verbatim |
|
|
21
|
+
|
|
22
|
+
`mode` defaults to `substring`; `embedding` is wired in Phase 2.
|
|
23
|
+
|
|
24
|
+
`vault_search` reports `verdict`, `readability_verdict`, `index_verdict`, and `analysis_verdict` separately. A readable raw note that has no `lastAnalyzedAt` returns `verdict: "PASS"` with `analysis_verdict: "NOT_ANALYZED"`; that means content is readable but has not been analyzed yet.
|
|
25
|
+
|
|
26
|
+
## Search And Read Semantics
|
|
27
|
+
|
|
28
|
+
`vault_search` is an index/search surface. It answers "did this query match registry metadata, and is the matched content readable?"
|
|
29
|
+
|
|
30
|
+
Matching covers:
|
|
31
|
+
|
|
32
|
+
- exact entry IDs, with exact ID hits marked as `matched: ["id_exact"]`
|
|
33
|
+
- titles, categories, tags, and source URLs
|
|
34
|
+
- punctuation-normalized text, so `Music/Rhythm` can be found with `music rhythm`, and `software-engineering/game-design` can be found with `software engineering game design`
|
|
35
|
+
|
|
36
|
+
The top-level `verdict` is about search/read usability, not analysis freshness:
|
|
37
|
+
|
|
38
|
+
```json
|
|
39
|
+
{
|
|
40
|
+
"verdict": "PASS",
|
|
41
|
+
"readability_verdict": "PASS",
|
|
42
|
+
"index_verdict": "PASS",
|
|
43
|
+
"analysis_verdict": "NOT_ANALYZED"
|
|
44
|
+
}
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
That shape means the result matched and the file is readable. It does not mean the entry has been analyzed. Missing `lastAnalyzedAt` is reported as `NOT_ANALYZED`, not as missing or broken content.
|
|
48
|
+
|
|
49
|
+
`vault_get` is the authoritative exact-ID read path. It requires an exact `id`, reads `knowledgePath` first when present, and falls back to `rawPath` when the knowledge file is absent. Use `include_content: true` when the client needs the markdown body:
|
|
50
|
+
|
|
51
|
+
```json
|
|
52
|
+
{
|
|
53
|
+
"id": "20260608-readable-raw-note",
|
|
54
|
+
"include_content": true
|
|
55
|
+
}
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## Install From npm
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
npm install -g @syndash/research-vault-mcp
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
This package runs on Bun. Keep `VAULT_ROOT` pointed at your local vault checkout.
|
|
65
|
+
|
|
66
|
+
## Install From Source
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
cd mcp
|
|
70
|
+
bun install
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## Run — stdio
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
VAULT_ROOT=/path/to/research-vault research-vault-mcp
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
From a source checkout:
|
|
80
|
+
|
|
81
|
+
```bash
|
|
82
|
+
VAULT_ROOT=/path/to/research-vault bun run dev
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
## Run — HTTP
|
|
86
|
+
|
|
87
|
+
```bash
|
|
88
|
+
VAULT_ROOT=/path/to/research-vault MCP_MODE=http MCP_HOST=127.0.0.1 MCP_PORT=8765 research-vault-mcp
|
|
89
|
+
curl http://127.0.0.1:8765/health
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
Bind `MCP_HOST=0.0.0.0` only behind private network/auth controls.
|
|
93
|
+
|
|
94
|
+
## Inspect (verify handshake + tool listing)
|
|
95
|
+
|
|
96
|
+
```bash
|
|
97
|
+
bun run inspect
|
|
98
|
+
# = bunx @modelcontextprotocol/inspector bun run server.ts
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
Then in the Inspector UI: connect → list tools → call each one.
|
|
102
|
+
|
|
103
|
+
## Environment
|
|
104
|
+
|
|
105
|
+
| Var | Default | Notes |
|
|
106
|
+
|-------------------|----------------------------------|--------------------------------------------------------|
|
|
107
|
+
| `MCP_MODE` | `stdio` | `stdio` \| `http` |
|
|
108
|
+
| `MCP_HOST` | `127.0.0.1` | Use `0.0.0.0` only behind private access controls |
|
|
109
|
+
| `MCP_PORT` | `8765` | HTTP mode port |
|
|
110
|
+
| `VAULT_ROOT` | parent dir of `mcp/` | Absolute path to the vault checkout |
|
|
111
|
+
| `EMBED_ENDPOINT` | `http://127.0.0.1:8080` | Optional BGE-M3-compatible embedding endpoint |
|
|
112
|
+
|
|
113
|
+
## Client Configuration
|
|
114
|
+
|
|
115
|
+
Use your actual private MCP URL in your local client config:
|
|
116
|
+
|
|
117
|
+
```yaml
|
|
118
|
+
mcp_servers:
|
|
119
|
+
research_vault:
|
|
120
|
+
url: http://127.0.0.1:8765/mcp
|
|
121
|
+
timeout: 60
|
|
122
|
+
connect_timeout: 30
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
Tool names will appear as `mcp_research_vault_vault_search` etc.
|
|
126
|
+
|
|
127
|
+
## Publishing OPSEC
|
|
128
|
+
|
|
129
|
+
The npm package is intentionally allowlisted to runtime files only: `README.md`, `server.ts`, `types.ts`, and `tsconfig.json`.
|
|
130
|
+
|
|
131
|
+
Before publishing, run:
|
|
132
|
+
|
|
133
|
+
```bash
|
|
134
|
+
npm pack --dry-run
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
Do not publish vault content, `.meta`, private hostnames, private IPs, service-token material, local absolute paths, or operator-specific deployment notes.
|
|
138
|
+
|
|
139
|
+
## Non-goals (explicit)
|
|
140
|
+
|
|
141
|
+
- No public internet exposure by default
|
|
142
|
+
- No bundled vault content or registry data
|
|
143
|
+
- No multi-tenant / multi-vault — one server, one `VAULT_ROOT`
|
package/package.json
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@syndash/research-vault-mcp",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "MCP server exposing local research-vault search and exact-ID reads",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"research-vault-mcp": "server.ts"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"README.md",
|
|
11
|
+
"server.ts",
|
|
12
|
+
"types.ts",
|
|
13
|
+
"tsconfig.json"
|
|
14
|
+
],
|
|
15
|
+
"repository": {
|
|
16
|
+
"type": "git",
|
|
17
|
+
"url": "git+https://github.com/Fearvox/ds-research-vault.git",
|
|
18
|
+
"directory": "mcp"
|
|
19
|
+
},
|
|
20
|
+
"bugs": {
|
|
21
|
+
"url": "https://github.com/Fearvox/ds-research-vault/issues"
|
|
22
|
+
},
|
|
23
|
+
"homepage": "https://github.com/Fearvox/ds-research-vault/tree/main/mcp#readme",
|
|
24
|
+
"license": "UNLICENSED",
|
|
25
|
+
"engines": {
|
|
26
|
+
"bun": ">=1.3.0"
|
|
27
|
+
},
|
|
28
|
+
"scripts": {
|
|
29
|
+
"start": "bun ./server.ts",
|
|
30
|
+
"dev": "MCP_MODE=stdio bun run server.ts",
|
|
31
|
+
"http": "MCP_MODE=http MCP_HOST=127.0.0.1 MCP_PORT=8765 bun run server.ts",
|
|
32
|
+
"inspect": "bunx @modelcontextprotocol/inspector bun run server.ts",
|
|
33
|
+
"test": "bun test server.test.ts",
|
|
34
|
+
"typecheck": "bunx tsc --noEmit -p tsconfig.json",
|
|
35
|
+
"pack:dry": "npm pack --dry-run",
|
|
36
|
+
"prepublishOnly": "bun run typecheck && bun test server.test.ts && npm pack --dry-run"
|
|
37
|
+
},
|
|
38
|
+
"dependencies": {
|
|
39
|
+
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
40
|
+
"zod": "^3.23.8"
|
|
41
|
+
},
|
|
42
|
+
"devDependencies": {
|
|
43
|
+
"@types/bun": "latest",
|
|
44
|
+
"typescript": "^6.0.3"
|
|
45
|
+
}
|
|
46
|
+
}
|
package/server.ts
ADDED
|
@@ -0,0 +1,913 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* research-vault MCP server
|
|
4
|
+
*
|
|
5
|
+
* Exposes semantic + metadata access to the research-vault knowledge repo
|
|
6
|
+
* over MCP (stdio for local clients, Streamable HTTP for private deployments).
|
|
7
|
+
*
|
|
8
|
+
* v0 tools (Phase 1):
|
|
9
|
+
* - vault_search → substring match over registry.jsonl (titles, tags, category)
|
|
10
|
+
* Phase 2 upgrade: BGE-M3 query embedding + cosine over .meta/embeddings.jsonl
|
|
11
|
+
* - vault_status → registry counts + decay summary + last maintenance run
|
|
12
|
+
* - vault_taxonomy → returns knowledge/_taxonomy.md verbatim
|
|
13
|
+
*
|
|
14
|
+
* v1 write tools (DAS-808 — write upgrade):
|
|
15
|
+
* - vault_store → create a new vault entry (registry + knowledge file)
|
|
16
|
+
* - vault_write → update an existing vault entry's metadata or content
|
|
17
|
+
*
|
|
18
|
+
* Env:
|
|
19
|
+
* MCP_MODE stdio | http (default: stdio)
|
|
20
|
+
* MCP_HOST host for http mode (default: 127.0.0.1; use 0.0.0.0 only behind private access controls)
|
|
21
|
+
* MCP_PORT port for http mode (default: 8765)
|
|
22
|
+
* VAULT_ROOT absolute path to research-vault root
|
|
23
|
+
* (default: parent of this file — works when running `bun run server.ts` from mcp/)
|
|
24
|
+
* EMBED_ENDPOINT BGE-M3 OpenAI-compatible embeddings URL
|
|
25
|
+
* (default: http://127.0.0.1:8080)
|
|
26
|
+
* MCP_READONLY if "1" / "true", do not register vault_store / vault_write
|
|
27
|
+
* (default: off; set on remote-agent hosts that consume but must not mutate)
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
|
|
31
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
|
|
32
|
+
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'
|
|
33
|
+
import { z } from 'zod'
|
|
34
|
+
import { readFileSync, existsSync, writeFileSync, mkdirSync, appendFileSync, statSync } from 'node:fs'
|
|
35
|
+
import { join, resolve, dirname, basename } from 'node:path'
|
|
36
|
+
import { fileURLToPath } from 'node:url'
|
|
37
|
+
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http'
|
|
38
|
+
import { createHash, randomUUID } from 'node:crypto'
|
|
39
|
+
import type { RawItem, DecayScore } from './types.ts'
|
|
40
|
+
|
|
41
|
+
// --------------------------------------------------------------------------
|
|
42
|
+
// Paths + config
|
|
43
|
+
// --------------------------------------------------------------------------
|
|
44
|
+
|
|
45
|
+
const HERE = dirname(fileURLToPath(import.meta.url))
|
|
46
|
+
const VAULT_ROOT = resolve(process.env.VAULT_ROOT ?? join(HERE, '..'))
|
|
47
|
+
const META_DIR = join(VAULT_ROOT, '.meta')
|
|
48
|
+
const REGISTRY_PATH = join(META_DIR, 'registry.jsonl')
|
|
49
|
+
const DECAY_PATH = join(META_DIR, 'decay-scores.json')
|
|
50
|
+
const MAINT_PATH = join(META_DIR, 'last-maintenance.json')
|
|
51
|
+
const TAXONOMY_PATH = join(VAULT_ROOT, 'knowledge', '_taxonomy.md')
|
|
52
|
+
|
|
53
|
+
const MODE = (process.env.MCP_MODE ?? 'stdio').toLowerCase()
|
|
54
|
+
const HOST = process.env.MCP_HOST ?? '127.0.0.1'
|
|
55
|
+
const PORT = Number(process.env.MCP_PORT ?? 8765)
|
|
56
|
+
const EMBED_ENDPOINT = process.env.EMBED_ENDPOINT ?? 'http://127.0.0.1:8080'
|
|
57
|
+
const READONLY = process.env.MCP_READONLY === '1' || process.env.MCP_READONLY === 'true'
|
|
58
|
+
const PUBLIC_BASE_URL =
|
|
59
|
+
(process.env.MCP_PUBLIC_BASE_URL ?? process.env.PUBLIC_BASE_URL ?? `http://${HOST}:${PORT}`).replace(/\/+$/, '')
|
|
60
|
+
const MCP_ENDPOINT_URL = `${PUBLIC_BASE_URL}/mcp`
|
|
61
|
+
|
|
62
|
+
// --------------------------------------------------------------------------
|
|
63
|
+
// Vault readers (cheap — O(registry size), called per tool invocation; no cache)
|
|
64
|
+
// --------------------------------------------------------------------------
|
|
65
|
+
|
|
66
|
+
function readRegistry(): RawItem[] {
|
|
67
|
+
if (!existsSync(REGISTRY_PATH)) return []
|
|
68
|
+
const raw = readFileSync(REGISTRY_PATH, 'utf-8').trim()
|
|
69
|
+
if (!raw) return []
|
|
70
|
+
return raw.split('\n').map((line) => JSON.parse(line) as RawItem)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function readDecayScores(): DecayScore[] {
|
|
74
|
+
if (!existsSync(DECAY_PATH)) return []
|
|
75
|
+
return JSON.parse(readFileSync(DECAY_PATH, 'utf-8')) as DecayScore[]
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function readLastMaintenance(): { last_run: string | null; items_processed: number } {
|
|
79
|
+
if (!existsSync(MAINT_PATH)) return { last_run: null, items_processed: 0 }
|
|
80
|
+
return JSON.parse(readFileSync(MAINT_PATH, 'utf-8'))
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function readTaxonomy(): string {
|
|
84
|
+
if (!existsSync(TAXONOMY_PATH)) return '(taxonomy file missing)'
|
|
85
|
+
return readFileSync(TAXONOMY_PATH, 'utf-8')
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// --------------------------------------------------------------------------
|
|
89
|
+
// Vault writers (v1 — DAS-808 write upgrade)
|
|
90
|
+
// --------------------------------------------------------------------------
|
|
91
|
+
|
|
92
|
+
function slugify(text: string): string {
|
|
93
|
+
return text
|
|
94
|
+
.toLowerCase()
|
|
95
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
96
|
+
.replace(/^-+|-+$/g, '')
|
|
97
|
+
.slice(0, 80)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function generateEntryId(title: string): string {
|
|
101
|
+
const date = new Date().toISOString().slice(0, 10).replace(/-/g, '')
|
|
102
|
+
const slug = slugify(title)
|
|
103
|
+
return `${date}-${slug}`
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
interface WriteStoreResult {
|
|
107
|
+
entry: RawItem
|
|
108
|
+
knowledgePath: string
|
|
109
|
+
rawPath: string
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function writeStoreEntry(args: {
|
|
113
|
+
title: string
|
|
114
|
+
content: string
|
|
115
|
+
category?: string
|
|
116
|
+
tags?: string[]
|
|
117
|
+
sourceUrl?: string
|
|
118
|
+
}): WriteStoreResult {
|
|
119
|
+
const id = generateEntryId(args.title)
|
|
120
|
+
const now = new Date().toISOString()
|
|
121
|
+
const yearMonth = now.slice(0, 7) // YYYY-MM
|
|
122
|
+
|
|
123
|
+
// Determine paths
|
|
124
|
+
const catDir = args.category ? args.category.replace(/\/+/g, '/').replace(/^\/|\/$/g, '') : ''
|
|
125
|
+
const knowledgeDir = catDir ? join(VAULT_ROOT, 'knowledge', catDir) : join(VAULT_ROOT, 'knowledge')
|
|
126
|
+
const rawDir = join(VAULT_ROOT, 'raw', yearMonth)
|
|
127
|
+
const knowledgeFile = join(knowledgeDir, `${id}.md`)
|
|
128
|
+
const rawFile = join(rawDir, `${id}.md`)
|
|
129
|
+
|
|
130
|
+
// Create directories
|
|
131
|
+
mkdirSync(knowledgeDir, { recursive: true })
|
|
132
|
+
mkdirSync(rawDir, { recursive: true })
|
|
133
|
+
|
|
134
|
+
// Write content files
|
|
135
|
+
const header = `# ${args.title}\n\n`
|
|
136
|
+
const sourceLine = args.sourceUrl ? `> Source: ${args.sourceUrl}\n\n` : ''
|
|
137
|
+
const body = header + sourceLine + args.content
|
|
138
|
+
|
|
139
|
+
writeFileSync(knowledgeFile, body, 'utf-8')
|
|
140
|
+
writeFileSync(rawFile, body, 'utf-8')
|
|
141
|
+
|
|
142
|
+
// Build registry entry
|
|
143
|
+
const entry: RawItem = {
|
|
144
|
+
id,
|
|
145
|
+
title: args.title,
|
|
146
|
+
source: args.sourceUrl ? 'url' : 'local',
|
|
147
|
+
sourceUrl: args.sourceUrl,
|
|
148
|
+
rawPath: `raw/${yearMonth}/${id}.md`,
|
|
149
|
+
ingestedAt: now,
|
|
150
|
+
status: 'raw',
|
|
151
|
+
tags: args.tags ?? [],
|
|
152
|
+
category: args.category,
|
|
153
|
+
knowledgePath: catDir ? `knowledge/${catDir}/${id}.md` : `knowledge/${id}.md`,
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Append to registry
|
|
157
|
+
appendFileSync(REGISTRY_PATH, JSON.stringify(entry) + '\n', 'utf-8')
|
|
158
|
+
|
|
159
|
+
return { entry, knowledgePath: knowledgeFile, rawPath: rawFile }
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
interface WriteUpdateResult {
|
|
163
|
+
entry: RawItem
|
|
164
|
+
previous: RawItem
|
|
165
|
+
knowledgePath?: string
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function writeUpdateEntry(args: {
|
|
169
|
+
id: string
|
|
170
|
+
title?: string
|
|
171
|
+
content?: string
|
|
172
|
+
category?: string
|
|
173
|
+
tags?: string[]
|
|
174
|
+
status?: RawItem['status']
|
|
175
|
+
sourceUrl?: string
|
|
176
|
+
}): WriteUpdateResult {
|
|
177
|
+
const registry = readRegistry()
|
|
178
|
+
const idx = registry.findIndex((item) => item.id === args.id)
|
|
179
|
+
if (idx === -1) {
|
|
180
|
+
throw new Error(`Entry not found: ${args.id}`)
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const previous = { ...registry[idx], tags: [...registry[idx].tags] }
|
|
184
|
+
const updated = registry[idx]
|
|
185
|
+
|
|
186
|
+
// Update scalar fields
|
|
187
|
+
if (args.title !== undefined) updated.title = args.title
|
|
188
|
+
if (args.category !== undefined) updated.category = args.category
|
|
189
|
+
if (args.tags !== undefined) updated.tags = args.tags
|
|
190
|
+
if (args.status !== undefined) updated.status = args.status
|
|
191
|
+
if (args.sourceUrl !== undefined) {
|
|
192
|
+
updated.sourceUrl = args.sourceUrl
|
|
193
|
+
updated.source = 'url'
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Update content if provided
|
|
197
|
+
let knowledgePath: string | undefined
|
|
198
|
+
if (args.content !== undefined && updated.knowledgePath) {
|
|
199
|
+
const kp = join(VAULT_ROOT, updated.knowledgePath)
|
|
200
|
+
const header = `# ${updated.title}\n\n`
|
|
201
|
+
const sourceLine = updated.sourceUrl ? `> Source: ${updated.sourceUrl}\n\n` : ''
|
|
202
|
+
writeFileSync(kp, header + sourceLine + args.content, 'utf-8')
|
|
203
|
+
knowledgePath = kp
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Handle category change — move knowledge file
|
|
207
|
+
if (args.category !== undefined && args.category !== previous.category) {
|
|
208
|
+
const oldPath = previous.knowledgePath ? join(VAULT_ROOT, previous.knowledgePath) : null
|
|
209
|
+
const newCatDir = args.category.replace(/\/+/g, '/').replace(/^\/|\/$/g, '')
|
|
210
|
+
const newDir = join(VAULT_ROOT, 'knowledge', newCatDir)
|
|
211
|
+
const newPath = join(newDir, `${args.id}.md`)
|
|
212
|
+
const newRelPath = `knowledge/${newCatDir}/${args.id}.md`
|
|
213
|
+
|
|
214
|
+
if (oldPath && existsSync(oldPath)) {
|
|
215
|
+
mkdirSync(newDir, { recursive: true })
|
|
216
|
+
writeFileSync(newPath, readFileSync(oldPath, 'utf-8'), 'utf-8')
|
|
217
|
+
// Note: old file is left in place to avoid data loss; manual cleanup expected
|
|
218
|
+
}
|
|
219
|
+
updated.knowledgePath = newRelPath
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// Rewrite registry
|
|
223
|
+
registry[idx] = updated
|
|
224
|
+
writeFileSync(REGISTRY_PATH, registry.map((item) => JSON.stringify(item)).join('\n') + '\n', 'utf-8')
|
|
225
|
+
|
|
226
|
+
return { entry: updated, previous, knowledgePath }
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// --------------------------------------------------------------------------
|
|
230
|
+
// Search (v0: substring match; v1: BGE-M3 cosine)
|
|
231
|
+
// --------------------------------------------------------------------------
|
|
232
|
+
|
|
233
|
+
interface SearchHit {
|
|
234
|
+
id: string
|
|
235
|
+
title: string
|
|
236
|
+
category?: string
|
|
237
|
+
tags: string[]
|
|
238
|
+
status: RawItem['status']
|
|
239
|
+
knowledgePath?: string
|
|
240
|
+
rawPath?: string
|
|
241
|
+
score: number
|
|
242
|
+
matched: string[] // which fields matched
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
type GlobalVerdict = 'PASS' | 'FLAG'
|
|
246
|
+
type ReadabilityVerdict = 'PASS' | 'MISSING'
|
|
247
|
+
type IndexVerdict = 'PASS' | 'NO_MATCH'
|
|
248
|
+
type AnalysisVerdict = 'PASS' | 'NOT_ANALYZED'
|
|
249
|
+
|
|
250
|
+
interface ReadableContent {
|
|
251
|
+
readability_verdict: ReadabilityVerdict
|
|
252
|
+
read_path?: string
|
|
253
|
+
read_source?: 'knowledge' | 'raw'
|
|
254
|
+
source_mtime?: string
|
|
255
|
+
content_hash?: string
|
|
256
|
+
content?: string
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
interface SearchVerdicts {
|
|
260
|
+
verdict: GlobalVerdict
|
|
261
|
+
readability_verdict: ReadabilityVerdict
|
|
262
|
+
index_verdict: IndexVerdict
|
|
263
|
+
analysis_verdict: AnalysisVerdict
|
|
264
|
+
analysis_caveat?: string
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
interface DecoratedSearchHit extends SearchHit, SearchVerdicts {
|
|
268
|
+
sourceMtime?: string
|
|
269
|
+
contentHash?: string
|
|
270
|
+
lastIndexedAt?: string
|
|
271
|
+
lastAnalyzedAt?: string
|
|
272
|
+
analysisVersion?: string
|
|
273
|
+
read_path?: string
|
|
274
|
+
read_source?: 'knowledge' | 'raw'
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
interface SearchResponse extends SearchVerdicts {
|
|
278
|
+
query: string
|
|
279
|
+
backend: 'substring' | 'embedding'
|
|
280
|
+
count: number
|
|
281
|
+
hits: DecoratedSearchHit[]
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function normalizeSearchText(text: string): string {
|
|
285
|
+
return text
|
|
286
|
+
.toLowerCase()
|
|
287
|
+
.replace(/[^a-z0-9]+/g, ' ')
|
|
288
|
+
.replace(/\s+/g, ' ')
|
|
289
|
+
.trim()
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function matchesQuery(value: string | undefined, query: string): boolean {
|
|
293
|
+
if (!value) return false
|
|
294
|
+
const rawValue = value.toLowerCase()
|
|
295
|
+
const rawQuery = query.toLowerCase().trim()
|
|
296
|
+
if (rawValue.includes(rawQuery)) return true
|
|
297
|
+
const normalizedValue = normalizeSearchText(value)
|
|
298
|
+
const normalizedQuery = normalizeSearchText(query)
|
|
299
|
+
return normalizedQuery.length > 0 && normalizedValue.includes(normalizedQuery)
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
export function substringSearch(items: RawItem[], query: string, topK: number): SearchHit[] {
|
|
303
|
+
const q = query.toLowerCase().trim()
|
|
304
|
+
if (!q) return []
|
|
305
|
+
const hits: SearchHit[] = []
|
|
306
|
+
for (const item of items) {
|
|
307
|
+
const matched: string[] = []
|
|
308
|
+
let score = 0
|
|
309
|
+
if (item.id.toLowerCase() === q) { matched.push('id_exact'); score += 10 }
|
|
310
|
+
else if (matchesQuery(item.id, query)) { matched.push('id'); score += 4 }
|
|
311
|
+
if (matchesQuery(item.title, query)) { matched.push('title'); score += 3 }
|
|
312
|
+
if (matchesQuery(item.category, query)) { matched.push('category'); score += 2 }
|
|
313
|
+
if (item.tags.some((t) => matchesQuery(t, query))) { matched.push('tag'); score += 2 }
|
|
314
|
+
if (matchesQuery(item.sourceUrl, query)) { matched.push('source_url'); score += 1 }
|
|
315
|
+
if (score > 0) {
|
|
316
|
+
hits.push({
|
|
317
|
+
id: item.id,
|
|
318
|
+
title: item.title,
|
|
319
|
+
category: item.category,
|
|
320
|
+
tags: item.tags,
|
|
321
|
+
status: item.status,
|
|
322
|
+
knowledgePath: item.knowledgePath,
|
|
323
|
+
rawPath: item.rawPath,
|
|
324
|
+
score,
|
|
325
|
+
matched,
|
|
326
|
+
})
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
hits.sort((a, b) => b.score - a.score)
|
|
330
|
+
return hits.slice(0, topK)
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function candidateReadPaths(item: RawItem): Array<{ source: 'knowledge' | 'raw'; path: string }> {
|
|
334
|
+
const candidates: Array<{ source: 'knowledge' | 'raw'; path: string }> = []
|
|
335
|
+
if (item.knowledgePath) candidates.push({ source: 'knowledge', path: item.knowledgePath })
|
|
336
|
+
candidates.push({ source: 'raw', path: item.rawPath })
|
|
337
|
+
return candidates
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
export function resolveReadableContent(item: RawItem, vaultRoot = VAULT_ROOT, includeContent = false): ReadableContent {
|
|
341
|
+
for (const candidate of candidateReadPaths(item)) {
|
|
342
|
+
const absPath = join(vaultRoot, candidate.path)
|
|
343
|
+
if (!existsSync(absPath)) continue
|
|
344
|
+
|
|
345
|
+
const content = readFileSync(absPath, 'utf-8')
|
|
346
|
+
const stat = statSync(absPath)
|
|
347
|
+
return {
|
|
348
|
+
readability_verdict: 'PASS',
|
|
349
|
+
read_path: candidate.path,
|
|
350
|
+
read_source: candidate.source,
|
|
351
|
+
source_mtime: stat.mtime.toISOString(),
|
|
352
|
+
content_hash: createHash('sha256').update(content).digest('hex'),
|
|
353
|
+
...(includeContent ? { content } : {}),
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
return { readability_verdict: 'MISSING' }
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
function analysisVerdict(item: RawItem): Pick<SearchVerdicts, 'analysis_verdict' | 'analysis_caveat'> {
|
|
361
|
+
if (item.lastAnalyzedAt) return { analysis_verdict: 'PASS' }
|
|
362
|
+
return {
|
|
363
|
+
analysis_verdict: 'NOT_ANALYZED',
|
|
364
|
+
analysis_caveat: 'Readable content exists, but registry has no lastAnalyzedAt. Treat as not analyzed, not missing.',
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
function itemVerdicts(item: RawItem, vaultRoot = VAULT_ROOT): SearchVerdicts & ReadableContent {
|
|
369
|
+
const readable = resolveReadableContent(item, vaultRoot, false)
|
|
370
|
+
const analysis = analysisVerdict(item)
|
|
371
|
+
return {
|
|
372
|
+
verdict: readable.readability_verdict === 'PASS' ? 'PASS' : 'FLAG',
|
|
373
|
+
readability_verdict: readable.readability_verdict,
|
|
374
|
+
index_verdict: 'PASS',
|
|
375
|
+
...analysis,
|
|
376
|
+
read_path: readable.read_path,
|
|
377
|
+
read_source: readable.read_source,
|
|
378
|
+
source_mtime: item.sourceMtime ?? readable.source_mtime,
|
|
379
|
+
content_hash: item.contentHash ?? readable.content_hash,
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
function decorateSearchHit(hit: SearchHit, item: RawItem, vaultRoot = VAULT_ROOT): DecoratedSearchHit {
|
|
384
|
+
const verdicts = itemVerdicts(item, vaultRoot)
|
|
385
|
+
return {
|
|
386
|
+
...hit,
|
|
387
|
+
...verdicts,
|
|
388
|
+
sourceMtime: item.sourceMtime ?? verdicts.source_mtime,
|
|
389
|
+
contentHash: item.contentHash ?? verdicts.content_hash,
|
|
390
|
+
lastIndexedAt: item.lastIndexedAt,
|
|
391
|
+
lastAnalyzedAt: item.lastAnalyzedAt,
|
|
392
|
+
analysisVersion: item.analysisVersion,
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
export function buildSearchResponse(
|
|
397
|
+
query: string,
|
|
398
|
+
backend: 'substring' | 'embedding',
|
|
399
|
+
hits: SearchHit[],
|
|
400
|
+
items: RawItem[],
|
|
401
|
+
vaultRoot = VAULT_ROOT
|
|
402
|
+
): SearchResponse {
|
|
403
|
+
const byId = new Map(items.map((item) => [item.id, item]))
|
|
404
|
+
const decorated = hits.map((hit) => {
|
|
405
|
+
const item = byId.get(hit.id)
|
|
406
|
+
if (!item) {
|
|
407
|
+
return {
|
|
408
|
+
...hit,
|
|
409
|
+
verdict: 'FLAG' as const,
|
|
410
|
+
readability_verdict: 'MISSING' as const,
|
|
411
|
+
index_verdict: 'PASS' as const,
|
|
412
|
+
analysis_verdict: 'NOT_ANALYZED' as const,
|
|
413
|
+
analysis_caveat: 'Search hit has no matching registry item.',
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
return decorateSearchHit(hit, item, vaultRoot)
|
|
417
|
+
})
|
|
418
|
+
|
|
419
|
+
const hasHits = decorated.length > 0
|
|
420
|
+
const readable = hasHits && decorated.every((hit) => hit.readability_verdict === 'PASS')
|
|
421
|
+
const allAnalyzed = hasHits && decorated.every((hit) => hit.analysis_verdict === 'PASS')
|
|
422
|
+
return {
|
|
423
|
+
query,
|
|
424
|
+
backend,
|
|
425
|
+
count: decorated.length,
|
|
426
|
+
verdict: hasHits && readable ? 'PASS' : 'FLAG',
|
|
427
|
+
readability_verdict: readable ? 'PASS' : 'MISSING',
|
|
428
|
+
index_verdict: hasHits ? 'PASS' : 'NO_MATCH',
|
|
429
|
+
analysis_verdict: allAnalyzed ? 'PASS' : 'NOT_ANALYZED',
|
|
430
|
+
...(allAnalyzed ? {} : { analysis_caveat: 'One or more readable hits have no lastAnalyzedAt.' }),
|
|
431
|
+
hits: decorated,
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
export function getVaultItem(
|
|
436
|
+
registry: RawItem[],
|
|
437
|
+
id: string,
|
|
438
|
+
vaultRoot = VAULT_ROOT,
|
|
439
|
+
includeContent = false
|
|
440
|
+
) {
|
|
441
|
+
const item = registry.find((entry) => entry.id === id)
|
|
442
|
+
if (!item) {
|
|
443
|
+
return {
|
|
444
|
+
found: false as const,
|
|
445
|
+
id,
|
|
446
|
+
verdict: 'FLAG' as const,
|
|
447
|
+
readability_verdict: 'MISSING' as const,
|
|
448
|
+
index_verdict: 'NO_MATCH' as const,
|
|
449
|
+
analysis_verdict: 'NOT_ANALYZED' as const,
|
|
450
|
+
error: `Entry not found: ${id}`,
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
const readable = resolveReadableContent(item, vaultRoot, includeContent)
|
|
455
|
+
const analysis = analysisVerdict(item)
|
|
456
|
+
return {
|
|
457
|
+
found: true as const,
|
|
458
|
+
item: {
|
|
459
|
+
...item,
|
|
460
|
+
sourceMtime: item.sourceMtime ?? readable.source_mtime,
|
|
461
|
+
contentHash: item.contentHash ?? readable.content_hash,
|
|
462
|
+
read_path: readable.read_path,
|
|
463
|
+
read_source: readable.read_source,
|
|
464
|
+
},
|
|
465
|
+
verdict: readable.readability_verdict === 'PASS' ? 'PASS' as const : 'FLAG' as const,
|
|
466
|
+
readability_verdict: readable.readability_verdict,
|
|
467
|
+
index_verdict: 'PASS' as const,
|
|
468
|
+
...analysis,
|
|
469
|
+
...(includeContent ? { content: readable.content ?? null } : {}),
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
// Phase 2 hook — embed query via BGE-M3-compatible endpoint, cosine against .meta/embeddings.jsonl
|
|
474
|
+
// Left as a TODO stub; substringSearch ships tonight so Hermes can talk to the vault.
|
|
475
|
+
async function embeddingSearch(_query: string, _topK: number): Promise<SearchHit[]> {
|
|
476
|
+
throw new Error(
|
|
477
|
+
'embeddingSearch not yet wired — Phase 2. ' +
|
|
478
|
+
`Will call ${EMBED_ENDPOINT}/v1/embeddings and cosine against .meta/embeddings.jsonl.`
|
|
479
|
+
)
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
// --------------------------------------------------------------------------
|
|
483
|
+
// MCP server + tools
|
|
484
|
+
// --------------------------------------------------------------------------
|
|
485
|
+
|
|
486
|
+
function buildServer(): McpServer {
|
|
487
|
+
const server = new McpServer(
|
|
488
|
+
{ name: 'research-vault', version: '0.2.0' },
|
|
489
|
+
{ capabilities: { tools: {} } }
|
|
490
|
+
)
|
|
491
|
+
|
|
492
|
+
server.registerTool(
|
|
493
|
+
'vault_search',
|
|
494
|
+
{
|
|
495
|
+
title: 'Search research vault',
|
|
496
|
+
description:
|
|
497
|
+
'Search the research-vault knowledge base. v0 uses substring match over registry (title/category/tags/source URL). ' +
|
|
498
|
+
'Phase 2 will swap in BGE-M3 semantic search via the configured embedding endpoint.',
|
|
499
|
+
inputSchema: {
|
|
500
|
+
query: z.string().min(1).describe('Search query — keyword, phrase, tag, or URL fragment'),
|
|
501
|
+
top_k: z.number().int().min(1).max(50).optional().describe('Max results (default 10)'),
|
|
502
|
+
mode: z.enum(['substring', 'embedding']).optional().describe('Search backend (default substring; embedding = Phase 2)'),
|
|
503
|
+
},
|
|
504
|
+
},
|
|
505
|
+
async ({ query, top_k, mode }) => {
|
|
506
|
+
const k = top_k ?? 10
|
|
507
|
+
const backend = mode ?? 'substring'
|
|
508
|
+
const registry = readRegistry()
|
|
509
|
+
let hits: SearchHit[]
|
|
510
|
+
if (backend === 'embedding') {
|
|
511
|
+
hits = await embeddingSearch(query, k)
|
|
512
|
+
} else {
|
|
513
|
+
hits = substringSearch(registry, query, k)
|
|
514
|
+
}
|
|
515
|
+
const response = buildSearchResponse(query, backend, hits, registry)
|
|
516
|
+
return {
|
|
517
|
+
content: [
|
|
518
|
+
{
|
|
519
|
+
type: 'text',
|
|
520
|
+
text: JSON.stringify(
|
|
521
|
+
response,
|
|
522
|
+
null,
|
|
523
|
+
2
|
|
524
|
+
),
|
|
525
|
+
},
|
|
526
|
+
],
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
)
|
|
530
|
+
|
|
531
|
+
server.registerTool(
|
|
532
|
+
'vault_get',
|
|
533
|
+
{
|
|
534
|
+
title: 'Get research vault entry by exact ID',
|
|
535
|
+
description:
|
|
536
|
+
'Authoritative exact-ID read path for a research-vault entry. ' +
|
|
537
|
+
'Reads the knowledge file when present, otherwise falls back to the raw file. ' +
|
|
538
|
+
'Missing lastAnalyzedAt is reported as NOT_ANALYZED, not missing content.',
|
|
539
|
+
inputSchema: {
|
|
540
|
+
id: z.string().min(1).describe('Exact registry entry ID'),
|
|
541
|
+
include_content: z.boolean().optional().describe('Include markdown content in the response (default false)'),
|
|
542
|
+
},
|
|
543
|
+
},
|
|
544
|
+
async ({ id, include_content }) => {
|
|
545
|
+
const result = getVaultItem(readRegistry(), id, VAULT_ROOT, include_content ?? false)
|
|
546
|
+
return {
|
|
547
|
+
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
|
|
548
|
+
...(result.found ? {} : { isError: true }),
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
)
|
|
552
|
+
|
|
553
|
+
server.registerTool(
|
|
554
|
+
'vault_status',
|
|
555
|
+
{
|
|
556
|
+
title: 'Vault status',
|
|
557
|
+
description:
|
|
558
|
+
'Registry counts + decay summary + last maintenance run for the research vault. ' +
|
|
559
|
+
'Use to check vault health before ingest/analyze workflows.',
|
|
560
|
+
inputSchema: {},
|
|
561
|
+
},
|
|
562
|
+
async () => {
|
|
563
|
+
const reg = readRegistry()
|
|
564
|
+
const decay = readDecayScores()
|
|
565
|
+
const maint = readLastMaintenance()
|
|
566
|
+
const byStatus: Record<string, number> = {}
|
|
567
|
+
const byCategory: Record<string, number> = {}
|
|
568
|
+
for (const item of reg) {
|
|
569
|
+
byStatus[item.status] = (byStatus[item.status] ?? 0) + 1
|
|
570
|
+
if (item.category) byCategory[item.category] = (byCategory[item.category] ?? 0) + 1
|
|
571
|
+
}
|
|
572
|
+
const bySummary: Record<string, number> = { deep: 0, shallow: 0, none: 0 }
|
|
573
|
+
for (const d of decay) bySummary[d.summaryLevel]++
|
|
574
|
+
const status = {
|
|
575
|
+
vault_root: VAULT_ROOT,
|
|
576
|
+
registry: { total: reg.length, by_status: byStatus, by_category: byCategory },
|
|
577
|
+
decay: { tracked: decay.length, by_summary_level: bySummary },
|
|
578
|
+
last_maintenance: maint,
|
|
579
|
+
}
|
|
580
|
+
return { content: [{ type: 'text', text: JSON.stringify(status, null, 2) }] }
|
|
581
|
+
}
|
|
582
|
+
)
|
|
583
|
+
|
|
584
|
+
server.registerTool(
|
|
585
|
+
'vault_taxonomy',
|
|
586
|
+
{
|
|
587
|
+
title: 'Vault taxonomy',
|
|
588
|
+
description: 'Returns the canonical category taxonomy (knowledge/_taxonomy.md). Use before /analyze to pick the right category.',
|
|
589
|
+
inputSchema: {},
|
|
590
|
+
},
|
|
591
|
+
async () => {
|
|
592
|
+
return { content: [{ type: 'text', text: readTaxonomy() }] }
|
|
593
|
+
}
|
|
594
|
+
)
|
|
595
|
+
|
|
596
|
+
// --- v1 write tools (DAS-808 write upgrade) ---
|
|
597
|
+
// Skipped entirely when MCP_READONLY=1 — remote-agent hosts get a strictly
|
|
598
|
+
// read-only surface; write attempts return "tool not found" instead of touching disk.
|
|
599
|
+
if (!READONLY) {
|
|
600
|
+
server.registerTool(
|
|
601
|
+
'vault_store',
|
|
602
|
+
{
|
|
603
|
+
title: 'Store new entry in research vault',
|
|
604
|
+
description:
|
|
605
|
+
'Create a new entry in the research vault with title, content, and optional category/tags/source URL. ' +
|
|
606
|
+
'Appends to registry.jsonl and creates knowledge + raw files. ' +
|
|
607
|
+
'Requires explicit write approval (DAS-808). ' +
|
|
608
|
+
'Do not store secrets, OAuth material, tokens, raw payloads, or private infrastructure details.',
|
|
609
|
+
inputSchema: {
|
|
610
|
+
title: z.string().min(1).max(500).describe('Entry title (used to generate the entry ID)'),
|
|
611
|
+
content: z.string().min(1).describe('Markdown content body — must be sanitized (no secrets, tokens, or private payloads)'),
|
|
612
|
+
category: z.string().optional().describe('Taxonomy category path (e.g. software-engineering/security). Check vault_taxonomy first.'),
|
|
613
|
+
tags: z.array(z.string()).max(20).optional().describe('Tags for search indexing (max 20)'),
|
|
614
|
+
source_url: z.string().url().optional().describe('Source URL if this entry references an external resource'),
|
|
615
|
+
},
|
|
616
|
+
},
|
|
617
|
+
async ({ title, content, category, tags, source_url }) => {
|
|
618
|
+
try {
|
|
619
|
+
const result = writeStoreEntry({ title, content, category, tags, sourceUrl: source_url })
|
|
620
|
+
return {
|
|
621
|
+
content: [
|
|
622
|
+
{
|
|
623
|
+
type: 'text',
|
|
624
|
+
text: JSON.stringify(
|
|
625
|
+
{
|
|
626
|
+
stored: true,
|
|
627
|
+
entry: result.entry,
|
|
628
|
+
paths: { knowledge: result.knowledgePath, raw: result.rawPath },
|
|
629
|
+
},
|
|
630
|
+
null,
|
|
631
|
+
2
|
|
632
|
+
),
|
|
633
|
+
},
|
|
634
|
+
],
|
|
635
|
+
}
|
|
636
|
+
} catch (err) {
|
|
637
|
+
return {
|
|
638
|
+
content: [{ type: 'text', text: JSON.stringify({ stored: false, error: String(err) }, null, 2) }],
|
|
639
|
+
isError: true,
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
)
|
|
644
|
+
|
|
645
|
+
server.registerTool(
|
|
646
|
+
'vault_write',
|
|
647
|
+
{
|
|
648
|
+
title: 'Update existing vault entry',
|
|
649
|
+
description:
|
|
650
|
+
'Update metadata or content of an existing research vault entry identified by its id. ' +
|
|
651
|
+
'Rewrites the registry and optionally updates the knowledge file. ' +
|
|
652
|
+
'Requires explicit write approval (DAS-808). ' +
|
|
653
|
+
'Do not store secrets, OAuth material, tokens, raw payloads, or private infrastructure details.',
|
|
654
|
+
inputSchema: {
|
|
655
|
+
id: z.string().min(1).describe('Entry ID to update (e.g. 20260503-my-entry-slug)'),
|
|
656
|
+
title: z.string().min(1).max(500).optional().describe('New title'),
|
|
657
|
+
content: z.string().min(1).optional().describe('New markdown content — must be sanitized'),
|
|
658
|
+
category: z.string().optional().describe('New taxonomy category path'),
|
|
659
|
+
tags: z.array(z.string()).max(20).optional().describe('New tags (replaces existing tags)'),
|
|
660
|
+
status: z.enum(['raw', 'analyzed', 'archived']).optional().describe('New entry status'),
|
|
661
|
+
source_url: z.string().url().optional().describe('New source URL'),
|
|
662
|
+
},
|
|
663
|
+
},
|
|
664
|
+
async ({ id, title, content, category, tags, status, source_url }) => {
|
|
665
|
+
try {
|
|
666
|
+
const result = writeUpdateEntry({ id, title, content, category, tags, status, sourceUrl: source_url })
|
|
667
|
+
return {
|
|
668
|
+
content: [
|
|
669
|
+
{
|
|
670
|
+
type: 'text',
|
|
671
|
+
text: JSON.stringify(
|
|
672
|
+
{
|
|
673
|
+
updated: true,
|
|
674
|
+
entry: result.entry,
|
|
675
|
+
previous: { title: result.previous.title, category: result.previous.category, status: result.previous.status, tags: result.previous.tags },
|
|
676
|
+
knowledgePath: result.knowledgePath,
|
|
677
|
+
},
|
|
678
|
+
null,
|
|
679
|
+
2
|
|
680
|
+
),
|
|
681
|
+
},
|
|
682
|
+
],
|
|
683
|
+
}
|
|
684
|
+
} catch (err) {
|
|
685
|
+
return {
|
|
686
|
+
content: [{ type: 'text', text: JSON.stringify({ updated: false, error: String(err) }, null, 2) }],
|
|
687
|
+
isError: true,
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
)
|
|
692
|
+
} // end if (!READONLY)
|
|
693
|
+
|
|
694
|
+
return server
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
// --------------------------------------------------------------------------
|
|
698
|
+
// Transports
|
|
699
|
+
// --------------------------------------------------------------------------
|
|
700
|
+
|
|
701
|
+
async function runStdio() {
|
|
702
|
+
const server = buildServer()
|
|
703
|
+
const transport = new StdioServerTransport()
|
|
704
|
+
await server.connect(transport)
|
|
705
|
+
// stdio holds the process open via the transport's stdin reader.
|
|
706
|
+
process.stderr.write(`[research-vault-mcp] stdio ready — vault=${VAULT_ROOT}\n`)
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
function writeJson(res: ServerResponse, status: number, body: unknown) {
|
|
710
|
+
res.writeHead(status, {
|
|
711
|
+
'content-type': 'application/json',
|
|
712
|
+
'cache-control': 'no-store',
|
|
713
|
+
})
|
|
714
|
+
res.end(JSON.stringify(body))
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
function writeOAuthMetadata(res: ServerResponse) {
|
|
718
|
+
writeJson(res, 200, {
|
|
719
|
+
issuer: PUBLIC_BASE_URL,
|
|
720
|
+
authorization_endpoint: `${PUBLIC_BASE_URL}/oauth/authorize`,
|
|
721
|
+
token_endpoint: `${PUBLIC_BASE_URL}/oauth/token`,
|
|
722
|
+
registration_endpoint: `${PUBLIC_BASE_URL}/oauth/register`,
|
|
723
|
+
response_types_supported: ['code'],
|
|
724
|
+
grant_types_supported: ['authorization_code', 'refresh_token', 'client_credentials'],
|
|
725
|
+
token_endpoint_auth_methods_supported: ['none', 'client_secret_post', 'client_secret_basic'],
|
|
726
|
+
code_challenge_methods_supported: ['S256', 'plain'],
|
|
727
|
+
scopes_supported: ['vault.read', 'vault.write'],
|
|
728
|
+
})
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
function writeProtectedResourceMetadata(res: ServerResponse) {
|
|
732
|
+
writeJson(res, 200, {
|
|
733
|
+
resource: MCP_ENDPOINT_URL,
|
|
734
|
+
authorization_servers: [PUBLIC_BASE_URL],
|
|
735
|
+
bearer_methods_supported: ['header'],
|
|
736
|
+
scopes_supported: ['vault.read', 'vault.write'],
|
|
737
|
+
})
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
async function readRequestBody(req: IncomingMessage): Promise<string> {
|
|
741
|
+
const chunks: Buffer[] = []
|
|
742
|
+
for await (const chunk of req) {
|
|
743
|
+
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk))
|
|
744
|
+
}
|
|
745
|
+
return Buffer.concat(chunks).toString('utf8')
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
async function readRequestParams(req: IncomingMessage): Promise<Record<string, string>> {
|
|
749
|
+
const raw = await readRequestBody(req)
|
|
750
|
+
if (!raw.trim()) return {}
|
|
751
|
+
const contentType = req.headers['content-type'] ?? ''
|
|
752
|
+
if (contentType.includes('application/json')) {
|
|
753
|
+
const parsed = JSON.parse(raw) as Record<string, unknown>
|
|
754
|
+
return Object.fromEntries(
|
|
755
|
+
Object.entries(parsed).map(([key, value]) => [key, typeof value === 'string' ? value : String(value)])
|
|
756
|
+
)
|
|
757
|
+
}
|
|
758
|
+
return Object.fromEntries(new URLSearchParams(raw).entries())
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
function handleOAuthAuthorize(req: IncomingMessage, res: ServerResponse) {
|
|
762
|
+
const url = new URL(req.url ?? '/', PUBLIC_BASE_URL)
|
|
763
|
+
const redirectUri = url.searchParams.get('redirect_uri')
|
|
764
|
+
if (!redirectUri) {
|
|
765
|
+
writeJson(res, 400, { error: 'invalid_request', error_description: 'redirect_uri is required' })
|
|
766
|
+
return
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
const redirect = new URL(redirectUri)
|
|
770
|
+
redirect.searchParams.set('code', `rv-${randomUUID()}`)
|
|
771
|
+
const state = url.searchParams.get('state')
|
|
772
|
+
if (state) redirect.searchParams.set('state', state)
|
|
773
|
+
res.writeHead(302, {
|
|
774
|
+
location: redirect.toString(),
|
|
775
|
+
'cache-control': 'no-store',
|
|
776
|
+
})
|
|
777
|
+
res.end()
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
async function handleOAuthToken(req: IncomingMessage, res: ServerResponse) {
|
|
781
|
+
let params: Record<string, string>
|
|
782
|
+
try {
|
|
783
|
+
params = await readRequestParams(req)
|
|
784
|
+
} catch {
|
|
785
|
+
writeJson(res, 400, { error: 'invalid_request' })
|
|
786
|
+
return
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
const grantType = params.grant_type ?? 'authorization_code'
|
|
790
|
+
if (!['authorization_code', 'refresh_token', 'client_credentials'].includes(grantType)) {
|
|
791
|
+
writeJson(res, 400, { error: 'unsupported_grant_type' })
|
|
792
|
+
return
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
writeJson(res, 200, {
|
|
796
|
+
access_token: `rv-${randomUUID()}`,
|
|
797
|
+
token_type: 'Bearer',
|
|
798
|
+
expires_in: 3600,
|
|
799
|
+
scope: params.scope ?? 'vault.read vault.write',
|
|
800
|
+
})
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
async function handleOAuthRegister(req: IncomingMessage, res: ServerResponse) {
|
|
804
|
+
// Dynamic registration compatibility: deployments should enforce real
|
|
805
|
+
// authorization at their private access layer.
|
|
806
|
+
try {
|
|
807
|
+
await readRequestBody(req)
|
|
808
|
+
} catch {
|
|
809
|
+
// Ignore malformed registration bodies; this endpoint is compatibility-only.
|
|
810
|
+
}
|
|
811
|
+
writeJson(res, 201, {
|
|
812
|
+
client_id: `rv-client-${randomUUID()}`,
|
|
813
|
+
client_id_issued_at: Math.floor(Date.now() / 1000),
|
|
814
|
+
token_endpoint_auth_method: 'none',
|
|
815
|
+
grant_types: ['authorization_code', 'refresh_token', 'client_credentials'],
|
|
816
|
+
response_types: ['code'],
|
|
817
|
+
redirect_uris: [],
|
|
818
|
+
scope: 'vault.read vault.write',
|
|
819
|
+
})
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
async function handleMcpRequest(req: IncomingMessage, res: ServerResponse) {
|
|
823
|
+
// Stateless per-request transport keeps Capy refresh/probe/idempotent retries
|
|
824
|
+
// from poisoning a singleton transport session.
|
|
825
|
+
const server = buildServer()
|
|
826
|
+
const transport = new StreamableHTTPServerTransport({
|
|
827
|
+
sessionIdGenerator: undefined,
|
|
828
|
+
})
|
|
829
|
+
await server.connect(transport)
|
|
830
|
+
res.on('close', () => {
|
|
831
|
+
void transport.close().catch(() => undefined)
|
|
832
|
+
void server.close().catch(() => undefined)
|
|
833
|
+
})
|
|
834
|
+
await transport.handleRequest(req, res)
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
async function runHttp() {
|
|
838
|
+
const http = createServer(async (req, res) => {
|
|
839
|
+
try {
|
|
840
|
+
const url = new URL(req.url ?? '/', PUBLIC_BASE_URL)
|
|
841
|
+
// Health probe — matches embedding endpoint's /v1/models convention.
|
|
842
|
+
if (req.method === 'GET' && url.pathname === '/health') {
|
|
843
|
+
writeJson(res, 200, { status: 'ok', server: 'research-vault-mcp', version: '0.2.0', vault: VAULT_ROOT })
|
|
844
|
+
return
|
|
845
|
+
}
|
|
846
|
+
if (
|
|
847
|
+
req.method === 'GET' &&
|
|
848
|
+
(url.pathname === '/.well-known/oauth-protected-resource' ||
|
|
849
|
+
url.pathname === '/.well-known/oauth-protected-resource/mcp')
|
|
850
|
+
) {
|
|
851
|
+
writeProtectedResourceMetadata(res)
|
|
852
|
+
return
|
|
853
|
+
}
|
|
854
|
+
if (
|
|
855
|
+
req.method === 'GET' &&
|
|
856
|
+
(url.pathname === '/.well-known/oauth-authorization-server' ||
|
|
857
|
+
url.pathname === '/.well-known/oauth-authorization-server/mcp')
|
|
858
|
+
) {
|
|
859
|
+
writeOAuthMetadata(res)
|
|
860
|
+
return
|
|
861
|
+
}
|
|
862
|
+
if (req.method === 'GET' && url.pathname === '/oauth/authorize') {
|
|
863
|
+
handleOAuthAuthorize(req, res)
|
|
864
|
+
return
|
|
865
|
+
}
|
|
866
|
+
if (req.method === 'POST' && url.pathname === '/oauth/token') {
|
|
867
|
+
await handleOAuthToken(req, res)
|
|
868
|
+
return
|
|
869
|
+
}
|
|
870
|
+
if (req.method === 'POST' && url.pathname === '/oauth/register') {
|
|
871
|
+
await handleOAuthRegister(req, res)
|
|
872
|
+
return
|
|
873
|
+
}
|
|
874
|
+
// MCP Streamable HTTP endpoint (default path /mcp; accept / too for convenience).
|
|
875
|
+
if (url.pathname === '/mcp' || url.pathname === '/') {
|
|
876
|
+
await handleMcpRequest(req, res)
|
|
877
|
+
return
|
|
878
|
+
}
|
|
879
|
+
res.writeHead(404, { 'content-type': 'text/plain' })
|
|
880
|
+
res.end('not found')
|
|
881
|
+
} catch (err) {
|
|
882
|
+
process.stderr.write(`[research-vault-mcp] http error: ${String(err)}\n`)
|
|
883
|
+
if (!res.headersSent) {
|
|
884
|
+
res.writeHead(500, { 'content-type': 'text/plain' })
|
|
885
|
+
res.end('internal error')
|
|
886
|
+
}
|
|
887
|
+
}
|
|
888
|
+
})
|
|
889
|
+
|
|
890
|
+
http.listen(PORT, HOST, () => {
|
|
891
|
+
process.stderr.write(`[research-vault-mcp] http ready — http://${HOST}:${PORT}/mcp vault=${VAULT_ROOT}\n`)
|
|
892
|
+
})
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
// --------------------------------------------------------------------------
|
|
896
|
+
// Entry
|
|
897
|
+
// --------------------------------------------------------------------------
|
|
898
|
+
|
|
899
|
+
const isMain = process.argv[1] ? import.meta.url === new URL(process.argv[1], 'file:').href : false
|
|
900
|
+
|
|
901
|
+
if (isMain) {
|
|
902
|
+
if (MODE === 'http') {
|
|
903
|
+
runHttp().catch((err) => {
|
|
904
|
+
process.stderr.write(`[research-vault-mcp] fatal: ${String(err)}\n`)
|
|
905
|
+
process.exit(1)
|
|
906
|
+
})
|
|
907
|
+
} else {
|
|
908
|
+
runStdio().catch((err) => {
|
|
909
|
+
process.stderr.write(`[research-vault-mcp] fatal: ${String(err)}\n`)
|
|
910
|
+
process.exit(1)
|
|
911
|
+
})
|
|
912
|
+
}
|
|
913
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ESNext",
|
|
4
|
+
"module": "ESNext",
|
|
5
|
+
"moduleResolution": "bundler",
|
|
6
|
+
"strict": true,
|
|
7
|
+
"esModuleInterop": true,
|
|
8
|
+
"allowImportingTsExtensions": true,
|
|
9
|
+
"noEmit": true,
|
|
10
|
+
"types": ["bun-types"]
|
|
11
|
+
},
|
|
12
|
+
"include": ["**/*.ts"]
|
|
13
|
+
}
|
package/types.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
export interface RawItem {
|
|
2
|
+
id: string
|
|
3
|
+
title: string
|
|
4
|
+
source: 'url' | 'local'
|
|
5
|
+
sourceUrl?: string
|
|
6
|
+
rawPath: string
|
|
7
|
+
ingestedAt: string
|
|
8
|
+
status: 'raw' | 'analyzed' | 'archived'
|
|
9
|
+
tags: string[]
|
|
10
|
+
category?: string
|
|
11
|
+
knowledgePath?: string
|
|
12
|
+
sourceMtime?: string
|
|
13
|
+
contentHash?: string
|
|
14
|
+
lastIndexedAt?: string
|
|
15
|
+
lastAnalyzedAt?: string
|
|
16
|
+
analysisVersion?: string
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface DecayScore {
|
|
20
|
+
itemId: string
|
|
21
|
+
score: number
|
|
22
|
+
lastAccess: string
|
|
23
|
+
accessCount: number
|
|
24
|
+
summaryLevel: 'deep' | 'shallow' | 'none'
|
|
25
|
+
nextReviewAt: string
|
|
26
|
+
difficulty: number
|
|
27
|
+
}
|