@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 +16 -0
- package/deploy/phase2/pre_deploy_schema_gate.sh +186 -0
- package/deploy/phase3/smoke_gate.sh +84 -8
- package/package.json +3 -2
- package/server.mjs +311 -7
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
|
-
|
|
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
|
|
166
|
-
|
|
167
|
-
|
|
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
|
-
|
|
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
|
|
177
|
-
|
|
178
|
-
|
|
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
|
-
|
|
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:
|
|
941
|
-
Authorization: `Bearer ${
|
|
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
|
-
|
|
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(
|
|
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
|
|
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
|
|
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,
|