@syndash/research-vault-mcp 1.1.2 → 1.1.3
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/CHANGELOG.md +35 -0
- package/README.md +34 -7
- package/dist/server.js +1114 -323
- package/package.json +6 -5
- package/src/amplify.ts +32 -41
- package/src/evidence_metadata.ts +191 -0
- package/src/guidance.ts +57 -0
- package/src/ingest/html.ts +129 -19
- package/src/profile.ts +15 -0
- package/src/public_safety.ts +110 -0
- package/src/response.ts +73 -0
- package/src/server.ts +304 -108
- package/src/tool_policy.ts +58 -0
- package/src/types.ts +4 -3
- package/src/vault.ts +300 -75
- package/src/vault_get.ts +109 -0
- package/src/vault_write.ts +78 -112
package/src/server.ts
CHANGED
|
@@ -1,21 +1,55 @@
|
|
|
1
|
-
// Research Vault MCP Server — stdio by default,
|
|
1
|
+
// Research Vault MCP Server — stdio by default, HTTP when explicitly requested
|
|
2
2
|
// MCP stdio: JSON-RPC 2.0 over stdin/stdout for command-launched MCP clients.
|
|
3
3
|
// MCP SSE: JSON-RPC 2.0 over SSE (server→client) + HTTP POST (client→server).
|
|
4
|
+
// MCP Streamable HTTP: JSON-RPC 2.0 over POST /mcp for remote MCP clients.
|
|
4
5
|
//
|
|
5
|
-
//
|
|
6
|
+
// Legacy SSE flow:
|
|
6
7
|
// 1. Client connects GET /sse
|
|
7
8
|
// 2. Server sends: event: endpoint\ndata: /messages?sessionId=<uuid>
|
|
8
9
|
// 3. Client POSTs JSON-RPC to /messages?sessionId=<uuid>
|
|
9
10
|
// 4. Server sends JSON-RPC response via SSE: event: message\ndata: {...}
|
|
11
|
+
//
|
|
12
|
+
// Streamable HTTP flow:
|
|
13
|
+
// 1. Client POSTs initialize to /mcp
|
|
14
|
+
// 2. Server returns JSON-RPC response + mcp-session-id header
|
|
15
|
+
// 3. Client POSTs requests/notifications to /mcp with mcp-session-id
|
|
10
16
|
|
|
11
17
|
import { vaultTools } from './vault'
|
|
12
18
|
import { vaultWriteTools } from './vault_write.js'
|
|
13
19
|
import { amplifyTools, configureAmplify } from './amplify'
|
|
20
|
+
import { getActiveProfile } from './profile.ts'
|
|
21
|
+
import { errorEnvelope } from './response.ts'
|
|
22
|
+
import { blockedToolResponse, configureAllowed, isToolAllowed, visibleToolsForProfile } from './tool_policy.ts'
|
|
23
|
+
import { createHash, timingSafeEqual } from 'crypto'
|
|
24
|
+
|
|
25
|
+
// Env-var auto-config: skip the unauthenticated POST /configure step
|
|
26
|
+
// when the API key is provided at startup via env.
|
|
27
|
+
export function loadAmplifyFromEnv(): boolean {
|
|
28
|
+
if (process.env.AMPLIFY_API_KEY) {
|
|
29
|
+
configureAmplify(process.env.AMPLIFY_API_KEY)
|
|
30
|
+
console.error('[MCP] Loaded Amplify API key from AMPLIFY_API_KEY env var')
|
|
31
|
+
return true
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return false
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
loadAmplifyFromEnv()
|
|
14
38
|
|
|
15
39
|
const HOST = '0.0.0.0'
|
|
16
40
|
const TRANSPORT = process.env.MCP_TRANSPORT ?? 'stdio'
|
|
17
41
|
const PORT = parseInt(process.env.MCP_PORT ?? '8765')
|
|
18
42
|
|
|
43
|
+
const SUPPORTED_PROTOCOL_VERSIONS = [
|
|
44
|
+
'2025-11-25',
|
|
45
|
+
'2025-06-18',
|
|
46
|
+
'2025-03-26',
|
|
47
|
+
'2024-11-05',
|
|
48
|
+
'2024-10-07'
|
|
49
|
+
]
|
|
50
|
+
|
|
51
|
+
const DEFAULT_STREAMABLE_PROTOCOL_VERSION = '2025-03-26'
|
|
52
|
+
|
|
19
53
|
// ─── MCP Protocol Types ──────────────────────────────────────────────────────
|
|
20
54
|
|
|
21
55
|
interface MCPRequest {
|
|
@@ -56,6 +90,7 @@ interface Session {
|
|
|
56
90
|
}
|
|
57
91
|
|
|
58
92
|
const sessions = new Map<string, Session>()
|
|
93
|
+
const streamableSessions = new Set<string>()
|
|
59
94
|
|
|
60
95
|
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
|
61
96
|
|
|
@@ -67,6 +102,39 @@ function generateSessionId(): string {
|
|
|
67
102
|
return crypto.randomUUID()
|
|
68
103
|
}
|
|
69
104
|
|
|
105
|
+
function timingSafeStringEqual(a: string, b: string): boolean {
|
|
106
|
+
const ah = createHash('sha256').update(a).digest()
|
|
107
|
+
const bh = createHash('sha256').update(b).digest()
|
|
108
|
+
return timingSafeEqual(ah, bh)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function negotiateProtocolVersion(requested: unknown): string {
|
|
112
|
+
if (typeof requested === 'string' && SUPPORTED_PROTOCOL_VERSIONS.includes(requested)) {
|
|
113
|
+
return requested
|
|
114
|
+
}
|
|
115
|
+
return DEFAULT_STREAMABLE_PROTOCOL_VERSION
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function mcpResponseHeaders(sessionId?: string): Headers {
|
|
119
|
+
const headers = new Headers()
|
|
120
|
+
if (sessionId) headers.set('mcp-session-id', sessionId)
|
|
121
|
+
return headers
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function makeMcpJsonError(status: number, code: number, message: string, sessionId?: string): Response {
|
|
125
|
+
return Response.json(
|
|
126
|
+
{
|
|
127
|
+
jsonrpc: '2.0',
|
|
128
|
+
error: { code, message },
|
|
129
|
+
id: null
|
|
130
|
+
},
|
|
131
|
+
{
|
|
132
|
+
status,
|
|
133
|
+
headers: mcpResponseHeaders(sessionId)
|
|
134
|
+
}
|
|
135
|
+
)
|
|
136
|
+
}
|
|
137
|
+
|
|
70
138
|
// ─── MCP Handlers ─────────────────────────────────────────────────────────────
|
|
71
139
|
|
|
72
140
|
async function handleRequest(req: MCPRequest): Promise<MCPResponse | null> {
|
|
@@ -80,7 +148,7 @@ async function handleRequest(req: MCPRequest): Promise<MCPResponse | null> {
|
|
|
80
148
|
// ── initialize
|
|
81
149
|
if (method === 'initialize') {
|
|
82
150
|
return makeResponse(id, {
|
|
83
|
-
protocolVersion:
|
|
151
|
+
protocolVersion: negotiateProtocolVersion(params?.protocolVersion),
|
|
84
152
|
capabilities: {
|
|
85
153
|
tools: { listChanged: false },
|
|
86
154
|
},
|
|
@@ -93,8 +161,9 @@ async function handleRequest(req: MCPRequest): Promise<MCPResponse | null> {
|
|
|
93
161
|
|
|
94
162
|
// ── tools/list
|
|
95
163
|
if (method === 'tools/list') {
|
|
164
|
+
const visibleTools = visibleToolsForProfile(allTools, getActiveProfile())
|
|
96
165
|
return makeResponse(id, {
|
|
97
|
-
tools:
|
|
166
|
+
tools: visibleTools.map(t => ({
|
|
98
167
|
name: t.name,
|
|
99
168
|
description: t.description,
|
|
100
169
|
inputSchema: t.inputSchema
|
|
@@ -106,6 +175,9 @@ async function handleRequest(req: MCPRequest): Promise<MCPResponse | null> {
|
|
|
106
175
|
if (method === 'tools/call') {
|
|
107
176
|
const { name, arguments: args } = params
|
|
108
177
|
console.error('[DEBUG] tools/call:', name, JSON.stringify(args))
|
|
178
|
+
if (!isToolAllowed(name, getActiveProfile())) {
|
|
179
|
+
return makeResponse(id, blockedToolResponse(name, getActiveProfile()))
|
|
180
|
+
}
|
|
109
181
|
const tool = toolMap.get(name)
|
|
110
182
|
if (!tool) {
|
|
111
183
|
return makeResponse(id, undefined, { code: -32602, message: `Unknown tool: ${name}` })
|
|
@@ -153,137 +225,259 @@ async function handleStdioTransport() {
|
|
|
153
225
|
|
|
154
226
|
let server: ReturnType<typeof Bun.serve> | undefined
|
|
155
227
|
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
port: PORT,
|
|
159
|
-
hostname: HOST,
|
|
228
|
+
export async function httpHandler(req: Request): Promise<Response> {
|
|
229
|
+
const url = new URL(req.url)
|
|
160
230
|
|
|
161
|
-
|
|
162
|
-
|
|
231
|
+
// ── POST /mcp — MCP Streamable HTTP Transport: receive JSON-RPC, respond directly
|
|
232
|
+
if (url.pathname === '/mcp' && req.method === 'POST') {
|
|
233
|
+
let body: MCPRequest | MCPRequest[]
|
|
163
234
|
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
start(controller) {
|
|
170
|
-
const encoder = new TextEncoder()
|
|
235
|
+
try {
|
|
236
|
+
body = await req.json() as MCPRequest | MCPRequest[]
|
|
237
|
+
} catch (e: any) {
|
|
238
|
+
return makeMcpJsonError(400, -32700, `Parse error: ${e.message}`)
|
|
239
|
+
}
|
|
171
240
|
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
241
|
+
const messages = Array.isArray(body) ? body : [body]
|
|
242
|
+
if (messages.length === 0) {
|
|
243
|
+
return makeMcpJsonError(400, -32600, 'Invalid Request: empty batch')
|
|
244
|
+
}
|
|
175
245
|
|
|
176
|
-
|
|
177
|
-
|
|
246
|
+
const hasInitialize = messages.some(message => message?.method === 'initialize')
|
|
247
|
+
let sessionId = req.headers.get('mcp-session-id') ?? undefined
|
|
178
248
|
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
249
|
+
if (hasInitialize) {
|
|
250
|
+
if (messages.length > 1) {
|
|
251
|
+
return makeMcpJsonError(400, -32600, 'Invalid Request: initialize must be sent alone')
|
|
252
|
+
}
|
|
253
|
+
sessionId = generateSessionId()
|
|
254
|
+
streamableSessions.add(sessionId)
|
|
255
|
+
} else {
|
|
256
|
+
if (!sessionId) {
|
|
257
|
+
return makeMcpJsonError(400, -32000, 'Bad Request: Mcp-Session-Id header is required')
|
|
258
|
+
}
|
|
259
|
+
if (!streamableSessions.has(sessionId)) {
|
|
260
|
+
return makeMcpJsonError(404, -32001, 'Session not found', sessionId)
|
|
261
|
+
}
|
|
262
|
+
}
|
|
188
263
|
|
|
189
|
-
|
|
190
|
-
sessions.set(sessionId, { send, heartbeat })
|
|
264
|
+
const responses: MCPResponse[] = []
|
|
191
265
|
|
|
192
|
-
|
|
266
|
+
for (const message of messages) {
|
|
267
|
+
const result = await handleRequest(message)
|
|
268
|
+
if (result) responses.push(result)
|
|
269
|
+
}
|
|
193
270
|
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
})
|
|
199
|
-
}
|
|
271
|
+
if (responses.length === 0) {
|
|
272
|
+
return new Response(null, {
|
|
273
|
+
status: 202,
|
|
274
|
+
headers: mcpResponseHeaders(sessionId)
|
|
200
275
|
})
|
|
276
|
+
}
|
|
201
277
|
|
|
202
|
-
|
|
278
|
+
return Response.json(
|
|
279
|
+
Array.isArray(body) ? responses : responses[0],
|
|
280
|
+
{
|
|
203
281
|
status: 200,
|
|
282
|
+
headers: mcpResponseHeaders(sessionId)
|
|
283
|
+
}
|
|
284
|
+
)
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// ── GET /mcp — optional Streamable HTTP SSE stream, not needed for JSON response mode
|
|
288
|
+
if (url.pathname === '/mcp' && req.method === 'GET') {
|
|
289
|
+
return Response.json(
|
|
290
|
+
{
|
|
291
|
+
jsonrpc: '2.0',
|
|
292
|
+
error: { code: -32000, message: 'Method Not Allowed: /mcp supports POST JSON responses' },
|
|
293
|
+
id: null
|
|
294
|
+
},
|
|
295
|
+
{
|
|
296
|
+
status: 405,
|
|
204
297
|
headers: {
|
|
205
|
-
|
|
206
|
-
'Cache-Control': 'no-cache',
|
|
207
|
-
'Connection': 'keep-alive',
|
|
208
|
-
'X-Accel-Buffering': 'no'
|
|
298
|
+
Allow: 'POST, DELETE'
|
|
209
299
|
}
|
|
210
|
-
}
|
|
300
|
+
}
|
|
301
|
+
)
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// ── DELETE /mcp — terminate a Streamable HTTP session
|
|
305
|
+
if (url.pathname === '/mcp' && req.method === 'DELETE') {
|
|
306
|
+
const sessionId = req.headers.get('mcp-session-id') ?? undefined
|
|
307
|
+
if (!sessionId) {
|
|
308
|
+
return makeMcpJsonError(400, -32000, 'Bad Request: Mcp-Session-Id header is required')
|
|
309
|
+
}
|
|
310
|
+
if (!streamableSessions.has(sessionId)) {
|
|
311
|
+
return makeMcpJsonError(404, -32001, 'Session not found', sessionId)
|
|
211
312
|
}
|
|
313
|
+
streamableSessions.delete(sessionId)
|
|
314
|
+
return new Response(null, { status: 204 })
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// ── GET /sse — MCP SSE Transport: establish SSE stream + send endpoint
|
|
318
|
+
if (url.pathname === '/sse' && req.method === 'GET') {
|
|
319
|
+
const sessionId = generateSessionId()
|
|
320
|
+
|
|
321
|
+
const stream = new ReadableStream({
|
|
322
|
+
start(controller) {
|
|
323
|
+
const encoder = new TextEncoder()
|
|
324
|
+
|
|
325
|
+
const send = (data: string) => {
|
|
326
|
+
try { controller.enqueue(encoder.encode(data)) } catch {}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// Step 1: Send the endpoint event (MCP SSE spec requirement)
|
|
330
|
+
send(`event: endpoint\ndata: /messages?sessionId=${sessionId}\n\n`)
|
|
331
|
+
|
|
332
|
+
// Heartbeat every 15s
|
|
333
|
+
const heartbeat = setInterval(() => {
|
|
334
|
+
try {
|
|
335
|
+
controller.enqueue(encoder.encode(`: heartbeat\n\n`))
|
|
336
|
+
} catch {
|
|
337
|
+
clearInterval(heartbeat)
|
|
338
|
+
sessions.delete(sessionId)
|
|
339
|
+
}
|
|
340
|
+
}, 15000)
|
|
341
|
+
|
|
342
|
+
// Register session
|
|
343
|
+
sessions.set(sessionId, { send, heartbeat })
|
|
212
344
|
|
|
213
|
-
|
|
214
|
-
if (url.pathname === '/messages' && req.method === 'POST') {
|
|
215
|
-
const sessionId = url.searchParams.get('sessionId')
|
|
345
|
+
console.error(`[SSE] Session ${sessionId} connected`)
|
|
216
346
|
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
)
|
|
347
|
+
req.signal.addEventListener('abort', () => {
|
|
348
|
+
clearInterval(heartbeat)
|
|
349
|
+
sessions.delete(sessionId)
|
|
350
|
+
console.error(`[SSE] Session ${sessionId} disconnected`)
|
|
351
|
+
})
|
|
222
352
|
}
|
|
353
|
+
})
|
|
223
354
|
|
|
224
|
-
|
|
355
|
+
return new Response(stream, {
|
|
356
|
+
status: 200,
|
|
357
|
+
headers: {
|
|
358
|
+
'Content-Type': 'text/event-stream',
|
|
359
|
+
'Cache-Control': 'no-cache',
|
|
360
|
+
'Connection': 'keep-alive',
|
|
361
|
+
'X-Accel-Buffering': 'no'
|
|
362
|
+
}
|
|
363
|
+
})
|
|
364
|
+
}
|
|
225
365
|
|
|
226
|
-
|
|
227
|
-
|
|
366
|
+
// ── POST /messages?sessionId=xxx — MCP SSE Transport: receive JSON-RPC, respond via SSE
|
|
367
|
+
if (url.pathname === '/messages' && req.method === 'POST') {
|
|
368
|
+
const sessionId = url.searchParams.get('sessionId')
|
|
228
369
|
|
|
229
|
-
|
|
370
|
+
if (!sessionId || !sessions.has(sessionId)) {
|
|
371
|
+
return Response.json(
|
|
372
|
+
{ error: 'Invalid or missing sessionId' },
|
|
373
|
+
{ status: 400 }
|
|
374
|
+
)
|
|
375
|
+
}
|
|
230
376
|
|
|
231
|
-
|
|
232
|
-
if (result) {
|
|
233
|
-
session.send(`event: message\ndata: ${JSON.stringify(result)}\n\n`)
|
|
234
|
-
}
|
|
377
|
+
const session = sessions.get(sessionId)!
|
|
235
378
|
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
379
|
+
try {
|
|
380
|
+
const body = await req.json() as MCPRequest
|
|
381
|
+
|
|
382
|
+
const result = await handleRequest(body)
|
|
383
|
+
|
|
384
|
+
// Send response via SSE stream (MCP SSE spec)
|
|
385
|
+
if (result) {
|
|
386
|
+
session.send(`event: message\ndata: ${JSON.stringify(result)}\n\n`)
|
|
243
387
|
}
|
|
388
|
+
|
|
389
|
+
// Return 202 Accepted (MCP SSE spec: POST returns 202, response goes via SSE)
|
|
390
|
+
return new Response(null, { status: 202 })
|
|
391
|
+
} catch (e: any) {
|
|
392
|
+
return Response.json(
|
|
393
|
+
{ jsonrpc: '2.0', error: { code: -32700, message: `Parse error: ${e.message}` } },
|
|
394
|
+
{ status: 400 }
|
|
395
|
+
)
|
|
244
396
|
}
|
|
397
|
+
}
|
|
245
398
|
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
399
|
+
// ── GET /health
|
|
400
|
+
if (url.pathname === '/health' && req.method === 'GET') {
|
|
401
|
+
const profile = getActiveProfile()
|
|
402
|
+
const visibleTools = visibleToolsForProfile(allTools, profile)
|
|
403
|
+
return Response.json({
|
|
404
|
+
status: 'ok',
|
|
405
|
+
profile,
|
|
406
|
+
public_safe_default: true,
|
|
407
|
+
tools: visibleTools.length,
|
|
408
|
+
total_registered_tools: allTools.length,
|
|
409
|
+
visible_tools: visibleTools.map(tool => tool.name),
|
|
410
|
+
vault_tools: vaultTools.length,
|
|
411
|
+
amplify_tools: amplifyTools.length,
|
|
412
|
+
sse_sessions: sessions.size,
|
|
413
|
+
streamable_sessions: streamableSessions.size,
|
|
414
|
+
uptime: process.uptime()
|
|
415
|
+
})
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
// ── POST /configure — set Amplify API key
|
|
419
|
+
if (url.pathname === '/configure' && req.method === 'POST') {
|
|
420
|
+
const profile = getActiveProfile()
|
|
421
|
+
if (!configureAllowed(profile)) {
|
|
422
|
+
return Response.json(
|
|
423
|
+
errorEnvelope(
|
|
424
|
+
`/configure is unavailable while Research Vault MCP is running in ${profile} profile.`,
|
|
425
|
+
'Set MCP_PROFILE=full or MCP_PROFILE=admin in a private operator session before configuring mutation-capable tools.',
|
|
426
|
+
{ profile },
|
|
427
|
+
),
|
|
428
|
+
{ status: 403 },
|
|
429
|
+
)
|
|
256
430
|
}
|
|
257
431
|
|
|
258
|
-
|
|
259
|
-
if (
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
configureAmplify(apiKey)
|
|
264
|
-
return Response.json({ status: 'configured' })
|
|
265
|
-
} catch (e: any) {
|
|
266
|
-
return Response.json({ error: e.message }, { status: 400 })
|
|
432
|
+
const requiredSecret = process.env.MCP_CONFIGURE_SECRET
|
|
433
|
+
if (requiredSecret) {
|
|
434
|
+
const providedSecret = req.headers.get('x-configure-secret') ?? ''
|
|
435
|
+
if (!timingSafeStringEqual(providedSecret, requiredSecret)) {
|
|
436
|
+
return Response.json({ error: 'Forbidden' }, { status: 403 })
|
|
267
437
|
}
|
|
268
438
|
}
|
|
269
439
|
|
|
270
|
-
|
|
271
|
-
|
|
440
|
+
try {
|
|
441
|
+
const { apiKey } = await req.json() as { apiKey: string }
|
|
442
|
+
if (!apiKey) throw new Error('apiKey required')
|
|
443
|
+
configureAmplify(apiKey)
|
|
444
|
+
return Response.json({ status: 'configured' })
|
|
445
|
+
} catch (e: any) {
|
|
446
|
+
return Response.json({ error: e.message }, { status: 400 })
|
|
447
|
+
}
|
|
272
448
|
}
|
|
449
|
+
|
|
450
|
+
// ── 404
|
|
451
|
+
return Response.json({ error: 'Not found' }, { status: 404 })
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
function startHttpServer() {
|
|
455
|
+
server = Bun.serve({
|
|
456
|
+
port: PORT,
|
|
457
|
+
hostname: HOST,
|
|
458
|
+
fetch: httpHandler
|
|
273
459
|
})
|
|
274
460
|
}
|
|
275
461
|
|
|
276
462
|
// ─── Startup ─────────────────────────────────────────────────────────────────
|
|
277
463
|
|
|
278
|
-
if (
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
464
|
+
if (import.meta.main) {
|
|
465
|
+
if (TRANSPORT === 'stdio') {
|
|
466
|
+
console.error('[MCP] Running in stdio mode (stdin/stdout JSON-RPC)')
|
|
467
|
+
await handleStdioTransport()
|
|
468
|
+
process.exit(0)
|
|
469
|
+
} else {
|
|
470
|
+
if (!process.env.MCP_CONFIGURE_SECRET) {
|
|
471
|
+
console.error('[MCP] WARNING: /configure endpoint is unauthenticated. Set MCP_CONFIGURE_SECRET to require X-Configure-Secret header. Use AMPLIFY_API_KEY env var to skip /configure entirely.')
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
startHttpServer()
|
|
475
|
+
|
|
476
|
+
console.log(`
|
|
284
477
|
╔══════════════════════════════════════════════════════╗
|
|
285
|
-
║ Research Vault MCP Server — MCP
|
|
478
|
+
║ Research Vault MCP Server — MCP HTTP Transport ║
|
|
286
479
|
╠══════════════════════════════════════════════════════╣
|
|
480
|
+
║ MCP: http://${HOST}:${PORT}/mcp ║
|
|
287
481
|
║ SSE: http://${HOST}:${PORT}/sse ║
|
|
288
482
|
║ Messages: http://${HOST}:${PORT}/messages ║
|
|
289
483
|
║ Health: http://${HOST}:${PORT}/health ║
|
|
@@ -291,16 +485,18 @@ if (TRANSPORT === 'stdio') {
|
|
|
291
485
|
║ Tools: ${String(allTools.length).padEnd(3)} (${vaultTools.length} vault, ${amplifyTools.length} amplify) ║
|
|
292
486
|
╚══════════════════════════════════════════════════════╝
|
|
293
487
|
`)
|
|
294
|
-
}
|
|
488
|
+
}
|
|
295
489
|
|
|
296
|
-
// ─── Graceful Shutdown ───────────────────────────────────────────────────────
|
|
490
|
+
// ─── Graceful Shutdown ───────────────────────────────────────────────────────
|
|
297
491
|
|
|
298
|
-
process.on('SIGINT', () => {
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
492
|
+
process.on('SIGINT', () => {
|
|
493
|
+
console.log('\nShutting down...')
|
|
494
|
+
for (const [id, session] of sessions) {
|
|
495
|
+
clearInterval(session.heartbeat)
|
|
496
|
+
}
|
|
497
|
+
sessions.clear()
|
|
498
|
+
streamableSessions.clear()
|
|
499
|
+
server?.stop()
|
|
500
|
+
process.exit(0)
|
|
501
|
+
})
|
|
502
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { adminBlockedGuidance, readonlyBlockedGuidance } from './guidance.ts'
|
|
2
|
+
import { getActiveProfile, profileAllowsMutation, type McpProfile } from './profile.ts'
|
|
3
|
+
import { errorEnvelope } from './response.ts'
|
|
4
|
+
|
|
5
|
+
interface NamedTool {
|
|
6
|
+
name: string
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export const READONLY_TOOL_NAMES = new Set([
|
|
10
|
+
'vault_status',
|
|
11
|
+
'vault_taxonomy',
|
|
12
|
+
'vault_search',
|
|
13
|
+
'vault_get',
|
|
14
|
+
'vault_batch_analyze',
|
|
15
|
+
])
|
|
16
|
+
|
|
17
|
+
export const MUTATION_TOOL_NAMES = new Set([
|
|
18
|
+
'vault_raw_ingest',
|
|
19
|
+
'vault_note_save',
|
|
20
|
+
'vault_delete',
|
|
21
|
+
])
|
|
22
|
+
|
|
23
|
+
function isDeleteOrAdminTool(name: string): boolean {
|
|
24
|
+
return name === 'vault_delete' || name.includes('_delete') || name.includes('_admin') || name.startsWith('admin_')
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function visibleToolsForProfile<T extends NamedTool>(
|
|
28
|
+
tools: T[],
|
|
29
|
+
profile: McpProfile = getActiveProfile(),
|
|
30
|
+
): T[] {
|
|
31
|
+
return tools.filter(tool => isToolAllowed(tool.name, profile))
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function isToolAllowed(name: string, profile: McpProfile = getActiveProfile()): boolean {
|
|
35
|
+
if (profile === 'admin') return true
|
|
36
|
+
if (profile === 'readonly') return READONLY_TOOL_NAMES.has(name)
|
|
37
|
+
return !isDeleteOrAdminTool(name)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function blockedToolResponse(name: string, profile: McpProfile = getActiveProfile()) {
|
|
41
|
+
const guidance = isDeleteOrAdminTool(name)
|
|
42
|
+
? adminBlockedGuidance(name, profile)
|
|
43
|
+
: readonlyBlockedGuidance(name, profile)
|
|
44
|
+
return {
|
|
45
|
+
content: [{
|
|
46
|
+
type: 'text',
|
|
47
|
+
text: JSON.stringify({
|
|
48
|
+
...errorEnvelope(guidance.reason, guidance.next_step, { profile }),
|
|
49
|
+
agent_guidance: guidance,
|
|
50
|
+
}),
|
|
51
|
+
}],
|
|
52
|
+
isError: true,
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function configureAllowed(profile: McpProfile = getActiveProfile()): boolean {
|
|
57
|
+
return profileAllowsMutation(profile)
|
|
58
|
+
}
|
package/src/types.ts
CHANGED
|
@@ -63,8 +63,9 @@ export interface NoteSaveInput {
|
|
|
63
63
|
}
|
|
64
64
|
|
|
65
65
|
export interface VaultGetInput {
|
|
66
|
-
id
|
|
67
|
-
|
|
66
|
+
id: string
|
|
67
|
+
include_content?: boolean
|
|
68
|
+
max_chars?: number
|
|
68
69
|
}
|
|
69
70
|
|
|
70
71
|
export interface VaultDeleteInput {
|
|
@@ -74,4 +75,4 @@ export interface VaultDeleteInput {
|
|
|
74
75
|
|
|
75
76
|
// ─── Checksum Types ────────────────────────────────────────────
|
|
76
77
|
|
|
77
|
-
export type ChecksumStore = Record<string, { sha256: string; writtenAt: string }>
|
|
78
|
+
export type ChecksumStore = Record<string, { sha256: string; writtenAt: string }>
|