@marsnme/mcp-gateway 0.1.6 → 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 CHANGED
@@ -39,7 +39,11 @@ After startup:
39
39
 
40
40
  ## Common optional environment variables
41
41
  - `PORT`
42
- - Overrides default port (`18790`)
42
+ - Overrides the HTTP port used by the gateway
43
+ - If omitted, the gateway resolves a profile-based default:
44
+ - `coco` → `18790`
45
+ - `toto` → `18791`
46
+ - other profile IDs → deterministic port in `20000-29999`
43
47
  - `MCP_REQUIRE_BEARER`
44
48
  - Set `true` to require `Authorization: Bearer <token>` on MCP calls
45
49
  - `MCP_OAUTH_ENABLED`
@@ -60,6 +64,22 @@ curl -sS http://127.0.0.1:18790/mcp \
60
64
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}'
61
65
  ```
62
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
+
63
83
  ## Database setup and full onboarding
64
84
  For migrations and full setup details:
65
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.6",
3
+ "version": "0.1.8",
4
4
  "mcpName": "io.github.Marsmanleo/marsnme",
5
5
  "private": false,
6
6
  "description": "Agent-agnostic, LLM-agnostic memory backend for MCP-compatible tools",
@@ -14,10 +14,19 @@
14
14
  "keywords": [
15
15
  "mcp",
16
16
  "memory",
17
- "ai",
18
- "symbiosis",
17
+ "ai-memory",
18
+ "persistent-memory",
19
+ "ai-agent",
20
+ "model-context-protocol",
21
+ "semantic-search",
22
+ "supabase",
23
+ "jina",
19
24
  "embedding",
20
- "supabase"
25
+ "typescript",
26
+ "session-memory",
27
+ "cross-session",
28
+ "llm",
29
+ "symbiosis"
21
30
  ],
22
31
  "bin": {
23
32
  "marsnme": "server.mjs"
package/server.mjs CHANGED
@@ -45,6 +45,19 @@ const PROFILE_CONFIGS = {
45
45
  memoryIngestFixedTags: ['toto', 'insight']
46
46
  }
47
47
  };
48
+ const PROFILE_DEFAULT_PORT_RANGE_START = 20000;
49
+ const PROFILE_DEFAULT_PORT_RANGE_SIZE = 10000;
50
+ const PROFILE_LEGACY_DEFAULT_PORTS = {
51
+ coco: 18790,
52
+ toto: 18791
53
+ };
54
+ function resolveProfileDefaultPort(profileId) {
55
+ const legacyPort = PROFILE_LEGACY_DEFAULT_PORTS[profileId];
56
+ if (Number.isInteger(legacyPort)) return legacyPort;
57
+ const hash = crypto.createHash('sha256').update(profileId).digest();
58
+ const slot = hash.readUInt16BE(0) % PROFILE_DEFAULT_PORT_RANGE_SIZE;
59
+ return PROFILE_DEFAULT_PORT_RANGE_START + slot;
60
+ }
48
61
  const PROFILE_ID_PATTERN = /^[a-z][a-z0-9_-]*$/;
49
62
  function buildProfileConfig(profileId) {
50
63
  const legacyProfile = PROFILE_CONFIGS[profileId];
@@ -53,7 +66,7 @@ function buildProfileConfig(profileId) {
53
66
  ...PROFILE_CONFIGS.coco,
54
67
  schema: profileId,
55
68
  displayName: profileId,
56
- defaultPort: 18790,
69
+ defaultPort: resolveProfileDefaultPort(profileId),
57
70
  gatewayDir: `${profileId}-mcp-gateway`,
58
71
  publicHostSuffix: `${profileId}-mcp.marsgroup.asia`,
59
72
  recallBodyEnum: [profileId, 'system'],
@@ -151,7 +164,12 @@ function setMemorySourceWhitelist(nextSources) {
151
164
  }
152
165
  setMemorySourceWhitelist(buildMemorySourceListForMode());
153
166
 
154
- const PORT = Number.parseInt(process.env.PORT || '18790', 10);
167
+ const PORT_RAW = String(process.env.PORT || '').trim();
168
+ const PORT_SOURCE = PORT_RAW ? 'env' : 'profile-default';
169
+ const PORT = Number.parseInt(PORT_RAW || String(PROFILE.defaultPort), 10);
170
+ if (!Number.isInteger(PORT) || PORT < 1 || PORT > 65535) {
171
+ throw new Error(`Invalid PORT: ${PORT_RAW || String(PORT)}. Must be an integer between 1 and 65535.`);
172
+ }
155
173
  const SUPABASE_BASE_URL = process.env.SUPABASE_BASE_URL || 'http://127.0.0.1:8100';
156
174
  const OAUTH_ENABLED = process.env.MCP_OAUTH_ENABLED !== 'false';
157
175
  const REQUIRE_BEARER = process.env.MCP_REQUIRE_BEARER === 'true';
@@ -4336,8 +4354,27 @@ const server = http.createServer(async (req, res) => {
4336
4354
  }
4337
4355
  });
4338
4356
 
4357
+ server.on('error', (error) => {
4358
+ if (error?.code === 'EADDRINUSE') {
4359
+ const hint =
4360
+ PORT_SOURCE === 'env'
4361
+ ? `Set PORT to an unused value (current PORT=${PORT}).`
4362
+ : `Set PORT to an unused value or use another MCP_PROFILE (profile=${MCP_PROFILE}, default_port=${PROFILE.defaultPort}).`;
4363
+ console.error(`[fatal] ${SERVER_NAME} cannot start: port ${PORT} is already in use. ${hint}`);
4364
+ process.exit(1);
4365
+ return;
4366
+ }
4367
+ console.error(
4368
+ `[fatal] ${SERVER_NAME} failed to start: ${String(error?.message || error)}`
4369
+ );
4370
+ process.exit(1);
4371
+ });
4372
+
4339
4373
  server.listen(PORT, '0.0.0.0', () => {
4340
4374
  console.log(`${SERVER_NAME} listening on 0.0.0.0:${PORT}`);
4375
+ console.log(
4376
+ `[config] profile=${MCP_PROFILE} resolved_port=${PORT} source=${PORT_SOURCE} profile_default_port=${PROFILE.defaultPort}`
4377
+ );
4341
4378
  console.log(`[config] source_mode=${SOURCE_MODE}`);
4342
4379
  if (SOURCE_MODE === 'registry') {
4343
4380
  void reloadSourceRegistryCache()