@marsnme/mcp-gateway 0.1.8 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/package.json +3 -2
  2. package/server.mjs +311 -7
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.0",
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"
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';
@@ -711,11 +734,52 @@ function buildTools() {
711
734
  required: ['id'],
712
735
  additionalProperties: false
713
736
  }
737
+ },
738
+ {
739
+ name: 'batch_promote',
740
+ description: `Auto-promote expiring short memories to long-term ${DB_PROFILE}.marsvault_chunks. Finds candidates via health check scoring, ingests content, marks promoted.`,
741
+ inputSchema: {
742
+ type: 'object',
743
+ properties: {
744
+ alert_window_hours: {
745
+ type: 'number',
746
+ minimum: 1,
747
+ maximum: 720,
748
+ default: 48,
749
+ description: 'Look-ahead window for expiring memories (hours)'
750
+ },
751
+ max_promote: {
752
+ type: 'number',
753
+ minimum: 1,
754
+ maximum: 50,
755
+ default: 10,
756
+ description: 'Maximum memories to promote in one batch'
757
+ },
758
+ dry_run: {
759
+ type: 'boolean',
760
+ default: false,
761
+ description: 'If true, list candidates without promoting'
762
+ },
763
+ memory_ids: {
764
+ type: 'array',
765
+ items: { type: 'string' },
766
+ maxItems: 50,
767
+ description: 'Optional explicit memory IDs to promote (skips auto-detection)'
768
+ },
769
+ origin: {
770
+ type: 'string',
771
+ description: 'Origin marker for promoted chunks',
772
+ default: 'batch-promote'
773
+ }
774
+ },
775
+ additionalProperties: false
776
+ }
714
777
  }
715
778
  ];
716
779
  }
717
780
 
718
781
  const TOOLS = buildTools();
782
+ const TOOL_BY_NAME = new Map(TOOLS.map((tool) => [tool.name, tool]));
719
783
 
720
784
  const OAUTH_CLIENTS = new Map();
721
785
  const OAUTH_CODES = new Map();
@@ -876,7 +940,15 @@ function getServiceKey() {
876
940
  );
877
941
  }
878
942
 
879
- const SERVICE_KEY = getServiceKey();
943
+ const SERVICE_KEY = SUPABASE_AUTH_MODE === 'anon' ? '' : getServiceKey();
944
+ if (
945
+ (SUPABASE_AUTH_MODE === 'anon' || SUPABASE_AUTH_MODE === 'hybrid') &&
946
+ !SUPABASE_ANON_KEY
947
+ ) {
948
+ throw new Error(
949
+ 'SUPABASE_ANON_KEY is required when MCP_SUPABASE_AUTH_MODE is anon or hybrid'
950
+ );
951
+ }
880
952
  loadPersistedOauthClients();
881
953
 
882
954
  if (STATIC_CLIENT_ID && STATIC_CLIENT_SECRET && OAUTH_ENABLED) {
@@ -935,10 +1007,67 @@ async function readRequestBody(req) {
935
1007
  }
936
1008
  }
937
1009
 
