@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.
Files changed (2) hide show
  1. package/dist/index.js +173 -30
  2. 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 a failure for the rest of the session.
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 { ok: true, role: cachedUserRole };
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 { ok: true, role: 'super_admin' };
213
+ return cachedUserRole;
211
214
  }
212
215
  if (!FCP_API_TOKEN) {
213
- return { ok: false, kind: 'no_token' };
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
- // 401/403 => the API key itself is bad (invalid/expired/revoked).
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 { ok: true, role };
231
+ return role;
232
232
  }
233
233
  catch (err) {
234
234
  console.error('[MCP Server] Role lookup error:', err);
235
- return { ok: false, kind: 'transient' };
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 lookup = await fetchUserRole();
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.26.0",
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",