@marsnme/mcp-gateway 0.1.7 → 0.1.8
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 +1 -1
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
|
|