@fruition/fcp-mcp-server 1.25.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 +188 -6
  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',
@@ -2811,7 +2814,7 @@ const TOOLS = [
2811
2814
  },
2812
2815
  {
2813
2816
  name: 'fcp_shield_add_domain',
2814
- description: 'Add a domain to Fruition Shield WAF protection. Returns DNS records (traffic CNAME + cert validation CNAME) that must be added at the registrar.',
2817
+ description: 'Add a domain to Fruition Shield WAF protection. Custom mode (default) fronts an origin server via the shared distribution. S3 mode (origin_type="s3") provisions a DEDICATED CloudFront distribution + OAC over a private S3 bucket (Steldris static hosting) and returns its distribution id + CloudFront domain. Returns DNS records to add at the registrar.',
2815
2818
  inputSchema: {
2816
2819
  type: 'object',
2817
2820
  properties: {
@@ -2819,9 +2822,14 @@ const TOOLS = [
2819
2822
  type: 'string',
2820
2823
  description: 'Domain to protect (e.g., www.butterflies.org)',
2821
2824
  },
2825
+ origin_type: {
2826
+ type: 'string',
2827
+ enum: ['custom', 's3'],
2828
+ description: 'Origin mode: custom (shared distribution + origin server, default) or s3 (dedicated distribution + OAC over a private S3 bucket).',
2829
+ },
2822
2830
  origin_host: {
2823
2831
  type: 'string',
2824
- description: 'Origin server IP or hostname (e.g., 146.190.2.169 for K8s prod)',
2832
+ description: 'Origin server IP or hostname (e.g., 146.190.2.169 for K8s prod). Required for origin_type=custom; ignored for s3.',
2825
2833
  },
2826
2834
  origin_port: {
2827
2835
  type: 'number',
@@ -2831,6 +2839,18 @@ const TOOLS = [
2831
2839
  type: 'string',
2832
2840
  description: 'Origin protocol: http or https (default: https)',
2833
2841
  },
2842
+ s3_bucket: {
2843
+ type: 'string',
2844
+ description: 'S3 artifacts bucket (origin_type=s3, e.g. steldris-sites-prod). Required for s3 mode.',
2845
+ },
2846
+ s3_region: {
2847
+ type: 'string',
2848
+ description: 'S3 bucket region (origin_type=s3, e.g. us-east-1). Required for s3 mode.',
2849
+ },
2850
+ s3_origin_path: {
2851
+ type: 'string',
2852
+ description: 'CloudFront OriginPath — the per-tenant key prefix (origin_type=s3, e.g. /acme).',
2853
+ },
2834
2854
  website_id: {
2835
2855
  type: 'number',
2836
2856
  description: 'Link to existing FCP website ID',
@@ -2841,14 +2861,14 @@ const TOOLS = [
2841
2861
  },
2842
2862
  cache_profile: {
2843
2863
  type: 'string',
2844
- description: 'Cache profile: standard, aggressive, minimal, none (default: standard)',
2864
+ description: 'Cache profile: standard, aggressive, minimal, none (default: standard; s3 defaults to aggressive)',
2845
2865
  },
2846
2866
  notes: {
2847
2867
  type: 'string',
2848
2868
  description: 'Notes about this domain',
2849
2869
  },
2850
2870
  },
2851
- required: ['domain', 'origin_host'],
2871
+ required: ['domain'],
2852
2872
  },
2853
2873
  },
2854
2874
  {
@@ -3201,6 +3221,36 @@ const TOOLS = [
3201
3221
  required: ['jobId'],
3202
3222
  },
3203
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
+ },
3204
3254
  {
3205
3255
  name: 'fcp_backup_list_pairings',
3206
3256
  description: 'List site pairings for backup management. Optionally filter by site ID. Shows production/staging/dev relationships grouped by account.',
@@ -4734,9 +4784,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
4734
4784
  };
4735
4785
  }
4736
4786
  case 'fcp_shield_add_domain': {
4737
- const { domain, origin_host, origin_port, origin_protocol, website_id, account_id, cache_profile, notes } = args;
4787
+ const { domain, origin_type, origin_host, origin_port, origin_protocol, s3_bucket, s3_region, s3_origin_path, website_id, account_id, cache_profile, notes } = args;
4738
4788
  const result = await client.shieldAddDomain({
4739
- domain, origin_host, origin_port, origin_protocol, website_id, account_id, cache_profile, notes,
4789
+ domain, origin_type, origin_host, origin_port, origin_protocol, s3_bucket, s3_region, s3_origin_path, website_id, account_id, cache_profile, notes,
4740
4790
  });
4741
4791
  return {
4742
4792
  content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
@@ -4912,6 +4962,138 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
4912
4962
  content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
4913
4963
  };
4914
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
+ }
4915
5097
  case 'fcp_backup_list_pairings': {
4916
5098
  const { siteId } = args;
4917
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.25.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",