@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.
- package/dist/index.js +188 -6
- 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.
|
|
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'
|
|
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.
|
|
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",
|