@marsnme/mcp-gateway 0.1.8 → 0.2.1

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.
Files changed (2) hide show
  1. package/package.json +4 -3
  2. package/server.mjs +386 -81
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@marsnme/mcp-gateway",
3
- "version": "0.1.8",
3
+ "version": "0.2.1",
4
4
  "mcpName": "io.github.Marsmanleo/marsnme",
5
5
  "private": false,
6
6
  "description": "Agent-agnostic, LLM-agnostic memory backend for MCP-compatible tools",
@@ -26,7 +26,8 @@
26
26
  "session-memory",
27
27
  "cross-session",
28
28
  "llm",
29
- "symbiosis"
29
+ "symbiosis",
30
+ "perplexity"
30
31
  ],
31
32
  "bin": {
32
33
  "marsnme": "server.mjs"
@@ -48,4 +49,4 @@
48
49
  "engines": {
49
50
  "node": ">=20"
50
51
  }
51
- }
52
+ }
package/server.mjs CHANGED
@@ -2,6 +2,7 @@
2
2
  import http from 'node:http';
3
3
  import crypto from 'node:crypto';
4
4
  import { execFileSync } from 'node:child_process';
5
+ import { AsyncLocalStorage } from 'node:async_hooks';
5
6
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
6
7
  import { dirname } from 'node:path';
7
8
 
@@ -171,6 +172,28 @@ if (!Number.isInteger(PORT) || PORT < 1 || PORT > 65535) {
171
172
  throw new Error(`Invalid PORT: ${PORT_RAW || String(PORT)}. Must be an integer between 1 and 65535.`);
172
173
  }
173
174
  const SUPABASE_BASE_URL = process.env.SUPABASE_BASE_URL || 'http://127.0.0.1:8100';
175
+ const SUPABASE_AUTH_MODE_VALUES = new Set(['service', 'anon', 'hybrid']);
176
+ const SUPABASE_AUTH_MODE_RAW = String(process.env.MCP_SUPABASE_AUTH_MODE || 'service')
177
+ .trim()
178
+ .toLowerCase();
179
+ if (SUPABASE_AUTH_MODE_RAW && !SUPABASE_AUTH_MODE_VALUES.has(SUPABASE_AUTH_MODE_RAW)) {
180
+ console.warn(
181
+ `[config] invalid MCP_SUPABASE_AUTH_MODE=${SUPABASE_AUTH_MODE_RAW}; fallback to service`
182
+ );
183
+ }
184
+ const SUPABASE_AUTH_MODE = SUPABASE_AUTH_MODE_VALUES.has(SUPABASE_AUTH_MODE_RAW)
185
+ ? SUPABASE_AUTH_MODE_RAW
186
+ : 'service';
187
+ const SUPABASE_ANON_KEY = String(process.env.SUPABASE_ANON_KEY || '').trim();
188
+ const ANON_ALLOWED_TOOL_NAMES = new Set([
189
+ 'list_memories',
190
+ 'search_memories',
191
+ 'reload_source_registry',
192
+ 'recall',
193
+ 'health_check',
194
+ 'explain_memory'
195
+ ]);
196
+ const SUPABASE_REQUEST_CONTEXT = new AsyncLocalStorage();
174
197
  const OAUTH_ENABLED = process.env.MCP_OAUTH_ENABLED !== 'false';
175
198
  const REQUIRE_BEARER = process.env.MCP_REQUIRE_BEARER === 'true';
176
199
  const BYPASS_BEARER_FOR_PRIVATE = process.env.MCP_BYPASS_BEARER_FOR_PRIVATE !== 'false';
