@fruition/fcp-mcp-server 1.26.0 → 1.27.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/dist/index.js +173 -30
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -39,6 +39,8 @@ import { runBackgroundSync } from './skills-sync.js';
|
|
|
39
39
|
// Configuration
|
|
40
40
|
const FCP_API_URL = process.env.FCP_API_URL || 'https://fcp.fru.io';
|
|
41
41
|
const FCP_API_TOKEN = process.env.FCP_API_TOKEN || '';
|
|
42
|
+
// Small async sleep used by bounded polling loops (e.g. fcp_backup_get_database).
|
|
43
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
42
44
|
// Unroo API can be called directly (legacy) or proxied through FCP (recommended)
|
|
43
45
|
// When UNROO_API_KEY is not set, Unroo calls go through FCP's proxy at /api/mcp/unroo/*
|
|
44
46
|
const UNROO_API_URL = process.env.UNROO_API_URL || 'https://app.unroo.io';
|
|
@@ -112,6 +114,7 @@ const TOOL_PERMISSIONS = {
|
|
|
112
114
|
fcp_backup_update_pairing: 'admin',
|
|
113
115
|
fcp_backup_download: 'admin',
|
|
114
116
|
fcp_backup_download_prepared: 'admin',
|
|
117
|
+
fcp_backup_get_database: 'admin',
|
|
115
118
|
fcp_kinsta_backup_download: 'admin',
|
|
116
119
|
fcp_filesync_start_sync: 'admin',
|
|
117
120
|
fcp_filesync_get_confirmation: 'admin',
|
|
@@ -198,19 +201,19 @@ const TOOL_PERMISSIONS = {
|
|
|
198
201
|
fcp_get_bulk_notify_history: 'viewer',
|
|
199
202
|
};
|
|
200
203
|
// Resolved role for the current API key. Cached only on successful lookup so
|
|
201
|
-
// transient FCP outages don't pin us to
|
|
204
|
+
// transient FCP outages don't pin us to 'none' for the rest of the session.
|
|
202
205
|
let cachedUserRole;
|
|
203
206
|
async function fetchUserRole() {
|
|
204
207
|
if (cachedUserRole !== undefined)
|
|
205
|
-
return
|
|
208
|
+
return cachedUserRole;
|
|
206
209
|
// dev_bypass is the local-dev token; treat as super_admin to avoid
|
|
207
210
|
// gating local development against role lookup.
|
|
208
211
|
if (FCP_API_TOKEN === 'dev_bypass') {
|
|
209
212
|
cachedUserRole = 'super_admin';
|
|
210
|
-
return
|
|
213
|
+
return cachedUserRole;
|
|
211
214
|
}
|
|
212
215
|
if (!FCP_API_TOKEN) {
|
|
213
|
-
return
|
|
216
|
+
return 'none';
|
|
214
217
|
}
|
|
215
218
|
try {
|
|
216
219
|
const res = await fetch(`${FCP_API_URL}/api/mcp/me`, {
|
|
@@ -219,20 +222,17 @@ async function fetchUserRole() {
|
|
|
219
222
|
});
|
|
220
223
|
if (!res.ok) {
|
|
221
224
|
console.error(`[MCP Server] Role lookup failed: HTTP ${res.status}`);
|
|
222
|
-
|
|
223
|
-
// Everything else (5xx, 429, ...) is treated as a transient failure.
|
|
224
|
-
const kind = res.status === 401 || res.status === 403 ? 'auth' : 'transient';
|
|
225
|
-
return { ok: false, kind, status: res.status };
|
|
225
|
+
return 'none';
|
|
226
226
|
}
|
|
227
227
|
const data = await res.json();
|
|
228
228
|
const role = data?.role ?? 'none';
|
|
229
229
|
cachedUserRole = role;
|
|
230
230
|
console.error(`[MCP Server] Resolved user role: ${role} (${data?.email ?? 'unknown'})`);
|
|
231
|
-
return
|
|
231
|
+
return role;
|
|
232
232
|
}
|
|
233
233
|
catch (err) {
|
|
234
234
|
console.error('[MCP Server] Role lookup error:', err);
|
|
235
|
-
return
|
|
235
|
+
return 'none';
|
|
236
236
|
}
|
|
237
237
|
}
|
|
238
238
|
function isRoleAtLeast(actual, required) {
|
|
@@ -246,26 +246,7 @@ function isRoleAtLeast(actual, required) {
|
|
|
246
246
|
}
|
|
247
247
|
async function enforceToolPermission(toolName) {
|
|
248
248
|
const required = TOOL_PERMISSIONS[toolName] ?? 'viewer';
|
|
249
|
-
const
|
|
250
|
-
// A failed lookup is NOT a role problem. Surface the real cause so the user
|
|
251
|
-
// takes the right action instead of asking a super_admin for a role they
|
|
252
|
-
// may already effectively have.
|
|
253
|
-
if (!lookup.ok) {
|
|
254
|
-
switch (lookup.kind) {
|
|
255
|
-
case 'no_token':
|
|
256
|
-
throw new Error('FCP_API_TOKEN is not set. Add your FCP API key to this MCP server\'s ' +
|
|
257
|
-
'env config (create one in FCP → Admin → API Keys).');
|
|
258
|
-
case 'auth':
|
|
259
|
-
throw new Error(`Your FCP API key is invalid, expired, or revoked (HTTP ${lookup.status}). ` +
|
|
260
|
-
'Create a new key in FCP → Admin → API Keys and update your MCP server ' +
|
|
261
|
-
'config. This is NOT a role/permission problem — do not request a role grant.');
|
|
262
|
-
case 'transient':
|
|
263
|
-
throw new Error('FCP role lookup is temporarily unavailable' +
|
|
264
|
-
(lookup.status ? ` (HTTP ${lookup.status})` : '') +
|
|
265
|
-
'. This is a transient FCP error — retry shortly.');
|
|
266
|
-
}
|
|
267
|
-
}
|
|
268
|
-
const actual = lookup.role;
|
|
249
|
+
const actual = await fetchUserRole();
|
|
269
250
|
if (!isRoleAtLeast(actual, required)) {
|
|
270
251
|
throw new Error(`Permission denied: tool '${toolName}' requires ${required} role; ` +
|
|
271
252
|
`your role is '${actual}'. Contact a super_admin (Brad, Mattox, or Andrea) ` +
|
|
@@ -3240,6 +3221,36 @@ const TOOLS = [
|
|
|
3240
3221
|
required: ['jobId'],
|
|
3241
3222
|
},
|
|
3242
3223
|
},
|
|
3224
|
+
{
|
|
3225
|
+
name: 'fcp_backup_get_database',
|
|
3226
|
+
description: 'Return a database backup for a site in ONE call. Reuses the most recent completed backup if it is younger than maxAgeHours, otherwise triggers a fresh one and waits. Production data is sanitized by default. Returns a presigned download URL (1h expiry) when ready. If the backup or sanitization is still running when waitSeconds is exhausted, returns status:"pending" with a handle (backupId or sanitizeJobId) so you can poll fcp_backup_get_status / fcp_backup_sanitize_status and re-call. Admin only.',
|
|
3227
|
+
inputSchema: {
|
|
3228
|
+
type: 'object',
|
|
3229
|
+
properties: {
|
|
3230
|
+
websiteId: {
|
|
3231
|
+
type: 'number',
|
|
3232
|
+
description: 'The website ID to back up',
|
|
3233
|
+
},
|
|
3234
|
+
maxAgeHours: {
|
|
3235
|
+
type: 'number',
|
|
3236
|
+
description: 'Reuse the latest completed backup if it is younger than this many hours (default: 24). Ignored when fresh=true.',
|
|
3237
|
+
},
|
|
3238
|
+
fresh: {
|
|
3239
|
+
type: 'boolean',
|
|
3240
|
+
description: 'Force a brand-new backup, ignoring any existing one (default: false).',
|
|
3241
|
+
},
|
|
3242
|
+
sanitize: {
|
|
3243
|
+
type: 'boolean',
|
|
3244
|
+
description: 'Whether to sanitize production data. Leave unset to use the server default (production is sanitized, non-production is returned raw). Set false to force a raw dump (admin, use with care).',
|
|
3245
|
+
},
|
|
3246
|
+
waitSeconds: {
|
|
3247
|
+
type: 'number',
|
|
3248
|
+
description: 'Total bounded wait budget in seconds across the trigger and sanitize polls (default: 180). On exhaustion the tool returns status:"pending" with a resume handle rather than hanging.',
|
|
3249
|
+
},
|
|
3250
|
+
},
|
|
3251
|
+
required: ['websiteId'],
|
|
3252
|
+
},
|
|
3253
|
+
},
|
|
3243
3254
|
{
|
|
3244
3255
|
name: 'fcp_backup_list_pairings',
|
|
3245
3256
|
description: 'List site pairings for backup management. Optionally filter by site ID. Shows production/staging/dev relationships grouped by account.',
|
|
@@ -4951,6 +4962,138 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
4951
4962
|
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
|
|
4952
4963
|
};
|
|
4953
4964
|
}
|
|
4965
|
+
case 'fcp_backup_get_database': {
|
|
4966
|
+
const { websiteId, maxAgeHours = 24, fresh = false, sanitize, waitSeconds = 180, } = args;
|
|
4967
|
+
const siteId = `site-${websiteId}`;
|
|
4968
|
+
const deadline = Date.now() + Math.max(0, waitSeconds) * 1000;
|
|
4969
|
+
const pollIntervalMs = 10_000;
|
|
4970
|
+
const isDbType = (t) => !!t && ['mysql', 'database', 'postgresql', 'mysqldump'].includes(String(t).toLowerCase());
|
|
4971
|
+
const respond = (payload) => ({
|
|
4972
|
+
content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }],
|
|
4973
|
+
});
|
|
4974
|
+
let backupId;
|
|
4975
|
+
let ageMinutes;
|
|
4976
|
+
// 1. Reuse the most recent completed DB backup unless a fresh one is requested.
|
|
4977
|
+
if (!fresh) {
|
|
4978
|
+
const list = await client.backupListBackups(siteId);
|
|
4979
|
+
const backups = Array.isArray(list) ? list : (list?.backups ?? []);
|
|
4980
|
+
const completed = backups
|
|
4981
|
+
.filter((b) => b?.status === 'completed' && isDbType(b?.backup_type) && b?.backup_location)
|
|
4982
|
+
.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime());
|
|
4983
|
+
const latest = completed[0];
|
|
4984
|
+
if (latest?.created_at) {
|
|
4985
|
+
ageMinutes = (Date.now() - new Date(latest.created_at).getTime()) / 60_000;
|
|
4986
|
+
if (ageMinutes <= maxAgeHours * 60) {
|
|
4987
|
+
backupId = latest.backup_id;
|
|
4988
|
+
}
|
|
4989
|
+
}
|
|
4990
|
+
}
|
|
4991
|
+
// 2. Trigger a fresh backup if none reusable, then poll until completed.
|
|
4992
|
+
if (!backupId) {
|
|
4993
|
+
const trig = await client.backupTrigger(websiteId, 'mcp');
|
|
4994
|
+
if (!trig?.backupId) {
|
|
4995
|
+
return respond({
|
|
4996
|
+
status: 'error',
|
|
4997
|
+
stage: 'trigger',
|
|
4998
|
+
message: trig?.error || trig?.message || 'Failed to trigger backup',
|
|
4999
|
+
detail: trig,
|
|
5000
|
+
});
|
|
5001
|
+
}
|
|
5002
|
+
backupId = trig.backupId;
|
|
5003
|
+
ageMinutes = 0;
|
|
5004
|
+
let backupStatus = trig.status || 'queued';
|
|
5005
|
+
while (backupStatus !== 'completed') {
|
|
5006
|
+
if (Date.now() >= deadline) {
|
|
5007
|
+
return respond({
|
|
5008
|
+
status: 'pending',
|
|
5009
|
+
stage: 'backup',
|
|
5010
|
+
backupId,
|
|
5011
|
+
message: `Backup still '${backupStatus}' after ${waitSeconds}s. Poll fcp_backup_get_status({ backupId: "${backupId}" }), then re-call fcp_backup_get_database to reuse it once completed.`,
|
|
5012
|
+
});
|
|
5013
|
+
}
|
|
5014
|
+
await sleep(pollIntervalMs);
|
|
5015
|
+
const st = await client.backupGetStatus({ backupId });
|
|
5016
|
+
const job = st?.job ?? st;
|
|
5017
|
+
backupStatus = job?.status || backupStatus;
|
|
5018
|
+
if (backupStatus === 'failed') {
|
|
5019
|
+
return respond({
|
|
5020
|
+
status: 'error',
|
|
5021
|
+
stage: 'backup',
|
|
5022
|
+
backupId,
|
|
5023
|
+
message: job?.error_message || 'Backup job failed',
|
|
5024
|
+
});
|
|
5025
|
+
}
|
|
5026
|
+
}
|
|
5027
|
+
}
|
|
5028
|
+
// 3. Prepare the download (sanitizes production data by default).
|
|
5029
|
+
const prep = await client.backupDownloadPrepared({
|
|
5030
|
+
siteId,
|
|
5031
|
+
backupId: backupId,
|
|
5032
|
+
downloadType: 'database',
|
|
5033
|
+
sanitize,
|
|
5034
|
+
});
|
|
5035
|
+
// Non-production (or explicit sanitize:false): direct presigned URL.
|
|
5036
|
+
if (prep?.downloadUrl && prep?.sanitized !== true) {
|
|
5037
|
+
return respond({
|
|
5038
|
+
status: 'ready',
|
|
5039
|
+
backupId,
|
|
5040
|
+
sanitized: false,
|
|
5041
|
+
downloadUrl: prep.downloadUrl,
|
|
5042
|
+
fileName: prep.fileName,
|
|
5043
|
+
sizeBytes: prep.sizeBytes,
|
|
5044
|
+
backupType: prep.backupType,
|
|
5045
|
+
ageMinutes: ageMinutes != null ? Math.round(ageMinutes) : undefined,
|
|
5046
|
+
expiresIn: 3600,
|
|
5047
|
+
});
|
|
5048
|
+
}
|
|
5049
|
+
// Production: poll the sanitization job until it produces a download URL.
|
|
5050
|
+
if (prep?.jobId) {
|
|
5051
|
+
const sanitizeJobId = prep.jobId;
|
|
5052
|
+
for (;;) {
|
|
5053
|
+
if (Date.now() >= deadline) {
|
|
5054
|
+
return respond({
|
|
5055
|
+
status: 'pending',
|
|
5056
|
+
stage: 'sanitize',
|
|
5057
|
+
backupId,
|
|
5058
|
+
sanitizeJobId,
|
|
5059
|
+
message: `Sanitization still running after ${waitSeconds}s. Poll fcp_backup_sanitize_status({ jobId: "${sanitizeJobId}" }) for the download URL.`,
|
|
5060
|
+
});
|
|
5061
|
+
}
|
|
5062
|
+
await sleep(pollIntervalMs);
|
|
5063
|
+
const s = await client.backupSanitizeStatus(sanitizeJobId);
|
|
5064
|
+
if (s?.status === 'completed' && s?.downloadUrl) {
|
|
5065
|
+
return respond({
|
|
5066
|
+
status: 'ready',
|
|
5067
|
+
backupId,
|
|
5068
|
+
sanitizeJobId,
|
|
5069
|
+
sanitized: true,
|
|
5070
|
+
downloadUrl: s.downloadUrl,
|
|
5071
|
+
fileName: s.fileName,
|
|
5072
|
+
sizeBytes: s.sanitizedSizeBytes,
|
|
5073
|
+
ageMinutes: ageMinutes != null ? Math.round(ageMinutes) : undefined,
|
|
5074
|
+
expiresIn: 3600,
|
|
5075
|
+
});
|
|
5076
|
+
}
|
|
5077
|
+
if (s?.status === 'failed') {
|
|
5078
|
+
return respond({
|
|
5079
|
+
status: 'error',
|
|
5080
|
+
stage: 'sanitize',
|
|
5081
|
+
backupId,
|
|
5082
|
+
sanitizeJobId,
|
|
5083
|
+
message: s?.error || 'Sanitization failed',
|
|
5084
|
+
});
|
|
5085
|
+
}
|
|
5086
|
+
}
|
|
5087
|
+
}
|
|
5088
|
+
// Unexpected prepare response — surface it for debugging.
|
|
5089
|
+
return respond({
|
|
5090
|
+
status: 'error',
|
|
5091
|
+
stage: 'prepare',
|
|
5092
|
+
backupId,
|
|
5093
|
+
message: 'Unexpected response from backup download preparation',
|
|
5094
|
+
detail: prep,
|
|
5095
|
+
});
|
|
5096
|
+
}
|
|
4954
5097
|
case 'fcp_backup_list_pairings': {
|
|
4955
5098
|
const { siteId } = args;
|
|
4956
5099
|
const result = await client.backupListPairings(siteId);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fruition/fcp-mcp-server",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.27.0",
|
|
4
4
|
"description": "MCP Server for FCP Launch Coordination System - enables Claude Code to interact with FCP launches and track development time",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"type": "module",
|