@syndash/research-vault-mcp 1.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/src/server.ts ADDED
@@ -0,0 +1,301 @@
1
+ // Research Vault MCP Server — Standard MCP SSE Transport
2
+ // MCP Protocol: JSON-RPC 2.0 over SSE (server→client) + HTTP POST (client→server)
3
+ //
4
+ // Flow:
5
+ // 1. Client connects GET /sse
6
+ // 2. Server sends: event: endpoint\ndata: /messages?sessionId=<uuid>
7
+ // 3. Client POSTs JSON-RPC to /messages?sessionId=<uuid>
8
+ // 4. Server sends JSON-RPC response via SSE: event: message\ndata: {...}
9
+
10
+ import { vaultTools } from './vault'
11
+ import { vaultWriteTools } from './vault_write.js'
12
+ import { amplifyTools, configureAmplify } from './amplify'
13
+
14
+ const HOST = '0.0.0.0'
15
+ const TRANSPORT = process.env.MCP_TRANSPORT ?? 'sse'
16
+ const PORT = parseInt(process.env.MCP_PORT ?? '8765')
17
+
18
+ // ─── MCP Protocol Types ──────────────────────────────────────────────────────
19
+
20
+ interface MCPRequest {
21
+ jsonrpc: '2.0'
22
+ id?: string | number
23
+ method: string
24
+ params?: any
25
+ }
26
+
27
+ interface MCPResponse {
28
+ jsonrpc: '2.0'
29
+ id?: string | number
30
+ result?: any
31
+ error?: { code: number; message: string; data?: any }
32
+ }
33
+
34
+ interface Tool {
35
+ name: string
36
+ description: string
37
+ inputSchema: any
38
+ call: (params: any) => Promise<{ content: Array<{type: string; text: string}>; isError?: boolean }>
39
+ }
40
+
41
+ // ─── State ───────────────────────────────────────────────────────────────────
42
+
43
+ const allTools: Tool[] = [
44
+ ...vaultTools,
45
+ ...vaultWriteTools,
46
+ ...amplifyTools
47
+ ]
48
+
49
+ const toolMap = new Map(allTools.map(t => [t.name, t]))
50
+
51
+ // Session management: sessionId → SSE writer
52
+ interface Session {
53
+ send: (data: string) => void
54
+ heartbeat: ReturnType<typeof setInterval>
55
+ }
56
+
57
+ const sessions = new Map<string, Session>()
58
+
59
+ // ─── Helpers ─────────────────────────────────────────────────────────────────
60
+
61
+ function makeResponse(id: string | number | undefined, result?: any, error?: any): MCPResponse {
62
+ return { jsonrpc: '2.0', id, result, error }
63
+ }
64
+
65
+ function generateSessionId(): string {
66
+ return crypto.randomUUID()
67
+ }
68
+
69
+ // ─── MCP Handlers ─────────────────────────────────────────────────────────────
70
+
71
+ async function handleRequest(req: MCPRequest): Promise<MCPResponse | null> {
72
+ const { method, id, params } = req
73
+
74
+ // ── notifications (no id = no response expected)
75
+ if (method === 'notifications/initialized' || method === 'notifications/cancelled') {
76
+ return null
77
+ }
78
+
79
+ // ── initialize
80
+ if (method === 'initialize') {
81
+ return makeResponse(id, {
82
+ protocolVersion: '2024-11-05',
83
+ capabilities: {
84
+ tools: { listChanged: false },
85
+ },
86
+ serverInfo: {
87
+ name: 'research-vault-mcp',
88
+ version: '1.0.0'
89
+ }
90
+ })
91
+ }
92
+
93
+ // ── tools/list
94
+ if (method === 'tools/list') {
95
+ return makeResponse(id, {
96
+ tools: allTools.map(t => ({
97
+ name: t.name,
98
+ description: t.description,
99
+ inputSchema: t.inputSchema
100
+ }))
101
+ })
102
+ }
103
+
104
+ // ── tools/call
105
+ if (method === 'tools/call') {
106
+ const { name, arguments: args } = params
107
+ console.error('[DEBUG] tools/call:', name, JSON.stringify(args))
108
+ const tool = toolMap.get(name)
109
+ if (!tool) {
110
+ return makeResponse(id, undefined, { code: -32602, message: `Unknown tool: ${name}` })
111
+ }
112
+ try {
113
+ const result = await tool.call(args || {})
114
+ return makeResponse(id, { content: result.content, isError: result.isError })
115
+ } catch (e: any) {
116
+ return makeResponse(id, undefined, { code: -32603, message: `Tool error: ${e.message}` })
117
+ }
118
+ }
119
+
120
+ // ── ping
121
+ if (method === 'ping') {
122
+ return makeResponse(id, {})
123
+ }
124
+
125
+ return makeResponse(id, undefined, { code: -32601, message: `Method not found: ${method}` })
126
+ }
127
+
128
+ // ─── STDIO Transport ──────────────────────────────────────────────────────────
129
+ async function handleStdioTransport() {
130
+ const rl = await import('readline')
131
+ const rli = rl.createInterface({ input: process.stdin as any, crlfDelay: Infinity })
132
+ const writer = Bun.stdout.writer()
133
+
134
+ const send = (obj: MCPResponse) => {
135
+ writer.write(JSON.stringify(obj) + '\n')
136
+ writer.flush()
137
+ }
138
+
139
+ for await (const line of rli) {
140
+ if (!line.trim()) continue
141
+ try {
142
+ const req = JSON.parse(line) as MCPRequest
143
+ const result = await handleRequest(req)
144
+ if (result) send(result)
145
+ } catch (e: unknown) {
146
+ send({ jsonrpc: '2.0', error: { code: -32700, message: `Parse error: ${e instanceof Error ? e.message : String(e)}` } })
147
+ }
148
+ }
149
+ }
150
+
151
+ // ─── HTTP Server ──────────────────────────────────────────────────────────────
152
+
153
+ const server = Bun.serve({
154
+ port: PORT,
155
+ hostname: HOST,
156
+
157
+ async fetch(req: Request): Promise<Response> {
158
+ const url = new URL(req.url)
159
+
160
+ // ── GET /sse — MCP SSE Transport: establish SSE stream + send endpoint
161
+ if (url.pathname === '/sse' && req.method === 'GET') {
162
+ const sessionId = generateSessionId()
163
+
164
+ const stream = new ReadableStream({
165
+ start(controller) {
166
+ const encoder = new TextEncoder()
167
+
168
+ const send = (data: string) => {
169
+ try { controller.enqueue(encoder.encode(data)) } catch {}
170
+ }
171
+
172
+ // Step 1: Send the endpoint event (MCP SSE spec requirement)
173
+ send(`event: endpoint\ndata: /messages?sessionId=${sessionId}\n\n`)
174
+
175
+ // Heartbeat every 15s
176
+ const heartbeat = setInterval(() => {
177
+ try {
178
+ controller.enqueue(encoder.encode(`: heartbeat\n\n`))
179
+ } catch {
180
+ clearInterval(heartbeat)
181
+ sessions.delete(sessionId)
182
+ }
183
+ }, 15000)
184
+
185
+ // Register session
186
+ sessions.set(sessionId, { send, heartbeat })
187
+
188
+ console.error(`[SSE] Session ${sessionId} connected`)
189
+
190
+ req.signal.addEventListener('abort', () => {
191
+ clearInterval(heartbeat)
192
+ sessions.delete(sessionId)
193
+ console.error(`[SSE] Session ${sessionId} disconnected`)
194
+ })
195
+ }
196
+ })
197
+
198
+ return new Response(stream, {
199
+ status: 200,
200
+ headers: {
201
+ 'Content-Type': 'text/event-stream',
202
+ 'Cache-Control': 'no-cache',
203
+ 'Connection': 'keep-alive',
204
+ 'X-Accel-Buffering': 'no'
205
+ }
206
+ })
207
+ }
208
+
209
+ // ── POST /messages?sessionId=xxx — MCP SSE Transport: receive JSON-RPC, respond via SSE
210
+ if (url.pathname === '/messages' && req.method === 'POST') {
211
+ const sessionId = url.searchParams.get('sessionId')
212
+
213
+ if (!sessionId || !sessions.has(sessionId)) {
214
+ return Response.json(
215
+ { error: 'Invalid or missing sessionId' },
216
+ { status: 400 }
217
+ )
218
+ }
219
+
220
+ const session = sessions.get(sessionId)!
221
+
222
+ try {
223
+ const body = await req.json() as MCPRequest
224
+
225
+ const result = await handleRequest(body)
226
+
227
+ // Send response via SSE stream (MCP SSE spec)
228
+ if (result) {
229
+ session.send(`event: message\ndata: ${JSON.stringify(result)}\n\n`)
230
+ }
231
+
232
+ // Return 202 Accepted (MCP SSE spec: POST returns 202, response goes via SSE)
233
+ return new Response(null, { status: 202 })
234
+ } catch (e: any) {
235
+ return Response.json(
236
+ { jsonrpc: '2.0', error: { code: -32700, message: `Parse error: ${e.message}` } },
237
+ { status: 400 }
238
+ )
239
+ }
240
+ }
241
+
242
+ // ── GET /health
243
+ if (url.pathname === '/health' && req.method === 'GET') {
244
+ return Response.json({
245
+ status: 'ok',
246
+ tools: allTools.length,
247
+ vault_tools: vaultTools.length,
248
+ amplify_tools: amplifyTools.length,
249
+ sse_sessions: sessions.size,
250
+ uptime: process.uptime()
251
+ })
252
+ }
253
+
254
+ // ── POST /configure — set Amplify API key
255
+ if (url.pathname === '/configure' && req.method === 'POST') {
256
+ try {
257
+ const { apiKey } = await req.json() as { apiKey: string }
258
+ if (!apiKey) throw new Error('apiKey required')
259
+ configureAmplify(apiKey)
260
+ return Response.json({ status: 'configured' })
261
+ } catch (e: any) {
262
+ return Response.json({ error: e.message }, { status: 400 })
263
+ }
264
+ }
265
+
266
+ // ── 404
267
+ return Response.json({ error: 'Not found' }, { status: 404 })
268
+ }
269
+ })
270
+
271
+ // ─── Startup ─────────────────────────────────────────────────────────────────
272
+
273
+ if (TRANSPORT === 'stdio') {
274
+ console.error('[MCP] Running in stdio mode (stdin/stdout JSON-RPC)')
275
+ await handleStdioTransport()
276
+ process.exit(0)
277
+ } else {
278
+ console.log(`
279
+ ╔══════════════════════════════════════════════════════╗
280
+ ║ Research Vault MCP Server — MCP SSE Transport ║
281
+ ╠══════════════════════════════════════════════════════╣
282
+ ║ SSE: http://${HOST}:${PORT}/sse ║
283
+ ║ Messages: http://${HOST}:${PORT}/messages ║
284
+ ║ Health: http://${HOST}:${PORT}/health ║
285
+ ╠══════════════════════════════════════════════════════╣
286
+ ║ Tools: ${String(allTools.length).padEnd(3)} (${vaultTools.length} vault, ${amplifyTools.length} amplify) ║
287
+ ╚══════════════════════════════════════════════════════╝
288
+ `)
289
+ }
290
+
291
+ // ─── Graceful Shutdown ───────────────────────────────────────────────────────
292
+
293
+ process.on('SIGINT', () => {
294
+ console.log('\nShutting down...')
295
+ for (const [id, session] of sessions) {
296
+ clearInterval(session.heartbeat)
297
+ }
298
+ sessions.clear()
299
+ server.stop()
300
+ process.exit(0)
301
+ })
package/src/types.ts ADDED
@@ -0,0 +1,77 @@
1
+ // packages/research-vault-mcp/src/types.ts
2
+
3
+ export interface VaultEntry {
4
+ id: string
5
+ title: string
6
+ category: string
7
+ path: string
8
+ modified: string
9
+ size: number
10
+ }
11
+
12
+ export interface DecayScore {
13
+ itemId: string
14
+ score: number
15
+ lastAccess: string
16
+ accessCount: number
17
+ summaryLevel: 'deep' | 'shallow' | 'none'
18
+ nextReviewAt: string
19
+ difficulty: number
20
+ }
21
+
22
+ // ─── Ingest Job Types ───────────────────────────────────────────
23
+
24
+ export type IngestStatus = 'queued' | 'fetching' | 'parsing' | 'done' | 'failed'
25
+
26
+ export interface IngestJob {
27
+ jobId: string
28
+ source: 'url' | 'file' | 'arxiv'
29
+ value: string
30
+ category: string
31
+ status: IngestStatus
32
+ rawPath: string | null
33
+ metadata: ArxivMetadata | null
34
+ error?: string
35
+ createdAt: string
36
+ updatedAt: string
37
+ }
38
+
39
+ export interface ArxivMetadata {
40
+ title: string | null
41
+ authors: string[] | null
42
+ abstract: string | null
43
+ arxivId: string | null
44
+ categories: string[] | null
45
+ }
46
+
47
+ // ─── Tool Input/Output Types ───────────────────────────────────
48
+
49
+ export interface RawIngestInput {
50
+ source: 'url' | 'file' | 'arxiv'
51
+ value: string
52
+ category?: string // defaults to "inbox"
53
+ priority?: 'high' | 'low'
54
+ arxivMetadata?: boolean // ArXiv only: prefetch metadata before storing, default true
55
+ }
56
+
57
+ export interface NoteSaveInput {
58
+ title: string
59
+ content: string
60
+ category: string
61
+ tags?: string[]
62
+ summaryLevel?: 'deep' | 'shallow' | 'none'
63
+ }
64
+
65
+ export interface VaultGetInput {
66
+ id?: string
67
+ path?: string
68
+ }
69
+
70
+ export interface VaultDeleteInput {
71
+ id?: string
72
+ path?: string
73
+ }
74
+
75
+ // ─── Checksum Types ────────────────────────────────────────────
76
+
77
+ export type ChecksumStore = Record<string, { sha256: string; writtenAt: string }>
package/src/vault.ts ADDED
@@ -0,0 +1,310 @@
1
+ // Research Vault MCP Tools
2
+ // Resolves vault root via env override, else defaults to the actual data location.
3
+ // After Phase 07 T3, CCR/research-vault is a submodule of ds-research-vault.
4
+
5
+ import { readFileSync, readdirSync, existsSync, statSync } from 'fs'
6
+ import { join, basename } from 'path'
7
+ import { homedir } from 'os'
8
+
9
+ const VAULT_ROOT = process.env.VAULT_ROOT ?? `${homedir()}/Documents/Evensong/research-vault`
10
+ const KNOWLEDGE_DIR = join(VAULT_ROOT, 'knowledge')
11
+ const RAW_DIR = join(VAULT_ROOT, 'raw')
12
+ const DECAY_PATH = join(VAULT_ROOT, '.meta', 'decay-scores.json')
13
+ const TAXONOMY_PATH = join(VAULT_ROOT, 'knowledge', '_taxonomy.md')
14
+
15
+ // ─── Types ───────────────────────────────────────────────────────────────────
16
+
17
+ interface VaultEntry {
18
+ id: string
19
+ title: string
20
+ category: string
21
+ path: string
22
+ modified: string
23
+ size: number
24
+ }
25
+
26
+ interface DecayScore {
27
+ itemId: string
28
+ score: number
29
+ lastAccess: string
30
+ accessCount: number
31
+ summaryLevel: 'deep' | 'shallow' | 'none'
32
+ nextReviewAt: string
33
+ difficulty: number
34
+ }
35
+
36
+ // ─── Helpers ─────────────────────────────────────────────────────────────────
37
+
38
+ function normalizeId(raw: string): string {
39
+ return raw
40
+ .replace(/^\d{8}--?\d{4}-/, '')
41
+ .replace(/^(\d{10,})--?/, '')
42
+ .replace(/\.md$/, '')
43
+ }
44
+
45
+ function loadDecayScores(): DecayScore[] {
46
+ try {
47
+ return JSON.parse(readFileSync(DECAY_PATH, 'utf-8'))
48
+ } catch {
49
+ return []
50
+ }
51
+ }
52
+
53
+ function loadTaxonomy(): string {
54
+ try {
55
+ return readFileSync(TAXONOMY_PATH, 'utf-8')
56
+ } catch {
57
+ return ''
58
+ }
59
+ }
60
+
61
+ function loadFileMeta(filePath: string): { title: string; modified: string; size: number } {
62
+ try {
63
+ const content = readFileSync(filePath, 'utf-8')
64
+ const lines = content.split('\n')
65
+ let title = ''
66
+ for (const line of lines.slice(0, 30)) {
67
+ const m = line.match(/^#\s+(.+)/)
68
+ if (m) { title = m[1]; break }
69
+ }
70
+ const s = statSync(filePath)
71
+ return {
72
+ title: title || normalizeId(basename(filePath)),
73
+ modified: s.mtime.toISOString(),
74
+ size: s.size
75
+ }
76
+ } catch {
77
+ return { title: normalizeId(basename(filePath)), modified: '', size: 0 }
78
+ }
79
+ }
80
+
81
+ function scanKnowledge(): VaultEntry[] {
82
+ const entries: VaultEntry[] = []
83
+ if (!existsSync(KNOWLEDGE_DIR)) return entries
84
+
85
+ const categories = readdirSync(KNOWLEDGE_DIR)
86
+ for (const cat of categories) {
87
+ if (cat.startsWith('_')) continue
88
+ const catPath = join(KNOWLEDGE_DIR, cat)
89
+ if (!existsSync(catPath) || !statSync(catPath).isDirectory()) continue
90
+
91
+ const subEntries = readdirSync(catPath)
92
+ for (const sub of subEntries) {
93
+ const subPath = join(catPath, sub)
94
+ const subStat = statSync(subPath)
95
+
96
+ if (subStat.isDirectory()) {
97
+ const files = readdirSync(subPath).filter(f => f.endsWith('.md'))
98
+ for (const file of files) {
99
+ const fp = join(subPath, file)
100
+ const meta = loadFileMeta(fp)
101
+ entries.push({
102
+ id: normalizeId(file),
103
+ title: meta.title,
104
+ category: `${cat}/${sub}`,
105
+ path: fp,
106
+ modified: meta.modified,
107
+ size: meta.size
108
+ })
109
+ }
110
+ } else if (sub.endsWith('.md')) {
111
+ const meta = loadFileMeta(subPath)
112
+ entries.push({
113
+ id: normalizeId(sub),
114
+ title: meta.title,
115
+ category: cat,
116
+ path: subPath,
117
+ modified: meta.modified,
118
+ size: meta.size
119
+ })
120
+ }
121
+ }
122
+ }
123
+ return entries
124
+ }
125
+
126
+ function scanRaw(): string[] {
127
+ const pending: string[] = []
128
+ if (!existsSync(RAW_DIR)) return pending
129
+
130
+ try {
131
+ const entries = readdirSync(RAW_DIR)
132
+ for (const entry of entries) {
133
+ if (entry === '_inbox') {
134
+ const inbox = join(RAW_DIR, entry)
135
+ if (existsSync(inbox)) {
136
+ pending.push(...readdirSync(inbox).filter(f => /\.(md|pdf|txt)$/.test(f)))
137
+ }
138
+ } else if (/^\d{4}-\d{2}$/.test(entry)) {
139
+ const monthDir = join(RAW_DIR, entry)
140
+ if (existsSync(monthDir)) {
141
+ pending.push(
142
+ ...readdirSync(monthDir)
143
+ .filter(f => /\.(md|pdf|txt)$/.test(f))
144
+ .map(f => `${entry}/${f}`)
145
+ )
146
+ }
147
+ }
148
+ }
149
+ } catch {}
150
+
151
+ return pending
152
+ }
153
+
154
+ // ─── MCP Tools ───────────────────────────────────────────────────────────────
155
+
156
+ const vaultTools = [
157
+ {
158
+ name: 'vault_search',
159
+ description: 'Search the Research Vault knowledge base. Returns analyzed papers with retention scores.',
160
+ inputSchema: {
161
+ type: 'object',
162
+ properties: {
163
+ query: { type: 'string', description: 'Search query (matches title, category)' },
164
+ category: { type: 'string', description: 'Filter by category (e.g., "ai-agents/benchmarking")' },
165
+ limit: { type: 'number', description: 'Max results (default 10)' }
166
+ }
167
+ },
168
+ call: async ({ query, category, limit = 10 }: { query?: string; category?: string; limit?: number }) => {
169
+ let items = scanKnowledge()
170
+ const scores = loadDecayScores()
171
+ const scoreMap = new Map(scores.map(s => [normalizeId(s.itemId), s]))
172
+
173
+ if (category) {
174
+ items = items.filter(item =>
175
+ item.category === category || item.category.startsWith(category + '/')
176
+ )
177
+ }
178
+
179
+ if (query) {
180
+ const q = query.toLowerCase()
181
+ items = items.filter(item =>
182
+ item.title.toLowerCase().includes(q) ||
183
+ item.id.toLowerCase().includes(q) ||
184
+ item.category.toLowerCase().includes(q)
185
+ )
186
+ }
187
+
188
+ const results = items.slice(0, limit).map(item => {
189
+ const sid = item.id.replace(/--/g, '-')
190
+ const score = scoreMap.get(item.id) || scoreMap.get(sid)
191
+ return {
192
+ id: item.id,
193
+ title: item.title,
194
+ category: item.category,
195
+ score: score?.score ?? null,
196
+ summaryLevel: score?.summaryLevel ?? null,
197
+ nextReview: score?.nextReviewAt ?? null,
198
+ accessCount: score?.accessCount ?? 0,
199
+ modified: item.modified
200
+ }
201
+ })
202
+
203
+ return {
204
+ content: [{
205
+ type: 'text',
206
+ text: JSON.stringify({ query, category, results, total: results.length }, null, 2)
207
+ }]
208
+ }
209
+ }
210
+ },
211
+
212
+ {
213
+ name: 'vault_status',
214
+ description: 'Get Research Vault health — item counts by decay level, top/bottom retention.',
215
+ inputSchema: { type: 'object', properties: {} },
216
+ call: async () => {
217
+ const scores = loadDecayScores()
218
+ const entries = scanKnowledge()
219
+ const deep = scores.filter(s => s.summaryLevel === 'deep')
220
+ const shallow = scores.filter(s => s.summaryLevel === 'shallow')
221
+ const none = scores.filter(s => s.summaryLevel === 'none')
222
+ const sorted = [...scores].sort((a, b) => b.score - a.score)
223
+
224
+ const top5 = sorted.slice(0, 5).map(s => {
225
+ const sid = s.itemId.replace(/--/g, '-')
226
+ const entry = entries.find(e => normalizeId(e.id) === normalizeId(s.itemId) || normalizeId(e.id) === normalizeId(sid))
227
+ return { itemId: s.itemId, score: s.score, accesses: s.accessCount, title: entry?.title || s.itemId }
228
+ })
229
+ const bottom5 = sorted.slice(-5).reverse().map(s => {
230
+ const sid = s.itemId.replace(/--/g, '-')
231
+ const entry = entries.find(e => normalizeId(e.id) === normalizeId(s.itemId) || normalizeId(e.id) === normalizeId(sid))
232
+ return { itemId: s.itemId, score: s.score, lastAccess: s.lastAccess.slice(0, 10), title: entry?.title || s.itemId }
233
+ })
234
+
235
+ const pending = scanRaw()
236
+ return {
237
+ content: [{
238
+ type: 'text',
239
+ text: JSON.stringify({
240
+ total: entries.length,
241
+ analyzed: scores.length,
242
+ deep: deep.length,
243
+ shallow: shallow.length,
244
+ dormant: none.length,
245
+ pending_raw: pending.length,
246
+ top5,
247
+ bottom5
248
+ }, null, 2)
249
+ }]
250
+ }
251
+ }
252
+ },
253
+
254
+ {
255
+ name: 'vault_batch_analyze',
256
+ description: 'Check batch analyze status and pending papers in the raw queue.',
257
+ inputSchema: {
258
+ type: 'object',
259
+ properties: {
260
+ count: { type: 'number', description: 'Preview N papers (default 5)' }
261
+ }
262
+ },
263
+ call: async ({ count = 5 }: { count?: number } = {}) => {
264
+ const pending = scanRaw()
265
+ const entries = scanKnowledge()
266
+ const analyzedIds = new Set(entries.map(e => normalizeId(e.id)))
267
+ const unanalyzed = pending.filter(p => {
268
+ const id = normalizeId(p)
269
+ return !analyzedIds.has(id)
270
+ })
271
+
272
+ if (unanalyzed.length === 0) {
273
+ return { content: [{ type: 'text', text: JSON.stringify({ message: 'Queue empty — all papers analyzed', analyzed: entries.length }) }] }
274
+ }
275
+
276
+ return {
277
+ content: [{
278
+ type: 'text',
279
+ text: JSON.stringify({
280
+ message: `${unanalyzed.length} papers pending analysis`,
281
+ pending: unanalyzed.length,
282
+ preview: unanalyzed.slice(0, count),
283
+ hint: 'cd ~/Desktop/research-vault && bun run scripts/batch-analyze.ts --count N'
284
+ }, null, 2)
285
+ }]
286
+ }
287
+ }
288
+ },
289
+
290
+ {
291
+ name: 'vault_taxonomy',
292
+ description: 'Get the Research Vault taxonomy — all categories and counts.',
293
+ inputSchema: { type: 'object', properties: {} },
294
+ call: async () => {
295
+ const taxonomy = loadTaxonomy()
296
+ const entries = scanKnowledge()
297
+ const catCounts: Record<string, number> = {}
298
+ for (const e of entries) catCounts[e.category] = (catCounts[e.category] || 0) + 1
299
+
300
+ return {
301
+ content: [{
302
+ type: 'text',
303
+ text: JSON.stringify({ taxonomy, categories: catCounts }, null, 2)
304
+ }]
305
+ }
306
+ }
307
+ }
308
+ ]
309
+
310
+ export { vaultTools, scanKnowledge, scanRaw, loadDecayScores, normalizeId }