@@ -284,29 +307,29 @@ function buildTools() {
284
307
  return [
285
308
  {
286
309
  name: 'insert_memory',
287
- description: `Insert a short-term ${PROFILE.displayName} memory into ${DB_PROFILE}.memories (ephemeral context)`,
310
+ description: `Store a short-term ${PROFILE.displayName} memory into ${DB_PROFILE}.memories (ephemeral context that auto-expires). Use this to capture observations, decisions, session notes, or any context worth remembering temporarily. Memories last 7 days by default unless expires_at is set. For permanent knowledge, use memory_ingest instead.`,
288
311
  inputSchema: {
289
312
  type: 'object',
290
313
  properties: {
291
- body: { type: 'string', description: 'Memory content text' },
314
+ body: { type: 'string', description: 'The memory content to store. Be specific and include relevant context — this text is used for semantic search later.' },
292
315
  source: buildMemorySourceSchema(),
293
- session_id: { type: 'string', description: 'Session trace id' },
316
+ session_id: { type: 'string', description: 'Unique identifier for the current session/conversation. Used to group related memories together.' },
294
317
  tags: {
295
318
  type: 'array',
296
319
  items: { type: 'string' },
297
- description: 'Optional tags list'
320
+ description: 'Categorization labels for filtering (e.g. ["decision", "architecture", "bugfix"])'
298
321
  },
299
322
  expires_at: {
300
323
  type: 'string',
301
- description: 'Optional ISO timestamp override'
324
+ description: 'Custom expiration time in ISO 8601 format (e.g. "2026-06-17T00:00:00Z"). Defaults to 7 days from now if omitted.'
302
325
  },
303
326
  agent_body: {
304
327
  type: 'string',
305
- description: 'Optional body label (default inferred from source)'
328
+ description: 'Which persona/agent body this memory belongs to (e.g. "coco", "toto"). Defaults to the profile of this server.'
306
329
  },
307
330
  environment: {
308
331
  type: 'string',
309
- description: 'Optional environment label (default from MCP_ENVIRONMENT)'
332
+ description: 'Deployment environment label (e.g. "production", "staging"). Defaults to MCP_ENVIRONMENT if set.'
310
333
  }
311
334
  },
312
335
  required: ['body', 'source', 'session_id'],
@@ -315,36 +338,36 @@ function buildTools() {
315
338
  },
316
339
  {
317
340
  name: 'list_memories',
318
- description: `List recent memories from ${DB_PROFILE}.memories`,
341
+ description: `List recent short-term memories from ${DB_PROFILE}.memories in reverse chronological order — like a daily log or activity feed. Use this to review what was recently captured, check for duplicate memories before inserting, or browse recent activity. This is a time-ordered listing tool, not a search tool. For semantic search by meaning, use search_memories instead. For long-term knowledge retrieval, use recall. Returns memory body, source, creation time, and expiration status.`,
319
342
  inputSchema: {
320
343
  type: 'object',
321
344
  properties: {
322
- limit: { type: 'number', minimum: 1, maximum: 100, default: 20 },
345
+ limit: { type: 'number', minimum: 1, maximum: 100, default: 20, description: 'Maximum number of memories to return (default 20)' },
323
346
  source: buildMemorySourceSchema(),
324
- unexpired_only: { type: 'boolean', default: true }
347
+ unexpired_only: { type: 'boolean', default: true, description: 'When true (default), only return memories that have not yet expired. Set false to include expired entries.' }
325
348
  },
326
349
  additionalProperties: false
327
350
  }
328
351
  },
329
352
  {
330
353
  name: 'search_memories',
331
- description: `Semantic search memories from ${DB_PROFILE}.memories using Jina embeddings`,
354
+ description: `Semantic search across short-term memories in ${DB_PROFILE}.memories using Jina embeddings. Finds memories by meaning, not just keywords — ask a natural language question and get the most relevant matches. Use this to recall past decisions, find related context, or check if something was already discussed recently.`,
332
355
  inputSchema: {
333
356
  type: 'object',
334
357
  properties: {
335
- query: { type: 'string', description: 'Semantic search query text' },
336
- limit: { type: 'number', minimum: 1, maximum: 100, default: 20 },
358
+ query: { type: 'string', description: 'Natural language search query. Describe what you are looking for — semantic matching finds relevant results even without exact keywords.' },
359
+ limit: { type: 'number', minimum: 1, maximum: 100, default: 20, description: 'Maximum number of results to return' },
337
360
  source: buildMemorySourceSchema(),
338
- unexpired_only: { type: 'boolean', default: true },
339
- min_similarity: { type: 'number', minimum: -1, maximum: 1 },
340
- scope: { type: 'string', enum: ['this_body', 'all_bodies'], default: 'this_body' },
361
+ unexpired_only: { type: 'boolean', default: true, description: 'When true (default), exclude expired memories from results' },
362
+ min_similarity: { type: 'number', minimum: -1, maximum: 1, description: 'Minimum cosine similarity threshold for results (range -1 to 1). Higher values return fewer but more relevant matches.' },
363
+ scope: { type: 'string', enum: ['this_body', 'all_bodies'], default: 'this_body', description: 'Search scope: "this_body" searches only the current profile, "all_bodies" searches across all personas.' },
341
364
  agent_body: {
342
365
  type: 'string',
343
- description: 'Optional body scope key; defaults to source/body/env inference'
366
+ description: 'Filter results to a specific persona/body (e.g. "coco", "toto")'
344
367
  },
345
368
  environment: {
346
369
  type: 'string',
347
- description: 'Optional environment filter'
370
+ description: 'Filter results to a specific environment (e.g. "production", "staging")'
348
371
  }
349
372
  },
350
373
  required: ['query'],
@@ -354,7 +377,7 @@ function buildTools() {
354
377
  {
355
378
  name: 'reload_source_registry',
356
379
  description:
357
- `Reload enabled sources cache from ${DB_PROFILE}.${SOURCE_REGISTRY_TABLE} (effective when MCP_SOURCE_MODE=registry)`,
380
+ `Reload the source registry cache from ${DB_PROFILE}.${SOURCE_REGISTRY_TABLE}. Use this after adding or removing entries to refresh which sources are enabled for insert/search filtering. Only effective when running in registry mode (MCP_SOURCE_MODE=registry).`,
358
381
  inputSchema: {
359
382
  type: 'object',
360
383
  properties: {},
@@ -363,31 +386,31 @@ function buildTools() {
363
386
  },
364
387
  {
365
388
  name: 'recall',
366
- description: `Semantic recall from ${DB_PROFILE}.marsvault_chunks using Jina embeddings`,
389
+ description: `Semantic recall from long-term memory (${DB_PROFILE}.marsvault_chunks) using Jina embeddings. Searches across promoted insights, digests, and archived knowledge using vector similarity. Use this to retrieve established patterns, past decisions, documented workflows, or any knowledge that was previously promoted to long-term storage. This is the primary tool for accessing institutional memory.`,
367
390
  inputSchema: {
368
391
  type: 'object',
369
392
  properties: {
370
- query: { type: 'string', description: 'Semantic recall query text' },
371
- limit: { type: 'number', minimum: 1, maximum: 50, default: 5 },
372
- body: { type: 'string', enum: PROFILE.recallBodyEnum, default: DB_PROFILE },
373
- include_global: { type: 'boolean', default: true },
374
- include_shared: { type: 'boolean', default: true },
375
- include_private: { type: 'boolean', default: true },
376
- type: { type: 'string', description: 'Optional chunk type filter' },
377
- min_similarity: { type: 'number', minimum: -1, maximum: 1 },
378
- scope: { type: 'string', enum: ['this_body', 'all_bodies'], default: 'this_body' },
393
+ query: { type: 'string', description: 'Natural language query describing what knowledge you need. The system finds semantically similar chunks — describe the concept, not just keywords.' },
394
+ limit: { type: 'number', minimum: 1, maximum: 50, default: 5, description: 'Maximum number of chunks to return (default 5)' },
395
+ body: { type: 'string', enum: PROFILE.recallBodyEnum, default: DB_PROFILE, description: 'Which persona profile to search in (e.g. "coco", "toto", "system")' },
396
+ include_global: { type: 'boolean', default: true, description: 'Include globally visible chunks in results' },
397
+ include_shared: { type: 'boolean', default: true, description: 'Include shared-visibility chunks in results' },
398
+ include_private: { type: 'boolean', default: true, description: 'Include private chunks in results' },
399
+ type: { type: 'string', description: 'Filter by chunk type (e.g. "insight", "digest", "observation")' },
400
+ min_similarity: { type: 'number', minimum: -1, maximum: 1, description: 'Minimum cosine similarity threshold. Raise for higher precision, lower for broader recall.' },
401
+ scope: { type: 'string', enum: ['this_body', 'all_bodies'], default: 'this_body', description: 'Search scope: "this_body" for current profile only, "all_bodies" for cross-persona search' },
379
402
  agent_body: {
380
403
  type: 'string',
381
- description: 'Optional body scope key; defaults to MCP_AGENT_BODY when present'
404
+ description: 'Filter to a specific persona/body scope'
382
405
  },
383
406
  environment: {
384
407
  type: 'string',
385
- description: 'Optional environment filter'
408
+ description: 'Filter to a specific environment label'
386
409
  },
387
410
  debug_explain: {
388
411
  type: 'boolean',
389
412
  default: false,
390
- description: 'Include query/hit token overlap debug details'
413
+ description: 'When true, include token overlap details in each result for debugging relevance'
391
414
  }
392
415
  },
393
416
  required: ['query'],
@@ -396,7 +419,7 @@ function buildTools() {
396
419
  },
397
420
  {
398
421
  name: 'health_check',
399
- description: `Run ${PROFILE.displayName} memory health diagnostics (count_chunks, expiry_alert, coverage_map, detect_conflicts)`,
422
+ description: `Run comprehensive ${PROFILE.displayName} memory system diagnostics. Returns chunk counts by type and visibility, identifies expiring short-term memories that need promotion, detects timeline coverage gaps, and finds conflicting or redundant long-term chunks. Use this regularly to maintain memory hygiene and before batch promotion.`,
400
423
  inputSchema: {
401
424
  type: 'object',
402
425
  properties: {
@@ -491,11 +514,11 @@ function buildTools() {
491
514
  {
492
515
  name: 'session_boot',
493
516
  description:
494
- 'One-call session boot rhythm: always run identity/workflow/status recall + expiry-focused health snapshot + heartbeat sign-in',
517
+ `Initialize a new ${PROFILE.displayName} session by loading identity, workflow context, and recent status from long-term memory. Also runs an expiry-focused health snapshot and records a heartbeat sign-in. Call this once at the start of every new conversation to restore continuity and awareness of pending work.`,
495
518
  inputSchema: {
496
519
  type: 'object',
497
520
  properties: {
498
- source: { type: 'string', enum: PROFILE.sourceWhitelist },
521
+ source: { type: 'string', enum: PROFILE.sourceWhitelist, description: 'Source tool identifier (e.g. "warp", "cursor", "perplexity") for provenance tracking' },
499
522
  body_name: {
500
523
  type: 'string',
501
524
  description: 'Optional body/persona name (例如:大家姐、三哥、五妹、Toto)'
@@ -528,7 +551,8 @@ function buildTools() {
528
551
  type: 'number',
529
552
  minimum: 1,
530
553
  maximum: 10,
531
- default: 5
554
+ default: 5,
555
+ description: 'Maximum recall results per category (identity/workflow/status, default 5)'
532
556
  },
533
557
  alert_window_hours: {
534
558
  type: 'number',
@@ -556,7 +580,7 @@ function buildTools() {
556
580
  {
557
581
  name: 'session_close',
558
582
  description:
559
- 'Store session close summary with 7-day retention for CoCo/Toto rhythm closure',
583
+ `Save a ${PROFILE.displayName} session close summary before ending a conversation. The summary is stored as a short-term memory with 7-day retention, providing context for the next session boot. Include what was accomplished, what is still pending, and any important context for continuity.`,
560
584
  inputSchema: {
561
585
  type: 'object',
562
586
  properties: {
@@ -581,24 +605,24 @@ function buildTools() {
581
605
  },
582
606
  {
583
607
  name: 'dream_ingest',
584
- description: `Chunk and ingest Hermes digest text into ${DB_PROFILE}.marsvault_chunks as long-term insight data`,
608
+ description: `Ingest a Hermes digest (periodic summary report) into ${DB_PROFILE}.marsvault_chunks long-term memory. The content is automatically chunked into semantically meaningful segments and stored with vector embeddings for future recall. Use this for scheduled reports, daily digests, or any structured summary content that should be permanently available.`,
585
609
  inputSchema: {
586
610
  type: 'object',
587
611
  properties: {
588
- content: { type: 'string', description: 'Digest full text content' },
589
- source_file: { type: 'string', description: 'Logical source path for digest' },
590
- section: { type: 'string', description: 'Section label prefix' },
612
+ content: { type: 'string', description: 'The full text content of the digest to ingest. Will be automatically split into semantically coherent chunks.' },
613
+ source_file: { type: 'string', description: 'Logical file path or identifier for the source of this digest (e.g. "hermes/daily/2026-06-10")' },
614
+ section: { type: 'string', description: 'Optional section label prefix to tag all resulting chunks' },
591
615
  tags: {
592
616
  type: 'array',
593
617
  items: { type: 'string' },
594
- description: 'Optional tags list'
618
+ description: 'Tags for categorization (e.g. ["digest", "daily", "report"])'
595
619
  },
596
- type: { type: 'string', description: 'Chunk type', default: 'digest' },
597
- date: { type: 'string', description: 'Optional YYYY-MM-DD date override' },
598
- body: { type: 'string', enum: PROFILE.recallBodyEnum, default: DB_PROFILE },
599
- visibility: { type: 'string', enum: ['private', 'shared', 'global'], default: 'private' },
600
- origin: { type: 'string', description: 'Origin marker', default: PROFILE.digestDefaultOrigin },
601
- max_chunk_chars: { type: 'number', minimum: 300, maximum: 3000, default: 1200 }
620
+ type: { type: 'string', description: 'Chunk type label', default: 'digest' },
621
+ date: { type: 'string', description: 'Date for this digest in YYYY-MM-DD format. Defaults to today if omitted.' },
622
+ body: { type: 'string', enum: PROFILE.recallBodyEnum, default: DB_PROFILE, description: 'Target persona profile for storage' },
623
+ visibility: { type: 'string', enum: ['private', 'shared', 'global'], default: 'private', description: 'Access level: "private" = owner only, "shared" = cross-profile, "global" = system-wide' },
624
+ origin: { type: 'string', description: 'Origin marker identifying where this content came from', default: PROFILE.digestDefaultOrigin },
625
+ max_chunk_chars: { type: 'number', minimum: 300, maximum: 3000, default: 1200, description: 'Maximum character length per chunk (default 1200). Larger values = fewer, longer chunks.' }
602
626
  },
603
627
  required: ['content'],
604
628
  additionalProperties: false
@@ -606,48 +630,48 @@ function buildTools() {
606
630
  },
607
631
  {
608
632
  name: PROFILE.memoryIngestToolName,
609
- description: `Chunk and ingest ${PROFILE.displayName} long-term insight content into ${DB_PROFILE}.marsvault_chunks (not short-memory)`,
633
+ description: `Promote content into permanent long-term memory (${DB_PROFILE}.marsvault_chunks). Automatically chunks the input text, generates vector embeddings, and stores each segment for semantic recall. Use this to preserve important insights, decisions, patterns, or knowledge that should survive beyond the current session. This is the primary path from ephemeral to permanent memory.`,
610
634
  inputSchema: {
611
635
  type: 'object',
612
636
  properties: {
613
- content: { type: 'string', description: 'Insight full text content' },
614
- source_file: { type: 'string', description: 'Logical source path for insight content' },
615
- section: { type: 'string', description: 'Section label prefix' },
637
+ content: { type: 'string', description: 'The insight content to promote to long-term memory. Be specific and self-contained — future recall depends on the quality of this text.' },
638
+ source_file: { type: 'string', description: 'Logical file path or identifier for the source (e.g. "sessions/2026-06-10-session-notes")' },
639
+ section: { type: 'string', description: 'Optional section label prefix to organize chunks within the source' },
616
640
  tags: {
617
641
  type: 'array',
618
642
  items: { type: 'string' },
619
- description: 'Optional tags list'
643
+ description: 'Categorization tags (e.g. ["decision", "architecture"])'
620
644
  },
621
- type: { type: 'string', description: 'Chunk type', default: 'insight' },
622
- date: { type: 'string', description: 'Optional YYYY-MM-DD date override' },
623
- visibility: { type: 'string', enum: ['private', 'shared', 'global'], default: 'private' },
645
+ type: { type: 'string', description: 'Content type label (e.g. "insight", "observation", "decision")', default: 'insight' },
646
+ date: { type: 'string', description: 'Date for this content in YYYY-MM-DD format. Defaults to today if omitted.' },
647
+ visibility: { type: 'string', enum: ['private', 'shared', 'global'], default: 'private', description: 'Access level: "private" = this profile only, "shared" = cross-profile readable, "global" = system-wide' },
624
648
  origin: buildMemoryIngestOriginSchema(),
625
649
  source_memory_id: {
626
650
  type: 'string',
627
- description: 'Optional short-memory id to link promoted long-memory chunks'
651
+ description: 'Link this promotion to a specific short-term memory ID (for traceability)'
628
652
  },
629
653
  source_session_id: {
630
654
  type: 'string',
631
- description: 'Optional source session id for provenance'
655
+ description: 'Session ID where this insight originated (for provenance tracking)'
632
656
  },
633
657
  source_tool: {
634
658
  type: 'string',
635
659
  enum: PROFILE.sourceWhitelist,
636
- description: 'Optional source tool for provenance'
660
+ description: 'The tool/platform where this insight was originally captured'
637
661
  },
638
662
  source_user_note: {
639
663
  type: 'string',
640
- description: 'Optional user note describing why this memory was promoted'
664
+ description: 'Brief note explaining why this memory was selected for promotion'
641
665
  },
642
666
  agent_body: {
643
667
  type: 'string',
644
- description: 'Optional body label for memory boundary'
668
+ description: 'The persona/body this memory belongs to (e.g. "coco", "toto")'
645
669
  },
646
670
  environment: {
647
671
  type: 'string',
648
- description: 'Optional environment label'
672
+ description: 'Environment label (e.g. "production", "staging")'
649
673
  },
650
- max_chunk_chars: { type: 'number', minimum: 300, maximum: 3000, default: 1200 }
674
+ max_chunk_chars: { type: 'number', minimum: 300, maximum: 3000, default: 1200, description: 'Maximum characters per chunk (default 1200). Adjust for finer or coarser granularity.' }
651
675
  },
652
676
  required: ['content'],
653
677
  additionalProperties: false
@@ -655,19 +679,19 @@ function buildTools() {
655
679
  },
656
680
  {
657
681
  name: 'demote_memory',
658
- description: `Mark a long-memory chunk as deprecated/superseded in ${DB_PROFILE}.marsvault_chunks`,
682
+ description: `Deprecate a long-term memory chunk in ${DB_PROFILE}.marsvault_chunks, marking it as outdated or superseded. The chunk is not deleted — it is flagged so it no longer appears in recall results. Use this when information becomes incorrect, irrelevant, or is replaced by newer knowledge. Optionally link to the replacement chunk for traceability.`,
659
683
  inputSchema: {
660
684
  type: 'object',
661
685
  properties: {
662
- id: { type: 'string', description: 'Long-memory chunk id (UUID)' },
663
- deprecated_reason: { type: 'string', description: 'Reason for deprecation' },
686
+ id: { type: 'string', description: 'UUID of the long-term memory chunk to deprecate' },
687
+ deprecated_reason: { type: 'string', description: 'Why this memory is being deprecated (e.g. "superseded by newer architecture decision", "information confirmed incorrect")' },
664
688
  superseded_by: {
665
689
  type: 'string',
666
- description: 'Optional replacement chunk id (UUID) when this memory is superseded'
690
+ description: 'UUID of the replacement chunk, if this memory is being superseded by updated content'
667
691
  },
668
692
  deprecated_at: {
669
693
  type: 'string',
670
- description: 'Optional ISO timestamp override; defaults to now'
694
+ description: 'Custom deprecation timestamp in ISO 8601 format. Defaults to now if omitted.'
671
695
  }
672
696
  },
673
697
  required: ['id', 'deprecated_reason'],
@@ -676,7 +700,7 @@ function buildTools() {
676
700
  },
677
701
  {
678
702
  name: 'soft_forget',
679
- description: `Expire short-memory rows early in ${DB_PROFILE}.memories (manual cleanup without hard delete)`,
703
+ description: `Expire short-term memories in ${DB_PROFILE}.memories early without permanently deleting them. Marked memories become invisible to search and list operations but remain in the database for audit purposes. Use this to clean up irrelevant or incorrect short-term memories before their natural expiration.`,
680
704
  inputSchema: {
681
705
  type: 'object',
682
706
  properties: {
@@ -685,15 +709,15 @@ function buildTools() {
685
709
  items: { type: 'string' },
686
710
  minItems: 1,
687
711
  maxItems: 50,
688
- description: 'Short-memory IDs (UUID) to expire immediately'
712
+ description: 'Array of short-term memory UUIDs to expire. Up to 50 at a time.'
689
713
  },
690
714
  reason: {
691
715
  type: 'string',
692
- description: 'Optional note for why these memories are being soft-forgotten'
716
+ description: 'Brief explanation of why these memories are being forgotten (for audit trail)'
693
717
  },
694
718
  forgotten_at: {
695
719
  type: 'string',
696
- description: 'Optional ISO timestamp override; defaults to now'
720
+ description: 'Custom expiration timestamp in ISO 8601 format. Defaults to now if omitted.'
697
721
  }
698
722
  },
699
723
  required: ['ids'],
@@ -702,20 +726,61 @@ function buildTools() {
702
726
  },
703
727
  {
704
728
  name: 'explain_memory',
705
- description: `Explain provenance of a long-memory chunk in ${DB_PROFILE}.marsvault_chunks (source memory/session/tool/timeline)`,
729
+ description: `Trace the full provenance of a long-term memory chunk in ${DB_PROFILE}.marsvault_chunks — where it came from, how it was created, and what source material it was derived from. Returns the original source memory, session, tool, and timeline information. Use this to understand why a piece of knowledge exists or to verify its reliability.`,
706
730
  inputSchema: {
707
731
  type: 'object',
708
732
  properties: {
709
- id: { type: 'string', description: 'Long-memory chunk id (UUID)' }
733
+ id: { type: 'string', description: 'UUID of the long-term memory chunk to explain' }
710
734
  },
711
735
  required: ['id'],
712
736
  additionalProperties: false
713
737
  }
738
+ },
739
+ {
740
+ name: 'batch_promote',
741
+ description: `Automatically promote expiring short-term memories from ${DB_PROFILE}.memories to permanent long-term storage (${DB_PROFILE}.marsvault_chunks). Scans for memories that will expire within the alert window, then chunks and ingests each one with vector embeddings. Use this to prevent valuable context from being lost when short-term memories expire. Supports dry-run mode to preview candidates before committing.`,
742
+ inputSchema: {
743
+ type: 'object',
744
+ properties: {
745
+ alert_window_hours: {
746
+ type: 'number',
747
+ minimum: 1,
748
+ maximum: 720,
749
+ default: 48,
750
+ description: 'Look-ahead window in hours. Memories expiring within this window are candidates for promotion (default 48).'
751
+ },
752
+ max_promote: {
753
+ type: 'number',
754
+ minimum: 1,
755
+ maximum: 50,
756
+ default: 10,
757
+ description: 'Maximum number of memories to promote in a single batch (default 10).'
758
+ },
759
+ dry_run: {
760
+ type: 'boolean',
761
+ default: false,
762
+ description: 'When true, list candidate memories without actually promoting them. Use to preview before committing.'
763
+ },
764
+ memory_ids: {
765
+ type: 'array',
766
+ items: { type: 'string' },
767
+ maxItems: 50,
768
+ description: 'Explicit list of short-term memory UUIDs to promote. When provided, auto-detection is skipped and only these IDs are processed.'
769
+ },
770
+ origin: {
771
+ type: 'string',
772
+ description: 'Origin marker tagged on all promoted chunks for traceability',
773
+ default: 'batch-promote'
774
+ }
775
+ },
776
+ additionalProperties: false
777
+ }
714
778
  }
715
779
  ];
716
780
  }
717
781
 
718
782
  const TOOLS = buildTools();
783
+ const TOOL_BY_NAME = new Map(TOOLS.map((tool) => [tool.name, tool]));
719
784
 
720
785
  const OAUTH_CLIENTS = new Map();
721
786
  const OAUTH_CODES = new Map();
@@ -876,7 +941,15 @@ function getServiceKey() {
876
941
  );
877
942
  }
878
943
 
879
- const SERVICE_KEY = getServiceKey();
944
+ const SERVICE_KEY = SUPABASE_AUTH_MODE === 'anon' ? '' : getServiceKey();
945
+ if (
946
+ (SUPABASE_AUTH_MODE === 'anon' || SUPABASE_AUTH_MODE === 'hybrid') &&
947
+ !SUPABASE_ANON_KEY
948
+ ) {
949
+ throw new Error(
950
+ 'SUPABASE_ANON_KEY is required when MCP_SUPABASE_AUTH_MODE is anon or hybrid'
951
+ );
952
+ }
880
953
  loadPersistedOauthClients();
881
954
 
882
955
  if (STATIC_CLIENT_ID && STATIC_CLIENT_SECRET && OAUTH_ENABLED) {
@@ -935,10 +1008,67 @@ async function readRequestBody(req) {
935
1008
  }
936
1009
  }
937
1010
 
1011
+ function resolveSupabaseRequestAuthMode() {
1012
+ const storeMode = String(SUPABASE_REQUEST_CONTEXT.getStore()?.db_auth_mode || '')
1013
+ .trim()
1014
+ .toLowerCase();
1015
+ if (SUPABASE_AUTH_MODE_VALUES.has(storeMode)) {
1016
+ return storeMode;
1017
+ }
1018
+ if (SUPABASE_AUTH_MODE === 'anon') {
1019
+ return 'anon';
1020
+ }
1021
+ return 'service';
1022
+ }
1023
+
1024
+ function isToolAllowedInAnonMode(toolName) {
1025
+ return ANON_ALLOWED_TOOL_NAMES.has(String(toolName || '').trim());
1026
+ }
1027
+
1028
+ function assertToolCapabilityForAuthMode(toolName) {
1029
+ if (SUPABASE_AUTH_MODE !== 'anon') return;
1030
+ if (isToolAllowedInAnonMode(toolName)) return;
1031
+ throw new Error(
1032
+ `capability_not_available_in_anon_mode: ${toolName} is disabled; use MCP_SUPABASE_AUTH_MODE=service or hybrid`
1033
+ );
1034
+ }
1035
+
1036
+ function resolveDbAuthModeForTool(toolName) {
1037
+ if (SUPABASE_AUTH_MODE === 'anon') return 'anon';
1038
+ if (SUPABASE_AUTH_MODE === 'service') return 'service';
1039
+ return isToolAllowedInAnonMode(toolName) ? 'anon' : 'service';
1040
+ }
1041
+ function listAvailableToolNamesForAuthMode(authMode) {
1042
+ if (authMode === 'anon') {
1043
+ return TOOLS
1044
+ .map((tool) => String(tool?.name || '').trim())
1045
+ .filter((name) => name && isToolAllowedInAnonMode(name));
1046
+ }
1047
+ return TOOLS.map((tool) => String(tool?.name || '').trim()).filter(Boolean);
1048
+ }
1049
+ function listAvailableToolsForAuthMode(authMode) {
1050
+ const names = listAvailableToolNamesForAuthMode(authMode);
1051
+ return names
1052
+ .map((name) => TOOL_BY_NAME.get(name))
1053
+ .filter((tool) => Boolean(tool));
1054
+ }
1055
+ function buildSupabaseCapabilityPayload() {
1056
+ return {
1057
+ mode: SUPABASE_AUTH_MODE,
1058
+ anon_allowed_tools: listAvailableToolNamesForAuthMode('anon'),
1059
+ effective_tools: listAvailableToolNamesForAuthMode(SUPABASE_AUTH_MODE)
1060
+ };
1061
+ }
1062
+
938
1063
  async function supabaseRequest(path, options = {}) {
1064
+ const requestAuthMode = resolveSupabaseRequestAuthMode();
1065
+ const requestApiKey = requestAuthMode === 'anon' ? SUPABASE_ANON_KEY : SERVICE_KEY;
1066
+ if (!requestApiKey) {
1067
+ throw new Error(`Supabase API key missing for auth mode: ${requestAuthMode}`);
1068
+ }
939
1069
  const headers = {
940
- apikey: SERVICE_KEY,
941
- Authorization: `Bearer ${SERVICE_KEY}`,
1070
+ apikey: requestApiKey,
1071
+ Authorization: `Bearer ${requestApiKey}`,
942
1072
  'content-type': 'application/json'
943
1073
  };
944
1074
  if (options.profile) {
@@ -1455,7 +1585,10 @@ async function writeToolUsageTelemetry(payload = {}) {
1455
1585
  new Date().toISOString();
1456
1586
  const agentBody =
1457
1587
  normalizeOptionalAgentBody(payload.agent_body) || DB_PROFILE;
1458
- try {
1588
+ if (SUPABASE_AUTH_MODE === 'anon') {
1589
+ return;
1590
+ }
1591
+ const writeTelemetry = async () => {
1459
1592
  await supabaseRequest(
1460
1593
  `/rest/v1/memory_tool_usage?select=${TOOL_USAGE_INSERT_SELECT_COLUMNS}`,
1461
1594
  {
@@ -1473,6 +1606,13 @@ async function writeToolUsageTelemetry(payload = {}) {
1473
1606
  ]
1474
1607
  }
1475
1608
  );
1609
+ };
1610
+ try {
1611
+ if (SUPABASE_AUTH_MODE === 'hybrid') {
1612
+ await SUPABASE_REQUEST_CONTEXT.run({ db_auth_mode: 'service' }, writeTelemetry);
1613
+ } else {
1614
+ await writeTelemetry();
1615
+ }
1476
1616
  } catch (error) {
1477
1617
  console.warn(
1478
1618
  `[telemetry] failed to persist memory_tool_usage: ${String(error?.message || error)}`
@@ -2717,6 +2857,156 @@ async function runHealthExpiryCheck(args = {}) {
2717
2857
  return payload;
2718
2858
  }
2719
2859
 
2860
+ async function runBatchPromote(args = {}) {
2861
+ const alertWindowHours = clampInteger(args.alert_window_hours, 1, 720, 48);
2862
+ const maxPromote = clampInteger(args.max_promote, 1, 50, 10);
2863
+ const dryRun = Boolean(args.dry_run);
2864
+ const origin = String(args.origin || 'batch-promote').trim() || 'batch-promote';
2865
+ const explicitIds = Array.isArray(args.memory_ids)
2866
+ ? args.memory_ids
2867
+ .map((id) => normalizeOptionalUuid(id, 'memory_ids'))
2868
+ .filter(Boolean)
2869
+ : [];
2870
+
2871
+ const candidates = [];
2872
+
2873
+ if (explicitIds.length > 0) {
2874
+ for (const memoryId of explicitIds.slice(0, maxPromote)) {
2875
+ const memory = await fetchMemoryById(memoryId);
2876
+ if (!memory) {
2877
+ candidates.push({ id: memoryId, status: 'not_found', skipped: true });
2878
+ continue;
2879
+ }
2880
+ if (memory.promoted || memory.promoted_at) {
2881
+ candidates.push({
2882
+ id: memoryId,
2883
+ status: 'already_promoted',
2884
+ skipped: true,
2885
+ excerpt: String(memory.body || '').slice(0, 140)
2886
+ });
2887
+ continue;
2888
+ }
2889
+ candidates.push({
2890
+ id: memoryId,
2891
+ status: 'eligible',
2892
+ skipped: false,
2893
+ excerpt: String(memory.body || '').slice(0, 140),
2894
+ tags: normalizeTags(memory.tags),
2895
+ expires_at: memory.expires_at || null,
2896
+ memory
2897
+ });
2898
+ }
2899
+ } else {
2900
+ const healthResult = await runHealthExpiryCheck({
2901
+ alert_window_hours: alertWindowHours
2902
+ });
2903
+ const soonExpiring = healthResult?.expiry_alert?.soon_expiring || [];
2904
+ const recommended = soonExpiring
2905
+ .filter((item) => item.recommend_promote === 'Y')
2906
+ .slice(0, maxPromote);
2907
+
2908
+ for (const item of recommended) {
2909
+ const memory = await fetchMemoryById(item.id);
2910
+ if (!memory) {
2911
+ candidates.push({ id: item.id, status: 'not_found', skipped: true });
2912
+ continue;
2913
+ }
2914
+ candidates.push({
2915
+ id: item.id,
2916
+ status: 'eligible',
2917
+ skipped: false,
2918
+ excerpt: item.excerpt || String(memory.body || '').slice(0, 140),
2919
+ tags: item.tags || normalizeTags(memory.tags),
2920
+ expires_at: item.expires_at || memory.expires_at || null,
2921
+ recommendation_reason: item.recommendation_reason || null,
2922
+ memory
2923
+ });
2924
+ }
2925
+ }
2926
+
2927
+ const eligible = candidates.filter((c) => !c.skipped);
2928
+
2929
+ if (dryRun) {
2930
+ return {
2931
+ ok: true,
2932
+ dry_run: true,
2933
+ profile: DB_PROFILE,
2934
+ origin,
2935
+ alert_window_hours: alertWindowHours,
2936
+ max_promote: maxPromote,
2937
+ total_candidates: candidates.length,
2938
+ eligible_count: eligible.length,
2939
+ skipped_count: candidates.length - eligible.length,
2940
+ candidates: candidates.map(({ memory, ...rest }) => rest)
2941
+ };
2942
+ }
2943
+
2944
+ const promoted = [];
2945
+ const errors = [];
2946
+
2947
+ for (const candidate of eligible) {
2948
+ try {
2949
+ const memory = candidate.memory;
2950
+ const content = String(memory.body || '').trim();
2951
+ if (!content) {
2952
+ errors.push({ id: candidate.id, error: 'empty memory body' });
2953
+ continue;
2954
+ }
2955
+
2956
+ const ingestResult = await ingestMarsvaultChunks(
2957
+ {
2958
+ content,
2959
+ tags: normalizeTags(memory.tags),
2960
+ source_memory_id: memory.id,
2961
+ source_session_id: memory.session_id || null,
2962
+ source_tool: memory.source || null,
2963
+ origin
2964
+ },
2965
+ {
2966
+ defaultType: 'insight',
2967
+ defaultSourceFile: PROFILE.memoryIngestDefaultSourceFile,
2968
+ defaultSectionPrefix: 'batch-promote',
2969
+ defaultOrigin: origin,
2970
+ body: DB_PROFILE,
2971
+ fixedTags: [...PROFILE.memoryIngestFixedTags, 'batch-promote']
2972
+ }
2973
+ );
2974
+
2975
+ const promotedMemory = await markMemoryAsPromoted(memory.id);
2976
+
2977
+ promoted.push({
2978
+ id: memory.id,
2979
+ chunk_count: ingestResult.chunk_count || 0,
2980
+ promoted_at: promotedMemory?.promoted_at || null,
2981
+ excerpt: String(content).slice(0, 140)
2982
+ });
2983
+ } catch (error) {
2984
+ errors.push({
2985
+ id: candidate.id,
2986
+ error: normalizeOptionalText(error?.message || String(error), 280)
2987
+ });
2988
+ }
2989
+ }
2990
+
2991
+ return {
2992
+ ok: true,
2993
+ dry_run: false,
2994
+ profile: DB_PROFILE,
2995
+ origin,
2996
+ alert_window_hours: alertWindowHours,
2997
+ max_promote: maxPromote,
2998
+ total_candidates: candidates.length,
2999
+ promoted_count: promoted.length,
3000
+ error_count: errors.length,
3001
+ skipped_count: candidates.length - eligible.length,
3002
+ promoted,
3003
+ errors: errors.length > 0 ? errors : undefined,
3004
+ skipped: candidates
3005
+ .filter((c) => c.skipped)
3006
+ .map(({ memory, ...rest }) => rest)
3007
+ };
3008
+ }
3009
+
2720
3010
  async function runHealthCheck(args = {}) {
2721
3011
  const alertWindowHours = clampInteger(args.alert_window_hours, 1, 720, 48);
2722
3012
  const gapDays = clampInteger(args.gap_days, 7, 365, 30);
@@ -3620,6 +3910,9 @@ async function callTool(name, args = {}) {
3620
3910
  };
3621
3911
  }
3622
3912
 
3913
+ if (toolName === 'batch_promote') {
3914
+ return await runBatchPromote(args);
3915
+ }
3623
3916
  if (toolName === 'health_check') {
3624
3917
  return await runHealthCheck(args);
3625
3918
  }
@@ -4237,6 +4530,8 @@ const server = http.createServer(async (req, res) => {
4237
4530
  embedding_provider: 'jina',
4238
4531
  embedding_model: JINA_EMBEDDING_MODEL,
4239
4532
  embedding_enabled: Boolean(JINA_API_KEY),
4533
+ supabase_auth_mode: SUPABASE_AUTH_MODE,
4534
+ supabase_capabilities: buildSupabaseCapabilityPayload(),
4240
4535
  source_mode: SOURCE_MODE,
4241
4536
  memory_sources: MEMORY_SOURCE_LIST.slice(),
4242
4537
  extra_sources: EXTRA_SOURCE_LIST.slice(),
@@ -4318,14 +4613,24 @@ const server = http.createServer(async (req, res) => {
4318
4613
  }
4319
4614
 
4320
4615
  if (method === 'tools/list') {
4321
- json(res, 200, mcpResult(id, { tools: TOOLS }));
4616
+ json(
4617
+ res,
4618
+ 200,
4619
+ mcpResult(id, { tools: listAvailableToolsForAuthMode(SUPABASE_AUTH_MODE) })
4620
+ );
4322
4621
  return;
4323
4622
  }
4324
4623
 
4325
4624
  if (method === 'tools/call') {
4326
- const toolName = rpc?.params?.name;
4625
+ const requestedToolName = rpc?.params?.name;
4626
+ const toolName = resolveToolName(requestedToolName);
4627
+ assertToolCapabilityForAuthMode(toolName);
4628
+ const dbAuthMode = resolveDbAuthModeForTool(toolName);
4327
4629
  const args = rpc?.params?.arguments ?? {};
4328
- const result = await callTool(toolName, args);
4630
+ const result = await SUPABASE_REQUEST_CONTEXT.run(
4631
+ { db_auth_mode: dbAuthMode },
4632
+ async () => await callTool(toolName, args)
4633
+ );
4329
4634
  json(
4330
4635
  res,
4331
4636
  200,