1010
+ function resolveSupabaseRequestAuthMode() {
1011
+ const storeMode = String(SUPABASE_REQUEST_CONTEXT.getStore()?.db_auth_mode || '')
1012
+ .trim()
1013
+ .toLowerCase();
1014
+ if (SUPABASE_AUTH_MODE_VALUES.has(storeMode)) {
1015
+ return storeMode;
1016
+ }
1017
+ if (SUPABASE_AUTH_MODE === 'anon') {
1018
+ return 'anon';
1019
+ }
1020
+ return 'service';
1021
+ }
1022
+
1023
+ function isToolAllowedInAnonMode(toolName) {
1024
+ return ANON_ALLOWED_TOOL_NAMES.has(String(toolName || '').trim());
1025
+ }
1026
+
1027
+ function assertToolCapabilityForAuthMode(toolName) {
1028
+ if (SUPABASE_AUTH_MODE !== 'anon') return;
1029
+ if (isToolAllowedInAnonMode(toolName)) return;
1030
+ throw new Error(
1031
+ `capability_not_available_in_anon_mode: ${toolName} is disabled; use MCP_SUPABASE_AUTH_MODE=service or hybrid`
1032
+ );
1033
+ }
1034
+
1035
+ function resolveDbAuthModeForTool(toolName) {
1036
+ if (SUPABASE_AUTH_MODE === 'anon') return 'anon';
1037
+ if (SUPABASE_AUTH_MODE === 'service') return 'service';
1038
+ return isToolAllowedInAnonMode(toolName) ? 'anon' : 'service';
1039
+ }
1040
+ function listAvailableToolNamesForAuthMode(authMode) {
1041
+ if (authMode === 'anon') {
1042
+ return TOOLS
1043
+ .map((tool) => String(tool?.name || '').trim())
1044
+ .filter((name) => name && isToolAllowedInAnonMode(name));
1045
+ }
1046
+ return TOOLS.map((tool) => String(tool?.name || '').trim()).filter(Boolean);
1047
+ }
1048
+ function listAvailableToolsForAuthMode(authMode) {
1049
+ const names = listAvailableToolNamesForAuthMode(authMode);
1050
+ return names
1051
+ .map((name) => TOOL_BY_NAME.get(name))
1052
+ .filter((tool) => Boolean(tool));
1053
+ }
1054
+ function buildSupabaseCapabilityPayload() {
1055
+ return {
1056
+ mode: SUPABASE_AUTH_MODE,
1057
+ anon_allowed_tools: listAvailableToolNamesForAuthMode('anon'),
1058
+ effective_tools: listAvailableToolNamesForAuthMode(SUPABASE_AUTH_MODE)
1059
+ };
1060
+ }
1061
+
938
1062
  async function supabaseRequest(path, options = {}) {
1063
+ const requestAuthMode = resolveSupabaseRequestAuthMode();
1064
+ const requestApiKey = requestAuthMode === 'anon' ? SUPABASE_ANON_KEY : SERVICE_KEY;
1065
+ if (!requestApiKey) {
1066
+ throw new Error(`Supabase API key missing for auth mode: ${requestAuthMode}`);
1067
+ }
939
1068
  const headers = {
940
- apikey: SERVICE_KEY,
941
- Authorization: `Bearer ${SERVICE_KEY}`,
1069
+ apikey: requestApiKey,
1070
+ Authorization: `Bearer ${requestApiKey}`,
942
1071
  'content-type': 'application/json'
943
1072
  };
944
1073
  if (options.profile) {
@@ -1455,7 +1584,10 @@ async function writeToolUsageTelemetry(payload = {}) {
1455
1584
  new Date().toISOString();
1456
1585
  const agentBody =
1457
1586
  normalizeOptionalAgentBody(payload.agent_body) || DB_PROFILE;
1458
- try {
1587
+ if (SUPABASE_AUTH_MODE === 'anon') {
1588
+ return;
1589
+ }
1590
+ const writeTelemetry = async () => {
1459
1591
  await supabaseRequest(
1460
1592
  `/rest/v1/memory_tool_usage?select=${TOOL_USAGE_INSERT_SELECT_COLUMNS}`,
1461
1593
  {
@@ -1473,6 +1605,13 @@ async function writeToolUsageTelemetry(payload = {}) {
1473
1605
  ]
1474
1606
  }
1475
1607
  );
1608
+ };
1609
+ try {
1610
+ if (SUPABASE_AUTH_MODE === 'hybrid') {
1611
+ await SUPABASE_REQUEST_CONTEXT.run({ db_auth_mode: 'service' }, writeTelemetry);
1612
+ } else {
1613
+ await writeTelemetry();
1614
+ }
1476
1615
  } catch (error) {
1477
1616
  console.warn(
1478
1617
  `[telemetry] failed to persist memory_tool_usage: ${String(error?.message || error)}`
@@ -2717,6 +2856,156 @@ async function runHealthExpiryCheck(args = {}) {
2717
2856
  return payload;
2718
2857
  }
2719
2858
 
2859
+ async function runBatchPromote(args = {}) {
2860
+ const alertWindowHours = clampInteger(args.alert_window_hours, 1, 720, 48);
2861
+ const maxPromote = clampInteger(args.max_promote, 1, 50, 10);
2862
+ const dryRun = Boolean(args.dry_run);
2863
+ const origin = String(args.origin || 'batch-promote').trim() || 'batch-promote';
2864
+ const explicitIds = Array.isArray(args.memory_ids)
2865
+ ? args.memory_ids
2866
+ .map((id) => normalizeOptionalUuid(id, 'memory_ids'))
2867
+ .filter(Boolean)
2868
+ : [];
2869
+
2870
+ const candidates = [];
2871
+
2872
+ if (explicitIds.length > 0) {
2873
+ for (const memoryId of explicitIds.slice(0, maxPromote)) {
2874
+ const memory = await fetchMemoryById(memoryId);
2875
+ if (!memory) {
2876
+ candidates.push({ id: memoryId, status: 'not_found', skipped: true });
2877
+ continue;
2878
+ }
2879
+ if (memory.promoted || memory.promoted_at) {
2880
+ candidates.push({
2881
+ id: memoryId,
2882
+ status: 'already_promoted',
2883
+ skipped: true,
2884
+ excerpt: String(memory.body || '').slice(0, 140)
2885
+ });
2886
+ continue;
2887
+ }
2888
+ candidates.push({
2889
+ id: memoryId,
2890
+ status: 'eligible',
2891
+ skipped: false,
2892
+ excerpt: String(memory.body || '').slice(0, 140),
2893
+ tags: normalizeTags(memory.tags),
2894
+ expires_at: memory.expires_at || null,
2895
+ memory
2896
+ });
2897
+ }
2898
+ } else {
2899
+ const healthResult = await runHealthExpiryCheck({
2900
+ alert_window_hours: alertWindowHours
2901
+ });
2902
+ const soonExpiring = healthResult?.expiry_alert?.soon_expiring || [];
2903
+ const recommended = soonExpiring
2904
+ .filter((item) => item.recommend_promote === 'Y')
2905
+ .slice(0, maxPromote);
2906
+
2907
+ for (const item of recommended) {
2908
+ const memory = await fetchMemoryById(item.id);
2909
+ if (!memory) {
2910
+ candidates.push({ id: item.id, status: 'not_found', skipped: true });
2911
+ continue;
2912
+ }
2913
+ candidates.push({
2914
+ id: item.id,
2915
+ status: 'eligible',
2916
+ skipped: false,
2917
+ excerpt: item.excerpt || String(memory.body || '').slice(0, 140),
2918
+ tags: item.tags || normalizeTags(memory.tags),
2919
+ expires_at: item.expires_at || memory.expires_at || null,
2920
+ recommendation_reason: item.recommendation_reason || null,
2921
+ memory
2922
+ });
2923
+ }
2924
+ }
2925
+
2926
+ const eligible = candidates.filter((c) => !c.skipped);
2927
+
2928
+ if (dryRun) {
2929
+ return {
2930
+ ok: true,
2931
+ dry_run: true,
2932
+ profile: DB_PROFILE,
2933
+ origin,
2934
+ alert_window_hours: alertWindowHours,
2935
+ max_promote: maxPromote,
2936
+ total_candidates: candidates.length,
2937
+ eligible_count: eligible.length,
2938
+ skipped_count: candidates.length - eligible.length,
2939
+ candidates: candidates.map(({ memory, ...rest }) => rest)
2940
+ };
2941
+ }
2942
+
2943
+ const promoted = [];
2944
+ const errors = [];
2945
+
2946
+ for (const candidate of eligible) {
2947
+ try {
2948
+ const memory = candidate.memory;
2949
+ const content = String(memory.body || '').trim();
2950
+ if (!content) {
2951
+ errors.push({ id: candidate.id, error: 'empty memory body' });
2952
+ continue;
2953
+ }
2954
+
2955
+ const ingestResult = await ingestMarsvaultChunks(
2956
+ {
2957
+ content,
2958
+ tags: normalizeTags(memory.tags),
2959
+ source_memory_id: memory.id,
2960
+ source_session_id: memory.session_id || null,
2961
+ source_tool: memory.source || null,
2962
+ origin
2963
+ },
2964
+ {
2965
+ defaultType: 'insight',
2966
+ defaultSourceFile: PROFILE.memoryIngestDefaultSourceFile,
2967
+ defaultSectionPrefix: 'batch-promote',
2968
+ defaultOrigin: origin,
2969
+ body: DB_PROFILE,
2970
+ fixedTags: [...PROFILE.memoryIngestFixedTags, 'batch-promote']
2971
+ }
2972
+ );
2973
+
2974
+ const promotedMemory = await markMemoryAsPromoted(memory.id);
2975
+
2976
+ promoted.push({
2977
+ id: memory.id,
2978
+ chunk_count: ingestResult.chunk_count || 0,
2979
+ promoted_at: promotedMemory?.promoted_at || null,
2980
+ excerpt: String(content).slice(0, 140)
2981
+ });
2982
+ } catch (error) {
2983
+ errors.push({
2984
+ id: candidate.id,
2985
+ error: normalizeOptionalText(error?.message || String(error), 280)
2986
+ });
2987
+ }
2988
+ }
2989
+
2990
+ return {
2991
+ ok: true,
2992
+ dry_run: false,
2993
+ profile: DB_PROFILE,
2994
+ origin,
2995
+ alert_window_hours: alertWindowHours,
2996
+ max_promote: maxPromote,
2997
+ total_candidates: candidates.length,
2998
+ promoted_count: promoted.length,
2999
+ error_count: errors.length,
3000
+ skipped_count: candidates.length - eligible.length,
3001
+ promoted,
3002
+ errors: errors.length > 0 ? errors : undefined,
3003
+ skipped: candidates
3004
+ .filter((c) => c.skipped)
3005
+ .map(({ memory, ...rest }) => rest)
3006
+ };
3007
+ }
3008
+
2720
3009
  async function runHealthCheck(args = {}) {
2721
3010
  const alertWindowHours = clampInteger(args.alert_window_hours, 1, 720, 48);
2722
3011
  const gapDays = clampInteger(args.gap_days, 7, 365, 30);
@@ -3620,6 +3909,9 @@ async function callTool(name, args = {}) {
3620
3909
  };
3621
3910
  }
3622
3911
 
3912
+ if (toolName === 'batch_promote') {
3913
+ return await runBatchPromote(args);
3914
+ }
3623
3915
  if (toolName === 'health_check') {
3624
3916
  return await runHealthCheck(args);
3625
3917
  }
@@ -4237,6 +4529,8 @@ const server = http.createServer(async (req, res) => {
4237
4529
  embedding_provider: 'jina',
4238
4530
  embedding_model: JINA_EMBEDDING_MODEL,
4239
4531
  embedding_enabled: Boolean(JINA_API_KEY),
4532
+ supabase_auth_mode: SUPABASE_AUTH_MODE,
4533
+ supabase_capabilities: buildSupabaseCapabilityPayload(),
4240
4534
  source_mode: SOURCE_MODE,
4241
4535
  memory_sources: MEMORY_SOURCE_LIST.slice(),
4242
4536
  extra_sources: EXTRA_SOURCE_LIST.slice(),
@@ -4318,14 +4612,24 @@ const server = http.createServer(async (req, res) => {
4318
4612
  }
4319
4613
 
4320
4614
  if (method === 'tools/list') {
4321
- json(res, 200, mcpResult(id, { tools: TOOLS }));
4615
+ json(
4616
+ res,
4617
+ 200,
4618
+ mcpResult(id, { tools: listAvailableToolsForAuthMode(SUPABASE_AUTH_MODE) })
4619
+ );
4322
4620
  return;
4323
4621
  }
4324
4622
 
4325
4623
  if (method === 'tools/call') {
4326
- const toolName = rpc?.params?.name;
4624
+ const requestedToolName = rpc?.params?.name;
4625
+ const toolName = resolveToolName(requestedToolName);
4626
+ assertToolCapabilityForAuthMode(toolName);
4627
+ const dbAuthMode = resolveDbAuthModeForTool(toolName);
4327
4628
  const args = rpc?.params?.arguments ?? {};
4328
- const result = await callTool(toolName, args);
4629
+ const result = await SUPABASE_REQUEST_CONTEXT.run(
4630
+ { db_auth_mode: dbAuthMode },
4631
+ async () => await callTool(toolName, args)
4632
+ );
4329
4633
  json(
4330
4634
  res,
4331
4635
  200,