@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/src/server.ts CHANGED
@@ -1,21 +1,55 @@
1
- // Research Vault MCP Server — stdio by default, SSE when explicitly requested
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
- // Flow:
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: '2024-11-05',
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: allTools.map(t => ({
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
- if (TRANSPORT !== 'stdio') {
157
- server = Bun.serve({
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
- async fetch(req: Request): Promise<Response> {
162
- const url = new URL(req.url)
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
- // ── GET /sse — MCP SSE Transport: establish SSE stream + send endpoint
165
- if (url.pathname === '/sse' && req.method === 'GET') {
166
- const sessionId = generateSessionId()
167
-
168
- const stream = new ReadableStream({
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
- const send = (data: string) => {
173
- try { controller.enqueue(encoder.encode(data)) } catch {}
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
- // Step 1: Send the endpoint event (MCP SSE spec requirement)
177
- send(`event: endpoint\ndata: /messages?sessionId=${sessionId}\n\n`)
246
+ const hasInitialize = messages.some(message => message?.method === 'initialize')
247
+ let sessionId = req.headers.get('mcp-session-id') ?? undefined
178
248
 
179
- // Heartbeat every 15s
180
- const heartbeat = setInterval(() => {
181
- try {
182
- controller.enqueue(encoder.encode(`: heartbeat\n\n`))
183
- } catch {
184
- clearInterval(heartbeat)
185
- sessions.delete(sessionId)
186
- }
187
- }, 15000)
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
- // Register session
190
- sessions.set(sessionId, { send, heartbeat })
264
+ const responses: MCPResponse[] = []
191
265
 
192
- console.error(`[SSE] Session ${sessionId} connected`)
266
+ for (const message of messages) {
267
+ const result = await handleRequest(message)
268
+ if (result) responses.push(result)
269
+ }
193
270
 
194
- req.signal.addEventListener('abort', () => {
195
- clearInterval(heartbeat)
196
- sessions.delete(sessionId)
197
- console.error(`[SSE] Session ${sessionId} disconnected`)
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
- return new Response(stream, {
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
- 'Content-Type': 'text/event-stream',
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
- // ── POST /messages?sessionId=xxx — MCP SSE Transport: receive JSON-RPC, respond via SSE
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
- if (!sessionId || !sessions.has(sessionId)) {
218
- return Response.json(
219
- { error: 'Invalid or missing sessionId' },
220
- { status: 400 }
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
- const session = sessions.get(sessionId)!
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
- try {
227
- const body = await req.json() as MCPRequest
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
- const result = await handleRequest(body)
370
+ if (!sessionId || !sessions.has(sessionId)) {
371
+ return Response.json(
372
+ { error: 'Invalid or missing sessionId' },
373
+ { status: 400 }
374
+ )
375
+ }
230
376
 
231
- // Send response via SSE stream (MCP SSE spec)
232
- if (result) {
233
- session.send(`event: message\ndata: ${JSON.stringify(result)}\n\n`)
234
- }
377
+ const session = sessions.get(sessionId)!
235
378
 
236
- // Return 202 Accepted (MCP SSE spec: POST returns 202, response goes via SSE)
237
- return new Response(null, { status: 202 })
238
- } catch (e: any) {
239
- return Response.json(
240
- { jsonrpc: '2.0', error: { code: -32700, message: `Parse error: ${e.message}` } },
241
- { status: 400 }
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
- // ── GET /health
247
- if (url.pathname === '/health' && req.method === 'GET') {
248
- return Response.json({
249
- status: 'ok',
250
- tools: allTools.length,
251
- vault_tools: vaultTools.length,
252
- amplify_tools: amplifyTools.length,
253
- sse_sessions: sessions.size,
254
- uptime: process.uptime()
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
- // ── POST /configure — set Amplify API key
259
- if (url.pathname === '/configure' && req.method === 'POST') {
260
- try {
261
- const { apiKey } = await req.json() as { apiKey: string }
262
- if (!apiKey) throw new Error('apiKey required')
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
- // ── 404
271
- return Response.json({ error: 'Not found' }, { status: 404 })
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 (TRANSPORT === 'stdio') {
279
- console.error('[MCP] Running in stdio mode (stdin/stdout JSON-RPC)')
280
- await handleStdioTransport()
281
- process.exit(0)
282
- } else {
283
- console.log(`
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 SSE Transport
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
- console.log('\nShutting down...')
300
- for (const [id, session] of sessions) {
301
- clearInterval(session.heartbeat)
302
- }
303
- sessions.clear()
304
- server?.stop()
305
- process.exit(0)
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?: string
67
- path?: string
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 }>