@marsnme/mcp-gateway 0.1.7 → 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.
package/README.md CHANGED
@@ -64,6 +64,22 @@ curl -sS http://127.0.0.1:18790/mcp \
64
64
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}'
65
65
  ```
66
66
 
67
+ ## Deployment safety gate (before restart)
68
+ For upgrades, run database migrations with an explicit DDL-capable role and gate schema compatibility before restarting services.
69
+
70
+ Recommended role guidance:
71
+ - Use a role that can execute DDL on target schemas.
72
+ - On Supabase-hosted Postgres this is typically `supabase_admin` (not `postgres`).
73
+
74
+ Schema gate command:
75
+ ```bash
76
+ bash deploy/phase2/pre_deploy_schema_gate.sh \
77
+ --db-url "<postgres://supabase_admin:<password>@<host>:5432/postgres>" \
78
+ --profiles coco,toto \
79
+ --expected-role supabase_admin
80
+ ```
81
+ - If gate exits non-zero, stop deployment and do not restart services.
82
+
67
83
  ## Database setup and full onboarding
68
84
  For migrations and full setup details:
69
85
  - Repository README: https://github.com/Marsmanleo/MarsNMe/blob/main/README.md
@@ -0,0 +1,186 @@
1
+ #!/usr/bin/env bash
2
+
3
+ set -euo pipefail
4
+
5
+ DB_URL="${SUPABASE_DB_URL:-}"
6
+ PROFILES="coco,toto"
7
+ EXPECTED_ROLE="${MIGRATION_EXPECTED_ROLE:-supabase_admin}"
8
+ SKIP_ROLE_CHECK="false"
9
+
10
+ usage() {
11
+ cat <<EOF
12
+ usage: $0 --db-url <postgres-url> [--profiles <comma-separated>] [--expected-role <role>] [--skip-role-check]
13
+
14
+ examples:
15
+ $0 --db-url "<postgres-connection-string>"
16
+ $0 --db-url "<postgres-connection-string>" --profiles coco,toto --expected-role supabase_admin
17
+ $0 --db-url "<postgres-connection-string>" --profiles profile-a --expected-role app_owner
18
+ $0 --db-url "<postgres-connection-string>" --profiles profile-a --skip-role-check
19
+
20
+ notes:
21
+ - run this gate before any service restart in deployment flow
22
+ - exit code 0 means schema is compatible with current gateway code
23
+ - exit code 1 means compatibility gate failed (do NOT restart)
24
+ EOF
25
+ }
26
+
27
+ while [[ $# -gt 0 ]]; do
28
+ case "$1" in
29
+ --db-url)
30
+ DB_URL="$2"
31
+ shift 2
32
+ ;;
33
+ --profiles)
34
+ PROFILES="$2"
35
+ shift 2
36
+ ;;
37
+ --expected-role)
38
+ EXPECTED_ROLE="$2"
39
+ shift 2
40
+ ;;
41
+ --skip-role-check)
42
+ SKIP_ROLE_CHECK="true"
43
+ shift
44
+ ;;
45
+ -h|--help)
46
+ usage
47
+ exit 0
48
+ ;;
49
+ *)
50
+ echo "unknown arg: $1" >&2
51
+ usage >&2
52
+ exit 2
53
+ ;;
54
+ esac
55
+ done
56
+
57
+ if ! command -v psql >/dev/null 2>&1; then
58
+ echo "psql not found in PATH; install PostgreSQL client tools first" >&2
59
+ exit 2
60
+ fi
61
+
62
+ if [[ -z "${DB_URL}" ]]; then
63
+ echo "--db-url is required (or set SUPABASE_DB_URL env)" >&2
64
+ exit 2
65
+ fi
66
+
67
+ escape_sql_literal() {
68
+ printf "%s" "$1" | sed "s/'/''/g"
69
+ }
70
+
71
+ quote_ident() {
72
+ local raw="$1"
73
+ printf '"%s"' "${raw//\"/\"\"}"
74
+ }
75
+
76
+ run_sql() {
77
+ local sql="$1"
78
+ psql "${DB_URL}" --set ON_ERROR_STOP=1 --no-psqlrc --tuples-only --no-align --quiet -c "${sql}"
79
+ }
80
+
81
+ PASS_COUNT=0
82
+ FAIL_COUNT=0
83
+
84
+ log_pass() {
85
+ printf '[PASS] %s\n' "$1"
86
+ PASS_COUNT=$((PASS_COUNT + 1))
87
+ }
88
+
89
+ log_fail() {
90
+ printf '[FAIL] %s\n' "$1" >&2
91
+ FAIL_COUNT=$((FAIL_COUNT + 1))
92
+ }
93
+
94
+ check_sql() {
95
+ local label="$1"
96
+ local sql="$2"
97
+ local output
98
+ if output="$(run_sql "${sql}" 2>&1)"; then
99
+ log_pass "${label}"
100
+ return
101
+ fi
102
+ output="$(printf '%s' "${output}" | tr '\n' ' ' | sed -E 's/[[:space:]]+/ /g')"
103
+ log_fail "${label} :: ${output}"
104
+ }
105
+
106
+ PROFILE_LIST=()
107
+ IFS=',' read -ra RAW_PROFILES <<< "${PROFILES}"
108
+ for raw_profile in "${RAW_PROFILES[@]}"; do
109
+ profile="$(printf '%s' "${raw_profile}" | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')"
110
+ if [[ -z "${profile}" ]]; then
111
+ continue
112
+ fi
113
+ if [[ ! "${profile}" =~ ^[a-z][a-z0-9_-]*$ ]]; then
114
+ echo "invalid profile id: ${profile} (must match ^[a-z][a-z0-9_-]*$)" >&2
115
+ exit 2
116
+ fi
117
+ PROFILE_LIST+=("${profile}")
118
+ done
119
+
120
+ if [[ ${#PROFILE_LIST[@]} -eq 0 ]]; then
121
+ echo "no valid profiles found from --profiles input: ${PROFILES}" >&2
122
+ exit 2
123
+ fi
124
+
125
+ CURRENT_ROLE="$(run_sql "SELECT current_user;" | tr -d '[:space:]')"
126
+ if [[ -z "${CURRENT_ROLE}" ]]; then
127
+ echo "failed to resolve current_user from database" >&2
128
+ exit 2
129
+ fi
130
+
131
+ printf '[INFO] pre-deploy schema gate start\n'
132
+ printf '[INFO] target profiles=%s\n' "$(IFS=,; printf '%s' "${PROFILE_LIST[*]}")"
133
+ printf '[INFO] connected role=%s\n' "${CURRENT_ROLE}"
134
+
135
+ if [[ "${SKIP_ROLE_CHECK}" == "true" ]]; then
136
+ printf '[INFO] migration role check skipped by --skip-role-check\n'
137
+ elif [[ -n "${EXPECTED_ROLE}" ]]; then
138
+ expected_role_lit="$(escape_sql_literal "${EXPECTED_ROLE}")"
139
+ role_status="$(run_sql "SELECT CASE WHEN current_user = '${expected_role_lit}' OR pg_has_role(current_user, '${expected_role_lit}', 'member') THEN 'ok' ELSE 'mismatch' END;" | tr -d '[:space:]' || true)"
140
+ if [[ "${role_status}" == "ok" ]]; then
141
+ log_pass "migration role check (expected=${EXPECTED_ROLE})"
142
+ else
143
+ log_fail "migration role check failed (expected=${EXPECTED_ROLE}, got=${CURRENT_ROLE})"
144
+ fi
145
+ else
146
+ printf '[INFO] migration role check disabled (empty expected role)\n'
147
+ fi
148
+
149
+ for profile in "${PROFILE_LIST[@]}"; do
150
+ profile_lit="$(escape_sql_literal "${profile}")"
151
+ profile_ident="$(quote_ident "${profile}")"
152
+
153
+ check_sql "${profile}.memories required columns" \
154
+ "SELECT id,body,source,session_id,tags,agent_body,environment,promoted,promoted_at,created_at,expires_at FROM ${profile_ident}.memories LIMIT 0;"
155
+
156
+ check_sql "${profile}.marsvault_chunks required columns" \
157
+ "SELECT id,content,source_file,section,body,visibility,tags,type,date,origin,source_memory_id,source_session_id,source_tool,source_user_note,agent_body,environment,created_at,updated_at FROM ${profile_ident}.marsvault_chunks LIMIT 0;"
158
+
159
+ check_sql "${profile}.memories DDL ownership check" \
160
+ "DO \$\$ DECLARE owner_oid oid; BEGIN SELECT c.relowner INTO owner_oid FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace WHERE n.nspname = '${profile_lit}' AND c.relname = 'memories' AND c.relkind = 'r'; IF owner_oid IS NULL THEN RAISE EXCEPTION 'table %.% is missing', '${profile_lit}', 'memories'; END IF; IF NOT ((SELECT rolsuper FROM pg_roles WHERE rolname = current_user) OR owner_oid = (SELECT oid FROM pg_roles WHERE rolname = current_user) OR pg_has_role(current_user, owner_oid, 'MEMBER')) THEN RAISE EXCEPTION 'current_user % cannot ALTER %.memories (owner=%)', current_user, '${profile_lit}', pg_get_userbyid(owner_oid); END IF; END \$\$;"
161
+
162
+ check_sql "${profile}.marsvault_chunks DDL ownership check" \
163
+ "DO \$\$ DECLARE owner_oid oid; BEGIN SELECT c.relowner INTO owner_oid FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace WHERE n.nspname = '${profile_lit}' AND c.relname = 'marsvault_chunks' AND c.relkind = 'r'; IF owner_oid IS NULL THEN RAISE EXCEPTION 'table %.% is missing', '${profile_lit}', 'marsvault_chunks'; END IF; IF NOT ((SELECT rolsuper FROM pg_roles WHERE rolname = current_user) OR owner_oid = (SELECT oid FROM pg_roles WHERE rolname = current_user) OR pg_has_role(current_user, owner_oid, 'MEMBER')) THEN RAISE EXCEPTION 'current_user % cannot ALTER %.marsvault_chunks (owner=%)', current_user, '${profile_lit}', pg_get_userbyid(owner_oid); END IF; END \$\$;"
164
+
165
+ check_sql "${profile}.search_memories_semantic(v2) exists" \
166
+ "DO \$\$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_proc p JOIN pg_namespace n ON n.oid = p.pronamespace WHERE n.nspname = '${profile_lit}' AND p.proname = 'search_memories_semantic' AND p.pronargs = 7) THEN RAISE EXCEPTION 'missing function %.search_memories_semantic(text, integer, text, boolean, text, text, text)', '${profile_lit}'; END IF; END \$\$;"
167
+
168
+ check_sql "${profile}.search_marsvault_chunks_semantic(v2) exists" \
169
+ "DO \$\$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_proc p JOIN pg_namespace n ON n.oid = p.pronamespace WHERE n.nspname = '${profile_lit}' AND p.proname = 'search_marsvault_chunks_semantic' AND p.pronargs = 10) THEN RAISE EXCEPTION 'missing function %.search_marsvault_chunks_semantic(text, integer, text, boolean, boolean, boolean, text, text, text, text)', '${profile_lit}'; END IF; END \$\$;"
170
+ done
171
+
172
+ LATEST_MIGRATION_VERSION="$(run_sql "SELECT version FROM supabase_migrations.schema_migrations ORDER BY version DESC LIMIT 1;" 2>/dev/null | tr -d '[:space:]' || true)"
173
+ if [[ -n "${LATEST_MIGRATION_VERSION}" ]]; then
174
+ printf '[INFO] latest schema_migrations.version=%s\n' "${LATEST_MIGRATION_VERSION}"
175
+ else
176
+ printf '[WARN] schema_migrations version not available (table missing or inaccessible)\n' >&2
177
+ fi
178
+
179
+ printf '[INFO] pre-deploy schema gate summary: pass=%d fail=%d\n' "${PASS_COUNT}" "${FAIL_COUNT}"
180
+
181
+ if [[ "${FAIL_COUNT}" -gt 0 ]]; then
182
+ printf '[ERROR] schema gate failed; do NOT restart services until all checks pass\n' >&2
183
+ exit 1
184
+ fi
185
+
186
+ printf '[OK] schema gate passed; safe to continue deployment restart flow\n'
@@ -159,23 +159,99 @@ TOTO_TOOL_CALL_JSON="$(mcp_jsonrpc "${TOTO_URL}" '{"jsonrpc":"2.0","id":"toto-li
159
159
 
160
160
  COCO_CALL_CHECK="$(CALL_PAYLOAD="${COCO_TOOL_CALL_JSON}" node -e '
161
161
  const payload = JSON.parse(process.env.CALL_PAYLOAD || "{}");
162
- if (payload.error) throw new Error("tools/call error: " + JSON.stringify(payload.error));
162
+ const clip = (value, max = 800) => {
163
+ const text = String(value ?? "");
164
+ return text.length > max ? text.slice(0, max) + "...(truncated)" : text;
165
+ };
166
+ const extractErrorText = (value) => {
167
+ if (value == null) return "";
168
+ if (typeof value === "string") return value;
169
+ if (typeof value === "number" || typeof value === "boolean") return String(value);
170
+ if (Array.isArray(value)) return value.map(extractErrorText).filter(Boolean).join(" | ");
171
+ if (typeof value === "object") {
172
+ if (typeof value.message === "string" && value.message) return value.message;
173
+ if (typeof value.error === "string" && value.error) return value.error;
174
+ if (value.error) {
175
+ const nested = extractErrorText(value.error);
176
+ if (nested) return nested;
177
+ }
178
+ if (value.details) {
179
+ const details = extractErrorText(value.details);
180
+ if (details) return details;
181
+ }
182
+ return JSON.stringify(value);
183
+ }
184
+ return String(value);
185
+ };
186
+ if (payload.error) throw new Error("tools/call error: " + clip(JSON.stringify(payload.error)));
163
187
  const content = payload.result?.content;
164
188
  if (!Array.isArray(content) || content.length === 0) throw new Error("tools/call missing content");
165
- const text = content[0]?.text ?? "";
166
- const parsed = JSON.parse(text);
167
- if (parsed.ok !== true) throw new Error("list_memories response missing ok=true");
189
+ const textItems = content
190
+ .map((item) => (typeof item?.text === "string" ? item.text.trim() : ""))
191
+ .filter(Boolean);
192
+ if (payload.result?.isError === true) {
193
+ throw new Error("tools/call returned isError=true; detail=" + clip(textItems.join(" | ")));
194
+ }
195
+ const firstText = textItems[0] || "";
196
+ let parsed;
197
+ try {
198
+ parsed = JSON.parse(firstText);
199
+ } catch (err) {
200
+ throw new Error("tools/call first content is not valid JSON: " + clip(firstText));
201
+ }
202
+ if (parsed.ok !== true) {
203
+ const inner = extractErrorText(parsed.error) || extractErrorText(parsed.details) || extractErrorText(parsed.message) || clip(JSON.stringify(parsed));
204
+ throw new Error("list_memories returned ok!=true; detail=" + clip(inner));
205
+ }
168
206
  process.stdout.write(JSON.stringify({ count: parsed.count ?? null }));
169
207
  ')"
170
208
 
171
209
  TOTO_CALL_CHECK="$(CALL_PAYLOAD="${TOTO_TOOL_CALL_JSON}" node -e '
172
210
  const payload = JSON.parse(process.env.CALL_PAYLOAD || "{}");
173
- if (payload.error) throw new Error("tools/call error: " + JSON.stringify(payload.error));
211
+ const clip = (value, max = 800) => {
212
+ const text = String(value ?? "");
213
+ return text.length > max ? text.slice(0, max) + "...(truncated)" : text;
214
+ };
215
+ const extractErrorText = (value) => {
216
+ if (value == null) return "";
217
+ if (typeof value === "string") return value;
218
+ if (typeof value === "number" || typeof value === "boolean") return String(value);
219
+ if (Array.isArray(value)) return value.map(extractErrorText).filter(Boolean).join(" | ");
220
+ if (typeof value === "object") {
221
+ if (typeof value.message === "string" && value.message) return value.message;
222
+ if (typeof value.error === "string" && value.error) return value.error;
223
+ if (value.error) {
224
+ const nested = extractErrorText(value.error);
225
+ if (nested) return nested;
226
+ }
227
+ if (value.details) {
228
+ const details = extractErrorText(value.details);
229
+ if (details) return details;
230
+ }
231
+ return JSON.stringify(value);
232
+ }
233
+ return String(value);
234
+ };
235
+ if (payload.error) throw new Error("tools/call error: " + clip(JSON.stringify(payload.error)));
174
236
  const content = payload.result?.content;
175
237
  if (!Array.isArray(content) || content.length === 0) throw new Error("tools/call missing content");
176
- const text = content[0]?.text ?? "";
177
- const parsed = JSON.parse(text);
178
- if (parsed.ok !== true) throw new Error("list_memories response missing ok=true");
238
+ const textItems = content
239
+ .map((item) => (typeof item?.text === "string" ? item.text.trim() : ""))
240
+ .filter(Boolean);
241
+ if (payload.result?.isError === true) {
242
+ throw new Error("tools/call returned isError=true; detail=" + clip(textItems.join(" | ")));
243
+ }
244
+ const firstText = textItems[0] || "";
245
+ let parsed;
246
+ try {
247
+ parsed = JSON.parse(firstText);
248
+ } catch (err) {
249
+ throw new Error("tools/call first content is not valid JSON: " + clip(firstText));
250
+ }
251
+ if (parsed.ok !== true) {
252
+ const inner = extractErrorText(parsed.error) || extractErrorText(parsed.details) || extractErrorText(parsed.message) || clip(JSON.stringify(parsed));
253
+ throw new Error("list_memories returned ok!=true; detail=" + clip(inner));
254
+ }
179
255
  process.stdout.write(JSON.stringify({ count: parsed.count ?? null }));
180
256
  ')"
181
257
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@marsnme/mcp-gateway",
3
- "version": "0.1.7",
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,