@mgsoftwarebv/mg-dashboard-mcp 2.0.0 → 2.0.4
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/dist/index.js +401 -31
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -24,6 +24,133 @@ if (!supabaseUrl || !supabaseKey) {
|
|
|
24
24
|
process.exit(1);
|
|
25
25
|
}
|
|
26
26
|
var supabase = createClient(supabaseUrl, supabaseKey);
|
|
27
|
+
var RateLimiter = class {
|
|
28
|
+
buckets = /* @__PURE__ */ new Map();
|
|
29
|
+
maxAttempts;
|
|
30
|
+
windowMs;
|
|
31
|
+
constructor(maxAttempts, windowMs) {
|
|
32
|
+
this.maxAttempts = maxAttempts;
|
|
33
|
+
this.windowMs = windowMs;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Check if an action is allowed for the given key.
|
|
37
|
+
* Increments the counter and returns whether the action should proceed.
|
|
38
|
+
*/
|
|
39
|
+
check(key) {
|
|
40
|
+
const now = Date.now();
|
|
41
|
+
const entry = this.buckets.get(key);
|
|
42
|
+
if (!entry || now >= entry.resetAt) {
|
|
43
|
+
this.buckets.set(key, { count: 1, resetAt: now + this.windowMs });
|
|
44
|
+
return { allowed: true, remaining: this.maxAttempts - 1, retryAfterMs: 0 };
|
|
45
|
+
}
|
|
46
|
+
entry.count++;
|
|
47
|
+
if (entry.count > this.maxAttempts) {
|
|
48
|
+
return {
|
|
49
|
+
allowed: false,
|
|
50
|
+
remaining: 0,
|
|
51
|
+
retryAfterMs: entry.resetAt - now
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
return {
|
|
55
|
+
allowed: true,
|
|
56
|
+
remaining: this.maxAttempts - entry.count,
|
|
57
|
+
retryAfterMs: 0
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
/** Periodically remove expired entries to prevent unbounded growth. */
|
|
61
|
+
cleanup() {
|
|
62
|
+
const now = Date.now();
|
|
63
|
+
for (const [key, entry] of this.buckets) {
|
|
64
|
+
if (now >= entry.resetAt) this.buckets.delete(key);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
var authRateLimiter = new RateLimiter(5, 15 * 60 * 1e3);
|
|
69
|
+
setInterval(() => {
|
|
70
|
+
authRateLimiter.cleanup();
|
|
71
|
+
}, 5 * 60 * 1e3).unref();
|
|
72
|
+
var MODULE_KEYS = [
|
|
73
|
+
"users",
|
|
74
|
+
"emails",
|
|
75
|
+
"logs",
|
|
76
|
+
"ssh_servers",
|
|
77
|
+
"supabase",
|
|
78
|
+
"wiki",
|
|
79
|
+
"ci_cd",
|
|
80
|
+
"source_control",
|
|
81
|
+
"domains",
|
|
82
|
+
"google_search_console",
|
|
83
|
+
"settings"
|
|
84
|
+
];
|
|
85
|
+
var FULL_PERMISSIONS = {
|
|
86
|
+
modules: Object.fromEntries(MODULE_KEYS.map((k) => [k, true])),
|
|
87
|
+
resources: { ssh_servers: ["*"], supabase_instances: ["*"] }
|
|
88
|
+
};
|
|
89
|
+
function parsePermissions(raw) {
|
|
90
|
+
if (!raw || typeof raw !== "object") return null;
|
|
91
|
+
return raw;
|
|
92
|
+
}
|
|
93
|
+
function resolvePermissions(roleName, roleDefaults, userOverrides) {
|
|
94
|
+
if (roleName === "superadmin") return FULL_PERMISSIONS;
|
|
95
|
+
const base = parsePermissions(roleDefaults);
|
|
96
|
+
const overrides = parsePermissions(userOverrides);
|
|
97
|
+
const modules = {};
|
|
98
|
+
for (const key of MODULE_KEYS) {
|
|
99
|
+
const userVal = overrides?.modules?.[key];
|
|
100
|
+
const roleVal = base?.modules?.[key];
|
|
101
|
+
modules[key] = userVal !== void 0 ? userVal : roleVal !== void 0 ? roleVal : false;
|
|
102
|
+
}
|
|
103
|
+
const resources = {
|
|
104
|
+
ssh_servers: overrides?.resources?.ssh_servers ?? base?.resources?.ssh_servers ?? [],
|
|
105
|
+
supabase_instances: overrides?.resources?.supabase_instances ?? base?.resources?.supabase_instances ?? []
|
|
106
|
+
};
|
|
107
|
+
return { modules, resources };
|
|
108
|
+
}
|
|
109
|
+
function intersectServerAccess(keyServerIds, permissionServerIds) {
|
|
110
|
+
const keyHasRestriction = keyServerIds !== null;
|
|
111
|
+
const permWildcard = permissionServerIds.includes("*");
|
|
112
|
+
const permEmpty = permissionServerIds.length === 0;
|
|
113
|
+
if (!keyHasRestriction && permWildcard) return null;
|
|
114
|
+
if (!keyHasRestriction && permEmpty) return [];
|
|
115
|
+
if (!keyHasRestriction) return permissionServerIds;
|
|
116
|
+
if (permWildcard) return keyServerIds;
|
|
117
|
+
if (permEmpty) return [];
|
|
118
|
+
return keyServerIds.filter((id) => permissionServerIds.includes(id));
|
|
119
|
+
}
|
|
120
|
+
var TOOL_MODULE_MAP = {
|
|
121
|
+
"list-servers": "ssh_servers",
|
|
122
|
+
"server-status": "ssh_servers",
|
|
123
|
+
"ssh-execute": "ssh_servers",
|
|
124
|
+
"server-reboot": "ssh_servers",
|
|
125
|
+
"server-restart-service": "ssh_servers",
|
|
126
|
+
"sftp-list": "ssh_servers",
|
|
127
|
+
"sftp-read": "ssh_servers",
|
|
128
|
+
"sftp-write": "ssh_servers",
|
|
129
|
+
"sftp-delete": "ssh_servers",
|
|
130
|
+
"docker-list": "ssh_servers",
|
|
131
|
+
"docker-action": "ssh_servers",
|
|
132
|
+
"docker-logs": "ssh_servers",
|
|
133
|
+
"db-discover": "ssh_servers",
|
|
134
|
+
"db-tables": "ssh_servers",
|
|
135
|
+
"db-describe": "ssh_servers",
|
|
136
|
+
"db-query": "ssh_servers",
|
|
137
|
+
"cache-purge": "ssh_servers",
|
|
138
|
+
"log-list": "ssh_servers",
|
|
139
|
+
"log-read": "ssh_servers",
|
|
140
|
+
"cron-list": "ssh_servers",
|
|
141
|
+
"cron-add": "ssh_servers",
|
|
142
|
+
"cron-remove": "ssh_servers",
|
|
143
|
+
"cron-toggle": "ssh_servers",
|
|
144
|
+
"env-list": "ci_cd",
|
|
145
|
+
"env-get": "ci_cd",
|
|
146
|
+
"env-store": "ci_cd",
|
|
147
|
+
"domain-list": "domains",
|
|
148
|
+
"domain-get": "domains",
|
|
149
|
+
"dns-list": "domains",
|
|
150
|
+
"dns-create": "domains",
|
|
151
|
+
"dns-update": "domains",
|
|
152
|
+
"dns-delete": "domains"
|
|
153
|
+
};
|
|
27
154
|
var authContext = null;
|
|
28
155
|
async function validateApiKey(key) {
|
|
29
156
|
if (!key.startsWith("dk_") || key.length !== 67) {
|
|
@@ -31,20 +158,42 @@ async function validateApiKey(key) {
|
|
|
31
158
|
return null;
|
|
32
159
|
}
|
|
33
160
|
const keyHash = createHash("sha256").update(key).digest("hex");
|
|
161
|
+
const rateCheck = authRateLimiter.check(keyHash);
|
|
162
|
+
if (!rateCheck.allowed) {
|
|
163
|
+
const retryMin = Math.ceil(rateCheck.retryAfterMs / 6e4);
|
|
164
|
+
console.error(`Rate limited: too many failed auth attempts. Retry in ${retryMin} minute(s).`);
|
|
165
|
+
return null;
|
|
166
|
+
}
|
|
34
167
|
const { data, error } = await supabase.from("dashboard_mcp_api_key").select("id, created_by, allowed_server_ids, is_active, expires_at").eq("api_key_hash", keyHash).eq("is_active", true).single();
|
|
35
168
|
if (error || !data) {
|
|
36
|
-
console.error(
|
|
169
|
+
console.error(`API key not found or inactive (${rateCheck.remaining} attempts remaining)`);
|
|
37
170
|
return null;
|
|
38
171
|
}
|
|
39
172
|
if (data.expires_at && new Date(data.expires_at) < /* @__PURE__ */ new Date()) {
|
|
40
|
-
console.error(
|
|
173
|
+
console.error(`API key has expired (${rateCheck.remaining} attempts remaining)`);
|
|
174
|
+
return null;
|
|
175
|
+
}
|
|
176
|
+
const { data: userData, error: userError } = await supabase.from("user").select("permissions, role:role!role_id(name, default_permissions)").eq("id", data.created_by).single();
|
|
177
|
+
if (userError || !userData) {
|
|
178
|
+
console.error(`User not found for API key creator: ${data.created_by}`);
|
|
41
179
|
return null;
|
|
42
180
|
}
|
|
181
|
+
const roleName = userData.role?.name || "user";
|
|
182
|
+
const roleDefaults = userData.role?.default_permissions ?? {};
|
|
183
|
+
const userOverrides = userData.permissions ?? null;
|
|
184
|
+
const permissions = resolvePermissions(roleName, roleDefaults, userOverrides);
|
|
185
|
+
const allowedServerIds = intersectServerAccess(
|
|
186
|
+
data.allowed_server_ids,
|
|
187
|
+
permissions.resources.ssh_servers
|
|
188
|
+
);
|
|
43
189
|
await supabase.from("dashboard_mcp_api_key").update({ last_used_at: (/* @__PURE__ */ new Date()).toISOString() }).eq("id", data.id);
|
|
44
|
-
|
|
190
|
+
const moduleCount = MODULE_KEYS.filter((k) => permissions.modules[k]).length;
|
|
191
|
+
console.error(`Authenticated as user ${data.created_by} (role: ${roleName}, modules: ${moduleCount}/${MODULE_KEYS.length})`);
|
|
45
192
|
return {
|
|
46
193
|
userId: data.created_by,
|
|
47
|
-
allowedServerIds
|
|
194
|
+
allowedServerIds,
|
|
195
|
+
permissions,
|
|
196
|
+
roleName
|
|
48
197
|
};
|
|
49
198
|
}
|
|
50
199
|
function assertServerAccess(serverId) {
|
|
@@ -54,6 +203,38 @@ function assertServerAccess(serverId) {
|
|
|
54
203
|
throw new Error(`Access denied: you do not have permission for server ${serverId}`);
|
|
55
204
|
}
|
|
56
205
|
}
|
|
206
|
+
async function resolveReleaseProfileStageIds(profileName) {
|
|
207
|
+
const { data: profile, error } = await supabase.from("release_profile").select("id, name").ilike("name", profileName).maybeSingle();
|
|
208
|
+
if (error) throw new Error(`Failed to look up release profile: ${error.message}`);
|
|
209
|
+
if (!profile) {
|
|
210
|
+
const { data: all } = await supabase.from("release_profile").select("name").order("name");
|
|
211
|
+
const names = (all || []).map((p) => p.name).join(", ");
|
|
212
|
+
throw new Error(
|
|
213
|
+
`Release profile "${profileName}" not found. Available profiles: ${names || "(none)"}`
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
const { data: stages, error: stageErr } = await supabase.from("release_profile_stage").select("id").eq("release_profile_id", profile.id);
|
|
217
|
+
if (stageErr) throw new Error(`Failed to look up stages: ${stageErr.message}`);
|
|
218
|
+
if (!stages || stages.length === 0) {
|
|
219
|
+
throw new Error(`Release profile "${profile.name}" has no stages configured`);
|
|
220
|
+
}
|
|
221
|
+
return { stageIds: stages.map((s) => s.id), profileId: profile.id };
|
|
222
|
+
}
|
|
223
|
+
async function getProfileNamesForStageIds(stageIds) {
|
|
224
|
+
if (stageIds.length === 0) return {};
|
|
225
|
+
const { data: stages } = await supabase.from("release_profile_stage").select("id, release_profile_id").in("id", stageIds);
|
|
226
|
+
if (!stages || stages.length === 0) return {};
|
|
227
|
+
const profileIds = [...new Set(stages.map((s) => s.release_profile_id))];
|
|
228
|
+
const { data: profiles } = await supabase.from("release_profile").select("id, name").in("id", profileIds);
|
|
229
|
+
if (!profiles) return {};
|
|
230
|
+
const profileMap = {};
|
|
231
|
+
for (const p of profiles) profileMap[p.id] = p.name;
|
|
232
|
+
const result = {};
|
|
233
|
+
for (const s of stages) {
|
|
234
|
+
result[s.id] = profileMap[s.release_profile_id] || "unknown";
|
|
235
|
+
}
|
|
236
|
+
return result;
|
|
237
|
+
}
|
|
57
238
|
var ENC_ALGORITHM = "aes-256-gcm";
|
|
58
239
|
var ENC_IV_LENGTH = 16;
|
|
59
240
|
var ENC_TAG_LENGTH = 16;
|
|
@@ -88,6 +269,120 @@ function decrypt(payload) {
|
|
|
88
269
|
decrypted += decipher.final("utf8");
|
|
89
270
|
return decrypted;
|
|
90
271
|
}
|
|
272
|
+
var VERCEL_API = "https://api.vercel.com";
|
|
273
|
+
function parseEnvContent(content) {
|
|
274
|
+
const result = {};
|
|
275
|
+
for (const line of content.split("\n")) {
|
|
276
|
+
const trimmed = line.trim();
|
|
277
|
+
if (!trimmed || trimmed.startsWith("#")) continue;
|
|
278
|
+
const eqIdx = trimmed.indexOf("=");
|
|
279
|
+
if (eqIdx === -1) continue;
|
|
280
|
+
const key = trimmed.slice(0, eqIdx).trim();
|
|
281
|
+
let value = trimmed.slice(eqIdx + 1).trim();
|
|
282
|
+
if (value.startsWith('"') && value.endsWith('"') || value.startsWith("'") && value.endsWith("'")) {
|
|
283
|
+
value = value.slice(1, -1);
|
|
284
|
+
}
|
|
285
|
+
if (key) result[key] = value;
|
|
286
|
+
}
|
|
287
|
+
return result;
|
|
288
|
+
}
|
|
289
|
+
function stageToVercelTargets(stageType, customEnvId) {
|
|
290
|
+
if (stageType === "prod") return { target: ["production"] };
|
|
291
|
+
if (customEnvId) return { customEnvironmentIds: [customEnvId] };
|
|
292
|
+
return { target: ["preview"] };
|
|
293
|
+
}
|
|
294
|
+
async function syncEnvVarsToVercel(token, projectId, envVars) {
|
|
295
|
+
if (envVars.length === 0) return { created: 0, error: null };
|
|
296
|
+
const res = await fetch(
|
|
297
|
+
`${VERCEL_API}/v10/projects/${encodeURIComponent(projectId)}/env?upsert=true`,
|
|
298
|
+
{
|
|
299
|
+
method: "POST",
|
|
300
|
+
headers: {
|
|
301
|
+
Authorization: `Bearer ${token}`,
|
|
302
|
+
"Content-Type": "application/json"
|
|
303
|
+
},
|
|
304
|
+
body: JSON.stringify(envVars)
|
|
305
|
+
}
|
|
306
|
+
);
|
|
307
|
+
if (!res.ok) {
|
|
308
|
+
const body = await res.text().catch(() => "");
|
|
309
|
+
return { created: 0, error: `Vercel API ${res.status}: ${body}` };
|
|
310
|
+
}
|
|
311
|
+
const data = await res.json().catch(() => ({}));
|
|
312
|
+
return { created: data?.created?.length ?? envVars.length, error: null };
|
|
313
|
+
}
|
|
314
|
+
async function attemptVercelSync(appName, environment) {
|
|
315
|
+
try {
|
|
316
|
+
const { data: direct } = await supabase.from("env_config").select("release_profile_stage_id").eq("app_name", appName).not("release_profile_stage_id", "is", null).limit(1).single();
|
|
317
|
+
const stageId = direct?.release_profile_stage_id;
|
|
318
|
+
if (!stageId) return "Vercel sync skipped: no stage link found";
|
|
319
|
+
const { data: settings } = await supabase.from("app_setting").select("vercel_token_encrypted").maybeSingle();
|
|
320
|
+
if (!settings?.vercel_token_encrypted) return "Vercel sync skipped: no Vercel token configured";
|
|
321
|
+
let token;
|
|
322
|
+
try {
|
|
323
|
+
token = decrypt(settings.vercel_token_encrypted);
|
|
324
|
+
} catch {
|
|
325
|
+
return "Vercel sync failed: could not decrypt Vercel token";
|
|
326
|
+
}
|
|
327
|
+
const { data: stage } = await supabase.from("release_profile_stage").select("id, stage, stage_apps").eq("id", stageId).single();
|
|
328
|
+
if (!stage) return "Vercel sync skipped: stage not found";
|
|
329
|
+
const stageType = stage.stage;
|
|
330
|
+
const stageApps = stage.stage_apps || [];
|
|
331
|
+
const vercelApps = stageApps.filter(
|
|
332
|
+
(a) => a.deployMethod === "vercel" && a.enabled && a.vercelProjectId
|
|
333
|
+
);
|
|
334
|
+
if (vercelApps.length === 0) return "Vercel sync skipped: no Vercel apps in stage";
|
|
335
|
+
const { data: envConfigs } = await supabase.from("env_config").select("*").eq("release_profile_stage_id", stageId);
|
|
336
|
+
if (!envConfigs || envConfigs.length === 0) return "Vercel sync skipped: no env configs for stage";
|
|
337
|
+
const variantMap = {
|
|
338
|
+
dev: "development",
|
|
339
|
+
staging: "staging",
|
|
340
|
+
prod: "production"
|
|
341
|
+
};
|
|
342
|
+
const deployedVariant = variantMap[stageType] ?? stageType;
|
|
343
|
+
const syncResults = [];
|
|
344
|
+
for (const app of vercelApps) {
|
|
345
|
+
const name = app.path.replace("apps/", "");
|
|
346
|
+
const config = envConfigs.find(
|
|
347
|
+
(c) => c.app_name === name && c.variant === deployedVariant
|
|
348
|
+
);
|
|
349
|
+
if (!config) {
|
|
350
|
+
syncResults.push(`${app.label}: skipped (no config for variant "${deployedVariant}")`);
|
|
351
|
+
continue;
|
|
352
|
+
}
|
|
353
|
+
let envContent;
|
|
354
|
+
try {
|
|
355
|
+
envContent = decrypt(config.env_data_encrypted);
|
|
356
|
+
} catch {
|
|
357
|
+
syncResults.push(`${app.label}: decrypt failed`);
|
|
358
|
+
continue;
|
|
359
|
+
}
|
|
360
|
+
const pairs = parseEnvContent(envContent);
|
|
361
|
+
const keys = Object.keys(pairs);
|
|
362
|
+
if (keys.length === 0) {
|
|
363
|
+
syncResults.push(`${app.label}: empty config`);
|
|
364
|
+
continue;
|
|
365
|
+
}
|
|
366
|
+
const targeting = stageToVercelTargets(stageType, app.vercelCustomEnvId);
|
|
367
|
+
const envVars = keys.map((key) => ({
|
|
368
|
+
key,
|
|
369
|
+
value: pairs[key],
|
|
370
|
+
type: "encrypted",
|
|
371
|
+
...targeting
|
|
372
|
+
}));
|
|
373
|
+
const { created, error } = await syncEnvVarsToVercel(token, app.vercelProjectId, envVars);
|
|
374
|
+
if (error) {
|
|
375
|
+
syncResults.push(`${app.label}: FAILED - ${error}`);
|
|
376
|
+
} else {
|
|
377
|
+
syncResults.push(`${app.label}: ${created} vars synced`);
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
return `Vercel sync: ${syncResults.join("; ")}`;
|
|
381
|
+
} catch (err) {
|
|
382
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
383
|
+
return `Vercel sync error: ${msg}`;
|
|
384
|
+
}
|
|
385
|
+
}
|
|
91
386
|
async function getServerConnection(serverId) {
|
|
92
387
|
assertServerAccess(serverId);
|
|
93
388
|
const { data, error } = await supabase.from("ssh_server").select("hostname, port, username, password_encrypted, ssh_key_encrypted, ssh_key_passphrase_encrypted").eq("id", serverId).single();
|
|
@@ -794,8 +1089,13 @@ var TOOLS = [
|
|
|
794
1089
|
},
|
|
795
1090
|
{
|
|
796
1091
|
name: "env-list",
|
|
797
|
-
description: "List all stored environment configurations
|
|
798
|
-
inputSchema: {
|
|
1092
|
+
description: "List all stored environment configurations with their release profile names.",
|
|
1093
|
+
inputSchema: {
|
|
1094
|
+
type: "object",
|
|
1095
|
+
properties: {
|
|
1096
|
+
releaseProfile: { type: "string", description: "Release profile name to filter by (usually matches the project folder name or git repo name, e.g. prefabaanbouw). Omit to list all." }
|
|
1097
|
+
}
|
|
1098
|
+
}
|
|
799
1099
|
},
|
|
800
1100
|
{
|
|
801
1101
|
name: "env-get",
|
|
@@ -804,7 +1104,8 @@ var TOOLS = [
|
|
|
804
1104
|
type: "object",
|
|
805
1105
|
properties: {
|
|
806
1106
|
appName: { type: "string", description: "Application name (e.g. backoffice, api, web)" },
|
|
807
|
-
environment: { type: "string", description: "Environment name (e.g. production, staging, development)" }
|
|
1107
|
+
environment: { type: "string", description: "Environment name (e.g. production, staging, development, local)" },
|
|
1108
|
+
releaseProfile: { type: "string", description: "Release profile name (usually matches the project folder name or git repo name, e.g. prefabaanbouw). Required when multiple profiles exist. Use env-list to discover available profiles." }
|
|
808
1109
|
},
|
|
809
1110
|
required: ["appName", "environment"]
|
|
810
1111
|
}
|
|
@@ -816,9 +1117,10 @@ var TOOLS = [
|
|
|
816
1117
|
type: "object",
|
|
817
1118
|
properties: {
|
|
818
1119
|
appName: { type: "string", description: "Application name (e.g. backoffice, api, web)" },
|
|
819
|
-
environment: { type: "string", description: "Environment name (e.g. production, staging, development)" },
|
|
1120
|
+
environment: { type: "string", description: "Environment name (e.g. production, staging, development, local)" },
|
|
820
1121
|
content: { type: "string", description: "The .env file content to store" },
|
|
821
|
-
description: { type: "string", description: "Optional description" }
|
|
1122
|
+
description: { type: "string", description: "Optional description" },
|
|
1123
|
+
releaseProfile: { type: "string", description: "Release profile name (usually matches the project folder name or git repo name, e.g. prefabaanbouw). Required when multiple profiles exist. Use env-list to discover available profiles." }
|
|
822
1124
|
},
|
|
823
1125
|
required: ["appName", "environment", "content"]
|
|
824
1126
|
}
|
|
@@ -992,13 +1294,30 @@ var server = new Server(
|
|
|
992
1294
|
{ name: "mg-dashboard-mcp", version: "1.7.0" },
|
|
993
1295
|
{ capabilities: { tools: {} } }
|
|
994
1296
|
);
|
|
995
|
-
server.setRequestHandler(ListToolsRequestSchema, async () =>
|
|
1297
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
1298
|
+
if (!authContext) return { tools: TOOLS };
|
|
1299
|
+
const accessible = TOOLS.filter((tool) => {
|
|
1300
|
+
const requiredModule = TOOL_MODULE_MAP[tool.name];
|
|
1301
|
+
if (!requiredModule) return true;
|
|
1302
|
+
return authContext.permissions.modules[requiredModule] === true;
|
|
1303
|
+
});
|
|
1304
|
+
return { tools: accessible };
|
|
1305
|
+
});
|
|
996
1306
|
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
997
1307
|
if (!authContext) {
|
|
998
1308
|
return { content: [{ type: "text", text: "Error: not authenticated" }] };
|
|
999
1309
|
}
|
|
1000
1310
|
const { name, arguments: toolArgs } = request.params;
|
|
1001
1311
|
const a = toolArgs || {};
|
|
1312
|
+
const requiredModule = TOOL_MODULE_MAP[name];
|
|
1313
|
+
if (requiredModule && authContext.permissions.modules[requiredModule] !== true) {
|
|
1314
|
+
return {
|
|
1315
|
+
content: [{
|
|
1316
|
+
type: "text",
|
|
1317
|
+
text: `Access denied: you do not have permission for the "${requiredModule}" module (tool: ${name})`
|
|
1318
|
+
}]
|
|
1319
|
+
};
|
|
1320
|
+
}
|
|
1002
1321
|
try {
|
|
1003
1322
|
switch (name) {
|
|
1004
1323
|
// ----- Servers -----
|
|
@@ -1135,44 +1454,95 @@ ${result.stderr}`);
|
|
|
1135
1454
|
}
|
|
1136
1455
|
// ----- Env Config -----
|
|
1137
1456
|
case "env-list": {
|
|
1138
|
-
|
|
1457
|
+
let query = supabase.from("env_config").select("id, app_name, environment, description, updated_at, release_profile_stage_id").order("app_name").order("environment");
|
|
1458
|
+
if (a.releaseProfile) {
|
|
1459
|
+
const { stageIds: stageIds2 } = await resolveReleaseProfileStageIds(String(a.releaseProfile));
|
|
1460
|
+
query = query.in("release_profile_stage_id", stageIds2);
|
|
1461
|
+
}
|
|
1462
|
+
const { data, error } = await query;
|
|
1139
1463
|
if (error) throw new Error(error.message);
|
|
1140
|
-
const
|
|
1141
|
-
|
|
1142
|
-
)
|
|
1464
|
+
const stageIds = (data || []).map((e) => e.release_profile_stage_id).filter(Boolean);
|
|
1465
|
+
const profileNames = await getProfileNamesForStageIds(stageIds);
|
|
1466
|
+
const lines = (data || []).map((e) => {
|
|
1467
|
+
const profile = e.release_profile_stage_id ? profileNames[e.release_profile_stage_id] || "unknown" : "unlinked";
|
|
1468
|
+
return `${e.app_name}/${e.environment} [${profile}] (updated: ${e.updated_at})`;
|
|
1469
|
+
});
|
|
1143
1470
|
return { content: [{ type: "text", text: lines.length ? lines.join("\n") : "No environment configs stored" }] };
|
|
1144
1471
|
}
|
|
1145
1472
|
case "env-get": {
|
|
1146
|
-
|
|
1147
|
-
if (
|
|
1148
|
-
|
|
1473
|
+
let query = supabase.from("env_config").select("env_data_encrypted, release_profile_stage_id").eq("app_name", String(a.appName)).eq("environment", String(a.environment));
|
|
1474
|
+
if (a.releaseProfile) {
|
|
1475
|
+
const { stageIds } = await resolveReleaseProfileStageIds(String(a.releaseProfile));
|
|
1476
|
+
query = query.in("release_profile_stage_id", stageIds);
|
|
1477
|
+
}
|
|
1478
|
+
const { data, error } = await query;
|
|
1479
|
+
if (error) throw new Error(`Env config query failed: ${error.message}`);
|
|
1480
|
+
if (!data || data.length === 0) {
|
|
1481
|
+
throw new Error(`Env config not found: ${a.appName}/${a.environment}${a.releaseProfile ? ` (profile: ${a.releaseProfile})` : ""}`);
|
|
1482
|
+
}
|
|
1483
|
+
if (data.length > 1) {
|
|
1484
|
+
const stageIds = data.map((r) => r.release_profile_stage_id).filter(Boolean);
|
|
1485
|
+
const profileNames = await getProfileNamesForStageIds(stageIds);
|
|
1486
|
+
const names = [...new Set(Object.values(profileNames))].join(", ");
|
|
1487
|
+
throw new Error(
|
|
1488
|
+
`Multiple env configs found for ${a.appName}/${a.environment} across profiles: ${names}. Pass releaseProfile parameter to select one (e.g. releaseProfile: "${Object.values(profileNames)[0] || "..."}")`
|
|
1489
|
+
);
|
|
1490
|
+
}
|
|
1491
|
+
const decrypted = decrypt(data[0].env_data_encrypted);
|
|
1149
1492
|
return { content: [{ type: "text", text: decrypted }] };
|
|
1150
1493
|
}
|
|
1151
1494
|
case "env-store": {
|
|
1152
1495
|
const appName = String(a.appName);
|
|
1153
1496
|
const environment = String(a.environment);
|
|
1154
1497
|
const encrypted = encrypt(String(a.content));
|
|
1155
|
-
|
|
1498
|
+
let resolvedStageIds = null;
|
|
1499
|
+
if (a.releaseProfile) {
|
|
1500
|
+
const { stageIds } = await resolveReleaseProfileStageIds(String(a.releaseProfile));
|
|
1501
|
+
resolvedStageIds = stageIds;
|
|
1502
|
+
}
|
|
1503
|
+
let existQuery = supabase.from("env_config").select("id, release_profile_stage_id").eq("app_name", appName).eq("environment", environment);
|
|
1504
|
+
if (resolvedStageIds) {
|
|
1505
|
+
existQuery = existQuery.in("release_profile_stage_id", resolvedStageIds);
|
|
1506
|
+
}
|
|
1507
|
+
const { data: existingRows, error: existErr } = await existQuery;
|
|
1508
|
+
if (existErr) throw new Error(`Lookup failed: ${existErr.message}`);
|
|
1509
|
+
if (existingRows && existingRows.length > 1 && !resolvedStageIds) {
|
|
1510
|
+
const stageIds = existingRows.map((r) => r.release_profile_stage_id).filter(Boolean);
|
|
1511
|
+
const profileNames = await getProfileNamesForStageIds(stageIds);
|
|
1512
|
+
const names = [...new Set(Object.values(profileNames))].join(", ");
|
|
1513
|
+
throw new Error(
|
|
1514
|
+
`Multiple env configs found for ${appName}/${environment} across profiles: ${names}. Pass releaseProfile parameter to select one.`
|
|
1515
|
+
);
|
|
1516
|
+
}
|
|
1517
|
+
const existing = existingRows?.[0] ?? null;
|
|
1518
|
+
let saveMsg;
|
|
1156
1519
|
if (existing) {
|
|
1157
|
-
const { error
|
|
1520
|
+
const { error } = await supabase.from("env_config").update({
|
|
1158
1521
|
env_data_encrypted: encrypted,
|
|
1159
1522
|
description: a.description ? String(a.description) : void 0,
|
|
1160
1523
|
updated_by: authContext.userId,
|
|
1161
1524
|
updated_at: (/* @__PURE__ */ new Date()).toISOString()
|
|
1162
1525
|
}).eq("id", existing.id);
|
|
1163
|
-
if (
|
|
1164
|
-
|
|
1526
|
+
if (error) throw new Error(error.message);
|
|
1527
|
+
saveMsg = `Updated env config: ${appName}/${environment}`;
|
|
1528
|
+
} else {
|
|
1529
|
+
const insertData = {
|
|
1530
|
+
app_name: appName,
|
|
1531
|
+
environment,
|
|
1532
|
+
env_data_encrypted: encrypted,
|
|
1533
|
+
description: a.description ? String(a.description) : null,
|
|
1534
|
+
created_by: authContext.userId,
|
|
1535
|
+
updated_by: authContext.userId
|
|
1536
|
+
};
|
|
1537
|
+
if (resolvedStageIds?.[0]) {
|
|
1538
|
+
insertData.release_profile_stage_id = resolvedStageIds[0];
|
|
1539
|
+
}
|
|
1540
|
+
const { error } = await supabase.from("env_config").insert(insertData);
|
|
1541
|
+
if (error) throw new Error(error.message);
|
|
1542
|
+
saveMsg = `Stored env config: ${appName}/${environment}`;
|
|
1165
1543
|
}
|
|
1166
|
-
const
|
|
1167
|
-
|
|
1168
|
-
environment,
|
|
1169
|
-
env_data_encrypted: encrypted,
|
|
1170
|
-
description: a.description ? String(a.description) : null,
|
|
1171
|
-
created_by: authContext.userId,
|
|
1172
|
-
updated_by: authContext.userId
|
|
1173
|
-
});
|
|
1174
|
-
if (error) throw new Error(error.message);
|
|
1175
|
-
return { content: [{ type: "text", text: `Stored env config: ${appName}/${environment}` }] };
|
|
1544
|
+
const vercelStatus = await attemptVercelSync(appName, environment);
|
|
1545
|
+
return { content: [{ type: "text", text: `${saveMsg}. ${vercelStatus}` }] };
|
|
1176
1546
|
}
|
|
1177
1547
|
// ----- Cache Purge -----
|
|
1178
1548
|
case "cache-purge": {
|