@fruition/fcp-mcp-server 1.24.1 → 1.26.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 +943 -16
- package/dist/skills-sync.d.ts +11 -0
- package/dist/skills-sync.js +36 -1
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -93,6 +93,7 @@ const TOOL_PERMISSIONS = {
|
|
|
93
93
|
fcp_clone_to_staging: 'super_admin',
|
|
94
94
|
fcp_clone_confirm: 'super_admin',
|
|
95
95
|
fcp_shield_remove_domain: 'super_admin',
|
|
96
|
+
fcp_trusted_ip_remove_range: 'super_admin',
|
|
96
97
|
fcp_backup_delete_pairing: 'super_admin',
|
|
97
98
|
fcp_filesync_cancel_sync: 'super_admin',
|
|
98
99
|
// --- admin+: mutating ops with real-world side effects ---
|
|
@@ -103,6 +104,8 @@ const TOOL_PERMISSIONS = {
|
|
|
103
104
|
fcp_delete_checklist_item: 'admin',
|
|
104
105
|
fcp_shield_add_domain: 'admin',
|
|
105
106
|
fcp_shield_update_domain: 'admin',
|
|
107
|
+
fcp_trusted_ip_add_range: 'admin',
|
|
108
|
+
fcp_trusted_ip_update_range: 'admin',
|
|
106
109
|
fcp_backup_enable: 'admin',
|
|
107
110
|
fcp_backup_trigger: 'admin',
|
|
108
111
|
fcp_backup_check_trigger: 'admin',
|
|
@@ -149,6 +152,8 @@ const TOOL_PERMISSIONS = {
|
|
|
149
152
|
fcp_shield_list_domains: 'viewer',
|
|
150
153
|
fcp_shield_get_domain: 'viewer',
|
|
151
154
|
fcp_shield_get_metrics: 'viewer',
|
|
155
|
+
fcp_trusted_ip_list_ranges: 'viewer',
|
|
156
|
+
fcp_trusted_ip_export: 'viewer',
|
|
152
157
|
fcp_backup_list_sites: 'viewer',
|
|
153
158
|
fcp_backup_get_config: 'viewer',
|
|
154
159
|
fcp_backup_list_eligible: 'viewer',
|
|
@@ -169,21 +174,43 @@ const TOOL_PERMISSIONS = {
|
|
|
169
174
|
unroo_get_my_tasks: 'viewer',
|
|
170
175
|
unroo_get_parking_lot: 'viewer',
|
|
171
176
|
unroo_get_backlog: 'viewer',
|
|
177
|
+
// --- Alerting ---
|
|
178
|
+
// admin: hard-delete of a rule and client-facing bulk notifications are
|
|
179
|
+
// higher-stakes than internal alert create/close, so gate them tighter.
|
|
180
|
+
fcp_delete_alert_rule: 'admin',
|
|
181
|
+
fcp_bulk_notify_clients: 'admin',
|
|
182
|
+
// operator: routine alert/outage writes (create, close, update).
|
|
183
|
+
fcp_create_alert: 'operator',
|
|
184
|
+
fcp_update_alert: 'operator',
|
|
185
|
+
fcp_create_alert_rule: 'operator',
|
|
186
|
+
fcp_update_alert_rule: 'operator',
|
|
187
|
+
fcp_create_outage: 'operator',
|
|
188
|
+
fcp_update_outage: 'operator',
|
|
189
|
+
fcp_resolve_outage: 'operator',
|
|
190
|
+
// viewer: reads.
|
|
191
|
+
fcp_list_alerts: 'viewer',
|
|
192
|
+
fcp_get_alert: 'viewer',
|
|
193
|
+
fcp_get_alert_stats: 'viewer',
|
|
194
|
+
fcp_list_alert_rules: 'viewer',
|
|
195
|
+
fcp_get_alert_rule: 'viewer',
|
|
196
|
+
fcp_list_outages: 'viewer',
|
|
197
|
+
fcp_get_outage: 'viewer',
|
|
198
|
+
fcp_get_bulk_notify_history: 'viewer',
|
|
172
199
|
};
|
|
173
200
|
// Resolved role for the current API key. Cached only on successful lookup so
|
|
174
|
-
// transient FCP outages don't pin us to
|
|
201
|
+
// transient FCP outages don't pin us to a failure for the rest of the session.
|
|
175
202
|
let cachedUserRole;
|
|
176
203
|
async function fetchUserRole() {
|
|
177
204
|
if (cachedUserRole !== undefined)
|
|
178
|
-
return cachedUserRole;
|
|
205
|
+
return { ok: true, role: cachedUserRole };
|
|
179
206
|
// dev_bypass is the local-dev token; treat as super_admin to avoid
|
|
180
207
|
// gating local development against role lookup.
|
|
181
208
|
if (FCP_API_TOKEN === 'dev_bypass') {
|
|
182
209
|
cachedUserRole = 'super_admin';
|
|
183
|
-
return
|
|
210
|
+
return { ok: true, role: 'super_admin' };
|
|
184
211
|
}
|
|
185
212
|
if (!FCP_API_TOKEN) {
|
|
186
|
-
return '
|
|
213
|
+
return { ok: false, kind: 'no_token' };
|
|
187
214
|
}
|
|
188
215
|
try {
|
|
189
216
|
const res = await fetch(`${FCP_API_URL}/api/mcp/me`, {
|
|
@@ -192,17 +219,20 @@ async function fetchUserRole() {
|
|
|
192
219
|
});
|
|
193
220
|
if (!res.ok) {
|
|
194
221
|
console.error(`[MCP Server] Role lookup failed: HTTP ${res.status}`);
|
|
195
|
-
|
|
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 };
|
|
196
226
|
}
|
|
197
227
|
const data = await res.json();
|
|
198
228
|
const role = data?.role ?? 'none';
|
|
199
229
|
cachedUserRole = role;
|
|
200
230
|
console.error(`[MCP Server] Resolved user role: ${role} (${data?.email ?? 'unknown'})`);
|
|
201
|
-
return role;
|
|
231
|
+
return { ok: true, role };
|
|
202
232
|
}
|
|
203
233
|
catch (err) {
|
|
204
234
|
console.error('[MCP Server] Role lookup error:', err);
|
|
205
|
-
return '
|
|
235
|
+
return { ok: false, kind: 'transient' };
|
|
206
236
|
}
|
|
207
237
|
}
|
|
208
238
|
function isRoleAtLeast(actual, required) {
|
|
@@ -216,7 +246,26 @@ function isRoleAtLeast(actual, required) {
|
|
|
216
246
|
}
|
|
217
247
|
async function enforceToolPermission(toolName) {
|
|
218
248
|
const required = TOOL_PERMISSIONS[toolName] ?? 'viewer';
|
|
219
|
-
const
|
|
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;
|
|
220
269
|
if (!isRoleAtLeast(actual, required)) {
|
|
221
270
|
throw new Error(`Permission denied: tool '${toolName}' requires ${required} role; ` +
|
|
222
271
|
`your role is '${actual}'. Contact a super_admin (Brad, Mattox, or Andrea) ` +
|
|
@@ -368,6 +417,97 @@ class FCPClient {
|
|
|
368
417
|
method: 'DELETE',
|
|
369
418
|
});
|
|
370
419
|
}
|
|
420
|
+
// --- Alerting: incidents (alert_events), rules, outages, bulk notifications ---
|
|
421
|
+
// Thin wrappers over the FCP alerting HTTP API. Request bodies are camelCase
|
|
422
|
+
// (the API maps to snake_case DB columns internally).
|
|
423
|
+
// Build a query string from defined params (skips undefined/empty values).
|
|
424
|
+
buildQuery(q) {
|
|
425
|
+
const entries = Object.entries(q)
|
|
426
|
+
.filter(([, v]) => v !== undefined && v !== null && v !== '')
|
|
427
|
+
.map(([k, v]) => [k, String(v)]);
|
|
428
|
+
const qs = new URLSearchParams(entries).toString();
|
|
429
|
+
return qs ? `?${qs}` : '';
|
|
430
|
+
}
|
|
431
|
+
// Incidents (individual alerts).
|
|
432
|
+
async listAlerts(q = {}) {
|
|
433
|
+
return this.fetch(`/api/incidents${this.buildQuery(q)}`);
|
|
434
|
+
}
|
|
435
|
+
async getAlert(id) {
|
|
436
|
+
return this.fetch(`/api/incidents/${id}`);
|
|
437
|
+
}
|
|
438
|
+
async createAlert(input) {
|
|
439
|
+
return this.fetch('/api/incidents', {
|
|
440
|
+
method: 'POST',
|
|
441
|
+
body: JSON.stringify(input),
|
|
442
|
+
});
|
|
443
|
+
}
|
|
444
|
+
async updateAlert(id, updates) {
|
|
445
|
+
return this.fetch(`/api/incidents/${id}`, {
|
|
446
|
+
method: 'PATCH',
|
|
447
|
+
body: JSON.stringify(updates),
|
|
448
|
+
});
|
|
449
|
+
}
|
|
450
|
+
async getAlertStats() {
|
|
451
|
+
return this.fetch('/api/incidents/stats');
|
|
452
|
+
}
|
|
453
|
+
// Alert rules (notification configuration).
|
|
454
|
+
async listAlertRules(accountId) {
|
|
455
|
+
return this.fetch(`/api/alerts/rules${this.buildQuery({ accountId })}`);
|
|
456
|
+
}
|
|
457
|
+
async getAlertRule(ruleId) {
|
|
458
|
+
return this.fetch(`/api/alerts/rules/${ruleId}`);
|
|
459
|
+
}
|
|
460
|
+
async createAlertRule(input) {
|
|
461
|
+
return this.fetch('/api/alerts/rules', {
|
|
462
|
+
method: 'POST',
|
|
463
|
+
body: JSON.stringify(input),
|
|
464
|
+
});
|
|
465
|
+
}
|
|
466
|
+
async updateAlertRule(ruleId, updates) {
|
|
467
|
+
return this.fetch(`/api/alerts/rules/${ruleId}`, {
|
|
468
|
+
method: 'PATCH',
|
|
469
|
+
body: JSON.stringify(updates),
|
|
470
|
+
});
|
|
471
|
+
}
|
|
472
|
+
async deleteAlertRule(ruleId) {
|
|
473
|
+
return this.fetch(`/api/alerts/rules/${ruleId}`, {
|
|
474
|
+
method: 'DELETE',
|
|
475
|
+
});
|
|
476
|
+
}
|
|
477
|
+
// Outages (admin outage tracking).
|
|
478
|
+
async listOutages(q = {}) {
|
|
479
|
+
return this.fetch(`/api/alerts/outages${this.buildQuery(q)}`);
|
|
480
|
+
}
|
|
481
|
+
async getOutage(outageId) {
|
|
482
|
+
return this.fetch(`/api/alerts/outages/${outageId}`);
|
|
483
|
+
}
|
|
484
|
+
async createOutage(input) {
|
|
485
|
+
return this.fetch('/api/alerts/outages', {
|
|
486
|
+
method: 'POST',
|
|
487
|
+
body: JSON.stringify(input),
|
|
488
|
+
});
|
|
489
|
+
}
|
|
490
|
+
async updateOutage(outageId, updates) {
|
|
491
|
+
return this.fetch(`/api/alerts/outages/${outageId}`, {
|
|
492
|
+
method: 'PATCH',
|
|
493
|
+
body: JSON.stringify(updates),
|
|
494
|
+
});
|
|
495
|
+
}
|
|
496
|
+
async resolveOutage(outageId) {
|
|
497
|
+
return this.fetch(`/api/alerts/outages/${outageId}`, {
|
|
498
|
+
method: 'DELETE',
|
|
499
|
+
});
|
|
500
|
+
}
|
|
501
|
+
// Bulk client notifications. Sends real emails/SMS to clients — handle with care.
|
|
502
|
+
async bulkNotifyClients(input) {
|
|
503
|
+
return this.fetch('/api/alerts/bulk-notify', {
|
|
504
|
+
method: 'POST',
|
|
505
|
+
body: JSON.stringify(input),
|
|
506
|
+
});
|
|
507
|
+
}
|
|
508
|
+
async getBulkNotifyHistory(limit) {
|
|
509
|
+
return this.fetch(`/api/alerts/bulk-notify${this.buildQuery({ limit })}`);
|
|
510
|
+
}
|
|
371
511
|
async createLaunch(input) {
|
|
372
512
|
return this.fetch('/api/launches', {
|
|
373
513
|
method: 'POST',
|
|
@@ -586,6 +726,78 @@ class FCPClient {
|
|
|
586
726
|
});
|
|
587
727
|
}
|
|
588
728
|
// ============================================================================
|
|
729
|
+
// Trusted IP Range Methods (FRUPRI-761)
|
|
730
|
+
// ============================================================================
|
|
731
|
+
// Text-returning sibling of fetch() for endpoints that emit text/plain or
|
|
732
|
+
// text/yaml (export) or an empty 204 body (delete). The base fetch() always
|
|
733
|
+
// calls response.json() and would throw on those.
|
|
734
|
+
async fetchText(path, options = {}, timeoutMs) {
|
|
735
|
+
const url = `${this.baseUrl}${path}`;
|
|
736
|
+
const headers = {
|
|
737
|
+
...(options.headers || {}),
|
|
738
|
+
};
|
|
739
|
+
if (this.token) {
|
|
740
|
+
if (this.token === 'dev_bypass') {
|
|
741
|
+
headers['X-Dev-Bypass'] = 'true';
|
|
742
|
+
}
|
|
743
|
+
else {
|
|
744
|
+
headers['X-API-Key'] = this.token;
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
const response = await fetch(url, {
|
|
748
|
+
...options,
|
|
749
|
+
headers,
|
|
750
|
+
signal: AbortSignal.timeout(timeoutMs || this.defaultTimeout),
|
|
751
|
+
});
|
|
752
|
+
if (!response.ok) {
|
|
753
|
+
const error = await response.text();
|
|
754
|
+
throw new Error(`FCP API error (${response.status}): ${error}`);
|
|
755
|
+
}
|
|
756
|
+
return response.text();
|
|
757
|
+
}
|
|
758
|
+
async trustedIpListRanges(filters) {
|
|
759
|
+
const params = new URLSearchParams();
|
|
760
|
+
if (filters?.group)
|
|
761
|
+
params.append('group', filters.group);
|
|
762
|
+
if (filters?.include_pending)
|
|
763
|
+
params.append('include_pending', 'true');
|
|
764
|
+
if (filters?.include_expired)
|
|
765
|
+
params.append('include_expired', 'true');
|
|
766
|
+
const qs = params.toString();
|
|
767
|
+
return this.fetch(`/api/infrastructure/trusted-ips/entries${qs ? `?${qs}` : ''}`);
|
|
768
|
+
}
|
|
769
|
+
async trustedIpAddRange(input) {
|
|
770
|
+
return this.fetch('/api/infrastructure/trusted-ips/entries', {
|
|
771
|
+
method: 'POST',
|
|
772
|
+
body: JSON.stringify(input),
|
|
773
|
+
});
|
|
774
|
+
}
|
|
775
|
+
async trustedIpUpdateRange(id, updates) {
|
|
776
|
+
return this.fetch(`/api/infrastructure/trusted-ips/entries/${id}`, {
|
|
777
|
+
method: 'PUT',
|
|
778
|
+
body: JSON.stringify(updates),
|
|
779
|
+
});
|
|
780
|
+
}
|
|
781
|
+
async trustedIpRemoveRange(id) {
|
|
782
|
+
await this.fetchText(`/api/infrastructure/trusted-ips/entries/${id}`, {
|
|
783
|
+
method: 'DELETE',
|
|
784
|
+
});
|
|
785
|
+
return { ok: true, id, message: `Removed trusted IP entry ${id}` };
|
|
786
|
+
}
|
|
787
|
+
async trustedIpExport(opts) {
|
|
788
|
+
const params = new URLSearchParams();
|
|
789
|
+
if (opts?.format)
|
|
790
|
+
params.append('format', opts.format);
|
|
791
|
+
if (opts?.group)
|
|
792
|
+
params.append('group', opts.group);
|
|
793
|
+
if (opts?.purpose)
|
|
794
|
+
params.append('purpose', opts.purpose);
|
|
795
|
+
if (opts?.separator)
|
|
796
|
+
params.append('separator', opts.separator);
|
|
797
|
+
const qs = params.toString();
|
|
798
|
+
return this.fetchText(`/api/infrastructure/trusted-ips/export${qs ? `?${qs}` : ''}`);
|
|
799
|
+
}
|
|
800
|
+
// ============================================================================
|
|
589
801
|
// Backup Management Methods
|
|
590
802
|
// ============================================================================
|
|
591
803
|
async backupListSites() {
|
|
@@ -2161,7 +2373,7 @@ const TOOLS = [
|
|
|
2161
2373
|
// Nuclei Security Scanning
|
|
2162
2374
|
{
|
|
2163
2375
|
name: 'fcp_trigger_nuclei_scan',
|
|
2164
|
-
description: 'Trigger a Nuclei security scan for a website.
|
|
2376
|
+
description: 'Trigger a Nuclei security scan for a website. Queues the scan as a pending record in the FCP database for the local scanner to pick up and run; findings are written back to the database. Returns the scan ID for follow-up queries with fcp_get_nuclei_results.',
|
|
2165
2377
|
inputSchema: {
|
|
2166
2378
|
type: 'object',
|
|
2167
2379
|
properties: {
|
|
@@ -2179,7 +2391,7 @@ const TOOLS = [
|
|
|
2179
2391
|
},
|
|
2180
2392
|
templates: {
|
|
2181
2393
|
type: 'string',
|
|
2182
|
-
description: 'Comma-separated Nuclei template categories (default: cves,exposures,misconfiguration). Options: cves, vulnerabilities, exposures, misconfiguration, technologies',
|
|
2394
|
+
description: 'Comma-separated Nuclei template categories (default: cves,exposures,misconfiguration). Options: cves, vulnerabilities, exposures, misconfiguration, technologies, ssl, dns, headless',
|
|
2183
2395
|
},
|
|
2184
2396
|
},
|
|
2185
2397
|
required: ['website_id'],
|
|
@@ -2621,7 +2833,7 @@ const TOOLS = [
|
|
|
2621
2833
|
},
|
|
2622
2834
|
{
|
|
2623
2835
|
name: 'fcp_shield_add_domain',
|
|
2624
|
-
description: 'Add a domain to Fruition Shield WAF protection.
|
|
2836
|
+
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.',
|
|
2625
2837
|
inputSchema: {
|
|
2626
2838
|
type: 'object',
|
|
2627
2839
|
properties: {
|
|
@@ -2629,9 +2841,14 @@ const TOOLS = [
|
|
|
2629
2841
|
type: 'string',
|
|
2630
2842
|
description: 'Domain to protect (e.g., www.butterflies.org)',
|
|
2631
2843
|
},
|
|
2844
|
+
origin_type: {
|
|
2845
|
+
type: 'string',
|
|
2846
|
+
enum: ['custom', 's3'],
|
|
2847
|
+
description: 'Origin mode: custom (shared distribution + origin server, default) or s3 (dedicated distribution + OAC over a private S3 bucket).',
|
|
2848
|
+
},
|
|
2632
2849
|
origin_host: {
|
|
2633
2850
|
type: 'string',
|
|
2634
|
-
description: 'Origin server IP or hostname (e.g., 146.190.2.169 for K8s prod)',
|
|
2851
|
+
description: 'Origin server IP or hostname (e.g., 146.190.2.169 for K8s prod). Required for origin_type=custom; ignored for s3.',
|
|
2635
2852
|
},
|
|
2636
2853
|
origin_port: {
|
|
2637
2854
|
type: 'number',
|
|
@@ -2641,6 +2858,18 @@ const TOOLS = [
|
|
|
2641
2858
|
type: 'string',
|
|
2642
2859
|
description: 'Origin protocol: http or https (default: https)',
|
|
2643
2860
|
},
|
|
2861
|
+
s3_bucket: {
|
|
2862
|
+
type: 'string',
|
|
2863
|
+
description: 'S3 artifacts bucket (origin_type=s3, e.g. steldris-sites-prod). Required for s3 mode.',
|
|
2864
|
+
},
|
|
2865
|
+
s3_region: {
|
|
2866
|
+
type: 'string',
|
|
2867
|
+
description: 'S3 bucket region (origin_type=s3, e.g. us-east-1). Required for s3 mode.',
|
|
2868
|
+
},
|
|
2869
|
+
s3_origin_path: {
|
|
2870
|
+
type: 'string',
|
|
2871
|
+
description: 'CloudFront OriginPath — the per-tenant key prefix (origin_type=s3, e.g. /acme).',
|
|
2872
|
+
},
|
|
2644
2873
|
website_id: {
|
|
2645
2874
|
type: 'number',
|
|
2646
2875
|
description: 'Link to existing FCP website ID',
|
|
@@ -2651,14 +2880,14 @@ const TOOLS = [
|
|
|
2651
2880
|
},
|
|
2652
2881
|
cache_profile: {
|
|
2653
2882
|
type: 'string',
|
|
2654
|
-
description: 'Cache profile: standard, aggressive, minimal, none (default: standard)',
|
|
2883
|
+
description: 'Cache profile: standard, aggressive, minimal, none (default: standard; s3 defaults to aggressive)',
|
|
2655
2884
|
},
|
|
2656
2885
|
notes: {
|
|
2657
2886
|
type: 'string',
|
|
2658
2887
|
description: 'Notes about this domain',
|
|
2659
2888
|
},
|
|
2660
2889
|
},
|
|
2661
|
-
required: ['domain'
|
|
2890
|
+
required: ['domain'],
|
|
2662
2891
|
},
|
|
2663
2892
|
},
|
|
2664
2893
|
{
|
|
@@ -2730,6 +2959,126 @@ const TOOLS = [
|
|
|
2730
2959
|
},
|
|
2731
2960
|
},
|
|
2732
2961
|
// ============================================================================
|
|
2962
|
+
// Trusted IP Range Tools (FRUPRI-761)
|
|
2963
|
+
// ============================================================================
|
|
2964
|
+
{
|
|
2965
|
+
name: 'fcp_trusted_ip_list_ranges',
|
|
2966
|
+
description: 'List trusted IP ranges (CIDR entries). Scope to a group by id or slug, or omit group to list entries for every group (each tagged with group_slug). By default only approved, non-expired entries are returned.',
|
|
2967
|
+
inputSchema: {
|
|
2968
|
+
type: 'object',
|
|
2969
|
+
properties: {
|
|
2970
|
+
group: {
|
|
2971
|
+
type: 'string',
|
|
2972
|
+
description: 'Group id or slug to scope to (optional; omit for all groups)',
|
|
2973
|
+
},
|
|
2974
|
+
include_pending: {
|
|
2975
|
+
type: 'boolean',
|
|
2976
|
+
description: 'Include entries awaiting approval (default false)',
|
|
2977
|
+
},
|
|
2978
|
+
include_expired: {
|
|
2979
|
+
type: 'boolean',
|
|
2980
|
+
description: 'Include entries past their expires_at (default false)',
|
|
2981
|
+
},
|
|
2982
|
+
},
|
|
2983
|
+
},
|
|
2984
|
+
},
|
|
2985
|
+
{
|
|
2986
|
+
name: 'fcp_trusted_ip_add_range',
|
|
2987
|
+
description: 'Add a CIDR to a trusted IP group. If the CIDR overlaps an existing entry the request is rejected with code=overlap unless override_reason (>=10 chars) is supplied to force-insert.',
|
|
2988
|
+
inputSchema: {
|
|
2989
|
+
type: 'object',
|
|
2990
|
+
properties: {
|
|
2991
|
+
group_id: { type: 'number', description: 'Target group id' },
|
|
2992
|
+
cidr: {
|
|
2993
|
+
type: 'string',
|
|
2994
|
+
description: 'CIDR or bare IP (e.g. 73.241.16.0/24 or 73.241.16.5)',
|
|
2995
|
+
},
|
|
2996
|
+
label: { type: 'string', description: 'Human label for the range' },
|
|
2997
|
+
purpose: {
|
|
2998
|
+
type: 'string',
|
|
2999
|
+
enum: ['employee-home', 'office', 'vendor', 'ci-runner', 'migration', 'partner', 'other'],
|
|
3000
|
+
description: 'Structured purpose',
|
|
3001
|
+
},
|
|
3002
|
+
ticket_ref: { type: 'string', description: 'Associated ticket reference' },
|
|
3003
|
+
expires_at: {
|
|
3004
|
+
type: 'string',
|
|
3005
|
+
description: 'ISO-8601 expiry timestamp, or null for permanent',
|
|
3006
|
+
},
|
|
3007
|
+
override_reason: {
|
|
3008
|
+
type: 'string',
|
|
3009
|
+
description: 'Reason (>=10 chars) to force-insert past an overlap',
|
|
3010
|
+
},
|
|
3011
|
+
},
|
|
3012
|
+
required: ['group_id', 'cidr'],
|
|
3013
|
+
},
|
|
3014
|
+
},
|
|
3015
|
+
{
|
|
3016
|
+
name: 'fcp_trusted_ip_update_range',
|
|
3017
|
+
description: 'Update mutable fields of a trusted IP entry (label, purpose, ticket_ref, expires_at, approval_state). The CIDR itself is immutable; remove and re-add to change it.',
|
|
3018
|
+
inputSchema: {
|
|
3019
|
+
type: 'object',
|
|
3020
|
+
properties: {
|
|
3021
|
+
id: { type: 'number', description: 'Entry id to update' },
|
|
3022
|
+
label: { type: 'string', description: 'New label' },
|
|
3023
|
+
purpose: {
|
|
3024
|
+
type: 'string',
|
|
3025
|
+
enum: ['employee-home', 'office', 'vendor', 'ci-runner', 'migration', 'partner', 'other'],
|
|
3026
|
+
description: 'New purpose',
|
|
3027
|
+
},
|
|
3028
|
+
ticket_ref: { type: 'string', description: 'New ticket reference' },
|
|
3029
|
+
expires_at: {
|
|
3030
|
+
type: 'string',
|
|
3031
|
+
description: 'New ISO-8601 expiry, or null for permanent',
|
|
3032
|
+
},
|
|
3033
|
+
approval_state: {
|
|
3034
|
+
type: 'string',
|
|
3035
|
+
enum: ['approved', 'pending', 'denied'],
|
|
3036
|
+
description: 'New approval state',
|
|
3037
|
+
},
|
|
3038
|
+
},
|
|
3039
|
+
required: ['id'],
|
|
3040
|
+
},
|
|
3041
|
+
},
|
|
3042
|
+
{
|
|
3043
|
+
name: 'fcp_trusted_ip_remove_range',
|
|
3044
|
+
description: 'Remove a trusted IP entry by id. The next sync run withdraws the CIDR from any live ingress/WAF targets the group propagates to.',
|
|
3045
|
+
inputSchema: {
|
|
3046
|
+
type: 'object',
|
|
3047
|
+
properties: {
|
|
3048
|
+
id: { type: 'number', description: 'Entry id to remove' },
|
|
3049
|
+
},
|
|
3050
|
+
required: ['id'],
|
|
3051
|
+
},
|
|
3052
|
+
},
|
|
3053
|
+
{
|
|
3054
|
+
name: 'fcp_trusted_ip_export',
|
|
3055
|
+
description: 'Export approved, non-expired CIDRs for a group (or all groups) in a consumable form. format=plain (default) joins CIDRs with separator (use separator=comma for an ingress-nginx whitelist-source-range value); format=json and format=yaml are also supported.',
|
|
3056
|
+
inputSchema: {
|
|
3057
|
+
type: 'object',
|
|
3058
|
+
properties: {
|
|
3059
|
+
format: {
|
|
3060
|
+
type: 'string',
|
|
3061
|
+
enum: ['plain', 'json', 'yaml'],
|
|
3062
|
+
description: 'Output format (default plain)',
|
|
3063
|
+
},
|
|
3064
|
+
group: {
|
|
3065
|
+
type: 'string',
|
|
3066
|
+
description: 'Group id or slug to scope to (optional; omit for all groups)',
|
|
3067
|
+
},
|
|
3068
|
+
purpose: {
|
|
3069
|
+
type: 'string',
|
|
3070
|
+
enum: ['employee-home', 'office', 'vendor', 'ci-runner', 'migration', 'partner', 'other'],
|
|
3071
|
+
description: 'Optional purpose filter',
|
|
3072
|
+
},
|
|
3073
|
+
separator: {
|
|
3074
|
+
type: 'string',
|
|
3075
|
+
enum: ['newline', 'comma', 'space'],
|
|
3076
|
+
description: 'Separator for plain format (default newline)',
|
|
3077
|
+
},
|
|
3078
|
+
},
|
|
3079
|
+
},
|
|
3080
|
+
},
|
|
3081
|
+
// ============================================================================
|
|
2733
3082
|
// Backup Management Tools
|
|
2734
3083
|
// ============================================================================
|
|
2735
3084
|
{
|
|
@@ -3083,6 +3432,344 @@ const TOOLS = [
|
|
|
3083
3432
|
required: ['siteId'],
|
|
3084
3433
|
},
|
|
3085
3434
|
},
|
|
3435
|
+
// ===================== Alerting: incidents (alerts) =====================
|
|
3436
|
+
{
|
|
3437
|
+
name: 'fcp_list_alerts',
|
|
3438
|
+
description: 'List alerts (incidents). Defaults to ACTIVE alerts (status firing,acknowledged). Pass status="resolved" for past alerts, or status="all" for everything. Alerts come from alertmanager, GCP monitoring, uptimerobot, cluster_health, or manual creation. Supports filtering by source, severity, cluster, namespace, domain, website, and time range.',
|
|
3439
|
+
inputSchema: {
|
|
3440
|
+
type: 'object',
|
|
3441
|
+
properties: {
|
|
3442
|
+
status: {
|
|
3443
|
+
type: 'string',
|
|
3444
|
+
description: 'Comma-separated statuses. Default "firing,acknowledged" (active). Use "resolved" for past, "silenced", or "all" for everything.',
|
|
3445
|
+
},
|
|
3446
|
+
source: {
|
|
3447
|
+
type: 'string',
|
|
3448
|
+
enum: ['alertmanager', 'gcp_monitoring', 'uptimerobot', 'cluster_health', 'manual'],
|
|
3449
|
+
description: 'Filter by alert source',
|
|
3450
|
+
},
|
|
3451
|
+
severity: {
|
|
3452
|
+
type: 'string',
|
|
3453
|
+
description: 'Comma-separated severities: critical, high, medium, low, info',
|
|
3454
|
+
},
|
|
3455
|
+
cluster: { type: 'string', description: 'Filter by cluster name' },
|
|
3456
|
+
namespace: { type: 'string', description: 'Filter by namespace' },
|
|
3457
|
+
domain: { type: 'string', description: 'Filter by domain (partial match)' },
|
|
3458
|
+
websiteId: { type: 'number', description: 'Filter by FCP website ID' },
|
|
3459
|
+
environment: { type: 'string', description: 'Filter by site environment (production, staging)' },
|
|
3460
|
+
from: { type: 'string', description: 'Start of time range (ISO timestamp), filters started_at' },
|
|
3461
|
+
to: { type: 'string', description: 'End of time range (ISO timestamp), filters started_at' },
|
|
3462
|
+
page: { type: 'number', description: 'Page number (default 1)' },
|
|
3463
|
+
limit: { type: 'number', description: 'Results per page (default 50, max 200)' },
|
|
3464
|
+
},
|
|
3465
|
+
required: [],
|
|
3466
|
+
},
|
|
3467
|
+
},
|
|
3468
|
+
{
|
|
3469
|
+
name: 'fcp_get_alert',
|
|
3470
|
+
description: 'Get full detail for a single alert (incident) by ID, including linked site/account info.',
|
|
3471
|
+
inputSchema: {
|
|
3472
|
+
type: 'object',
|
|
3473
|
+
properties: {
|
|
3474
|
+
id: { type: 'number', description: 'The alert (incident) ID' },
|
|
3475
|
+
},
|
|
3476
|
+
required: ['id'],
|
|
3477
|
+
},
|
|
3478
|
+
},
|
|
3479
|
+
{
|
|
3480
|
+
name: 'fcp_create_alert',
|
|
3481
|
+
description: 'Create a MANUAL alert (incident). Source is always "manual". Use this to manually raise an alert that did not come from automated monitoring. Returns the created alert with status "firing".',
|
|
3482
|
+
inputSchema: {
|
|
3483
|
+
type: 'object',
|
|
3484
|
+
properties: {
|
|
3485
|
+
alertName: { type: 'string', description: 'Short alert name/title (required)' },
|
|
3486
|
+
severity: {
|
|
3487
|
+
type: 'string',
|
|
3488
|
+
enum: ['critical', 'high', 'medium', 'low', 'info'],
|
|
3489
|
+
description: 'Severity (default: medium)',
|
|
3490
|
+
},
|
|
3491
|
+
summary: { type: 'string', description: 'Human-readable summary of the alert' },
|
|
3492
|
+
cluster: { type: 'string', description: 'Affected cluster, if any' },
|
|
3493
|
+
namespace: { type: 'string', description: 'Affected namespace, if any' },
|
|
3494
|
+
domain: { type: 'string', description: 'Affected domain, if any' },
|
|
3495
|
+
websiteId: { type: 'number', description: 'Link to an FCP website ID, if applicable' },
|
|
3496
|
+
},
|
|
3497
|
+
required: ['alertName'],
|
|
3498
|
+
},
|
|
3499
|
+
},
|
|
3500
|
+
{
|
|
3501
|
+
name: 'fcp_update_alert',
|
|
3502
|
+
description: 'Update an alert (incident): acknowledge, silence, resolve (CLOSE), add admin notes, link a site, or link a Unroo task. To CLOSE an alert, set status="resolved". Silencing requires silencedUntil.',
|
|
3503
|
+
inputSchema: {
|
|
3504
|
+
type: 'object',
|
|
3505
|
+
properties: {
|
|
3506
|
+
id: { type: 'number', description: 'The alert (incident) ID' },
|
|
3507
|
+
status: {
|
|
3508
|
+
type: 'string',
|
|
3509
|
+
enum: ['acknowledged', 'resolved', 'silenced'],
|
|
3510
|
+
description: 'New status. "resolved" closes the alert; "silenced" requires silencedUntil.',
|
|
3511
|
+
},
|
|
3512
|
+
acknowledgedBy: { type: 'string', description: 'Who acknowledged (when status=acknowledged)' },
|
|
3513
|
+
silencedUntil: { type: 'string', description: 'ISO timestamp to silence until (required when status=silenced)' },
|
|
3514
|
+
silencedBy: { type: 'string', description: 'Who silenced (when status=silenced)' },
|
|
3515
|
+
adminNotes: { type: 'string', description: 'Internal admin notes' },
|
|
3516
|
+
websiteId: { type: 'number', description: 'Link/relink to an FCP website ID' },
|
|
3517
|
+
unrooTaskId: { type: 'number', description: 'Link to a Unroo task ID' },
|
|
3518
|
+
},
|
|
3519
|
+
required: ['id'],
|
|
3520
|
+
},
|
|
3521
|
+
},
|
|
3522
|
+
{
|
|
3523
|
+
name: 'fcp_get_alert_stats',
|
|
3524
|
+
description: 'Get alert summary statistics for the last 7 days: counts by status, by source, critical-active count, and per-cluster breakdown.',
|
|
3525
|
+
inputSchema: {
|
|
3526
|
+
type: 'object',
|
|
3527
|
+
properties: {},
|
|
3528
|
+
required: [],
|
|
3529
|
+
},
|
|
3530
|
+
},
|
|
3531
|
+
// ===================== Alerting: rules (notification config) =====================
|
|
3532
|
+
{
|
|
3533
|
+
name: 'fcp_list_alert_rules',
|
|
3534
|
+
description: 'List client alert rules (notification configuration). Optionally filter by account.',
|
|
3535
|
+
inputSchema: {
|
|
3536
|
+
type: 'object',
|
|
3537
|
+
properties: {
|
|
3538
|
+
accountId: { type: 'number', description: 'Filter rules by account ID' },
|
|
3539
|
+
},
|
|
3540
|
+
required: [],
|
|
3541
|
+
},
|
|
3542
|
+
},
|
|
3543
|
+
{
|
|
3544
|
+
name: 'fcp_get_alert_rule',
|
|
3545
|
+
description: 'Get a single alert rule by ID, with account and website context.',
|
|
3546
|
+
inputSchema: {
|
|
3547
|
+
type: 'object',
|
|
3548
|
+
properties: {
|
|
3549
|
+
ruleId: { type: 'number', description: 'The alert rule ID' },
|
|
3550
|
+
},
|
|
3551
|
+
required: ['ruleId'],
|
|
3552
|
+
},
|
|
3553
|
+
},
|
|
3554
|
+
{
|
|
3555
|
+
name: 'fcp_create_alert_rule',
|
|
3556
|
+
description: 'Create a client alert rule defining which events trigger notifications, on which channels, with flapping/business-hours controls.',
|
|
3557
|
+
inputSchema: {
|
|
3558
|
+
type: 'object',
|
|
3559
|
+
properties: {
|
|
3560
|
+
accountId: { type: 'number', description: 'Account this rule belongs to (required)' },
|
|
3561
|
+
websiteId: { type: 'number', description: 'Specific website, or omit/null for all sites in the account' },
|
|
3562
|
+
name: { type: 'string', description: 'Rule name (required)' },
|
|
3563
|
+
description: { type: 'string', description: 'Optional description' },
|
|
3564
|
+
eventType: {
|
|
3565
|
+
type: 'string',
|
|
3566
|
+
enum: ['uptime_down', 'uptime_up', 'maintenance', 'cve', 'ssl_expiry', 'domain_expiry'],
|
|
3567
|
+
description: 'Event type that triggers this rule (required)',
|
|
3568
|
+
},
|
|
3569
|
+
severityThreshold: {
|
|
3570
|
+
type: 'string',
|
|
3571
|
+
enum: ['critical', 'high', 'medium', 'low', 'info'],
|
|
3572
|
+
description: 'Minimum severity to trigger (default: info)',
|
|
3573
|
+
},
|
|
3574
|
+
notifyChannels: {
|
|
3575
|
+
type: 'array',
|
|
3576
|
+
items: { type: 'string', enum: ['email', 'teams', 'slack', 'sms'] },
|
|
3577
|
+
description: 'Channels to notify on',
|
|
3578
|
+
},
|
|
3579
|
+
delaySeconds: { type: 'number', description: 'Delay before notifying (flap prevention)' },
|
|
3580
|
+
cooldownSeconds: { type: 'number', description: 'Cooldown between repeat notifications' },
|
|
3581
|
+
businessHoursOnly: { type: 'boolean', description: 'Only notify during business hours' },
|
|
3582
|
+
businessHoursStart: { type: 'string', description: 'Business hours start (e.g. "09:00")' },
|
|
3583
|
+
businessHoursEnd: { type: 'string', description: 'Business hours end (e.g. "17:00")' },
|
|
3584
|
+
businessHoursTimezone: { type: 'string', description: 'IANA timezone for business hours' },
|
|
3585
|
+
createdBy: { type: 'string', description: 'Who created the rule' },
|
|
3586
|
+
},
|
|
3587
|
+
required: ['accountId', 'name', 'eventType'],
|
|
3588
|
+
},
|
|
3589
|
+
},
|
|
3590
|
+
{
|
|
3591
|
+
name: 'fcp_update_alert_rule',
|
|
3592
|
+
description: 'Update an existing alert rule. Only provided fields are changed.',
|
|
3593
|
+
inputSchema: {
|
|
3594
|
+
type: 'object',
|
|
3595
|
+
properties: {
|
|
3596
|
+
ruleId: { type: 'number', description: 'The alert rule ID (required)' },
|
|
3597
|
+
name: { type: 'string', description: 'Rule name' },
|
|
3598
|
+
description: { type: 'string', description: 'Description' },
|
|
3599
|
+
severityThreshold: {
|
|
3600
|
+
type: 'string',
|
|
3601
|
+
enum: ['critical', 'high', 'medium', 'low', 'info'],
|
|
3602
|
+
description: 'Minimum severity to trigger',
|
|
3603
|
+
},
|
|
3604
|
+
notifyChannels: {
|
|
3605
|
+
type: 'array',
|
|
3606
|
+
items: { type: 'string', enum: ['email', 'teams', 'slack', 'sms'] },
|
|
3607
|
+
description: 'Channels to notify on',
|
|
3608
|
+
},
|
|
3609
|
+
delaySeconds: { type: 'number', description: 'Delay before notifying' },
|
|
3610
|
+
cooldownSeconds: { type: 'number', description: 'Cooldown between repeat notifications' },
|
|
3611
|
+
businessHoursOnly: { type: 'boolean', description: 'Only notify during business hours' },
|
|
3612
|
+
businessHoursStart: { type: 'string', description: 'Business hours start' },
|
|
3613
|
+
businessHoursEnd: { type: 'string', description: 'Business hours end' },
|
|
3614
|
+
businessHoursTimezone: { type: 'string', description: 'IANA timezone' },
|
|
3615
|
+
isActive: { type: 'boolean', description: 'Enable/disable the rule' },
|
|
3616
|
+
updatedBy: { type: 'string', description: 'Who updated the rule' },
|
|
3617
|
+
},
|
|
3618
|
+
required: ['ruleId'],
|
|
3619
|
+
},
|
|
3620
|
+
},
|
|
3621
|
+
{
|
|
3622
|
+
name: 'fcp_delete_alert_rule',
|
|
3623
|
+
description: 'Permanently DELETE an alert rule (hard delete). Requires admin. This cannot be undone.',
|
|
3624
|
+
inputSchema: {
|
|
3625
|
+
type: 'object',
|
|
3626
|
+
properties: {
|
|
3627
|
+
ruleId: { type: 'number', description: 'The alert rule ID to delete' },
|
|
3628
|
+
},
|
|
3629
|
+
required: ['ruleId'],
|
|
3630
|
+
},
|
|
3631
|
+
},
|
|
3632
|
+
// ===================== Alerting: outages =====================
|
|
3633
|
+
{
|
|
3634
|
+
name: 'fcp_list_outages',
|
|
3635
|
+
description: 'List active outages (admin outage tracking). Filter by status (active/resolved/all), account, and limit.',
|
|
3636
|
+
inputSchema: {
|
|
3637
|
+
type: 'object',
|
|
3638
|
+
properties: {
|
|
3639
|
+
status: { type: 'string', description: 'Filter: active, resolved, or all (default: active)' },
|
|
3640
|
+
accountId: { type: 'number', description: 'Filter by account ID' },
|
|
3641
|
+
limit: { type: 'number', description: 'Max results' },
|
|
3642
|
+
},
|
|
3643
|
+
required: [],
|
|
3644
|
+
},
|
|
3645
|
+
},
|
|
3646
|
+
{
|
|
3647
|
+
name: 'fcp_get_outage',
|
|
3648
|
+
description: 'Get a single outage by ID, with its timeline updates, notification history, and affected client contacts.',
|
|
3649
|
+
inputSchema: {
|
|
3650
|
+
type: 'object',
|
|
3651
|
+
properties: {
|
|
3652
|
+
outageId: { type: 'number', description: 'The outage ID' },
|
|
3653
|
+
},
|
|
3654
|
+
required: ['outageId'],
|
|
3655
|
+
},
|
|
3656
|
+
},
|
|
3657
|
+
{
|
|
3658
|
+
name: 'fcp_create_outage',
|
|
3659
|
+
description: 'Create an outage record for centralized outage management, optionally linked to an incident.',
|
|
3660
|
+
inputSchema: {
|
|
3661
|
+
type: 'object',
|
|
3662
|
+
properties: {
|
|
3663
|
+
incidentId: { type: 'number', description: 'Linked incident ID (from downtime_incidents)' },
|
|
3664
|
+
websiteId: { type: 'number', description: 'Affected website ID' },
|
|
3665
|
+
accountId: { type: 'number', description: 'Affected account ID' },
|
|
3666
|
+
startedAt: { type: 'string', description: 'Outage start (ISO timestamp)' },
|
|
3667
|
+
domain: { type: 'string', description: 'Affected domain' },
|
|
3668
|
+
environment: { type: 'string', description: 'Environment (production, staging)' },
|
|
3669
|
+
status: {
|
|
3670
|
+
type: 'string',
|
|
3671
|
+
enum: ['active', 'investigating', 'identified', 'monitoring', 'resolved'],
|
|
3672
|
+
description: 'Outage status (default: active)',
|
|
3673
|
+
},
|
|
3674
|
+
severity: {
|
|
3675
|
+
type: 'string',
|
|
3676
|
+
enum: ['critical', 'high', 'medium', 'low', 'info'],
|
|
3677
|
+
description: 'Severity',
|
|
3678
|
+
},
|
|
3679
|
+
likelyCause: { type: 'string', description: 'Likely cause description' },
|
|
3680
|
+
},
|
|
3681
|
+
required: [],
|
|
3682
|
+
},
|
|
3683
|
+
},
|
|
3684
|
+
{
|
|
3685
|
+
name: 'fcp_update_outage',
|
|
3686
|
+
description: 'Update an outage: status, severity, likely cause, admin notes, assignee. Optionally append a timeline updateMessage. ⚠️ Setting notifyClients=true SENDS REAL NOTIFICATIONS to affected clients.',
|
|
3687
|
+
inputSchema: {
|
|
3688
|
+
type: 'object',
|
|
3689
|
+
properties: {
|
|
3690
|
+
outageId: { type: 'number', description: 'The outage ID (required)' },
|
|
3691
|
+
status: {
|
|
3692
|
+
type: 'string',
|
|
3693
|
+
enum: ['active', 'investigating', 'identified', 'monitoring', 'resolved'],
|
|
3694
|
+
description: 'New outage status',
|
|
3695
|
+
},
|
|
3696
|
+
severity: {
|
|
3697
|
+
type: 'string',
|
|
3698
|
+
enum: ['critical', 'high', 'medium', 'low', 'info'],
|
|
3699
|
+
description: 'New severity',
|
|
3700
|
+
},
|
|
3701
|
+
likelyCause: { type: 'string', description: 'Likely cause' },
|
|
3702
|
+
adminNotes: { type: 'string', description: 'Internal admin notes' },
|
|
3703
|
+
assignedTo: { type: 'string', description: 'Assignee' },
|
|
3704
|
+
updateMessage: { type: 'string', description: 'Timeline update message to append' },
|
|
3705
|
+
notifyClients: {
|
|
3706
|
+
type: 'boolean',
|
|
3707
|
+
description: '⚠️ If true, sends real notifications to affected clients. Default false.',
|
|
3708
|
+
},
|
|
3709
|
+
updatedBy: { type: 'string', description: 'Who made the update' },
|
|
3710
|
+
confirm: {
|
|
3711
|
+
type: 'boolean',
|
|
3712
|
+
description: 'Safety gate for client notifications. Only consulted when notifyClients=true: must be explicitly true to actually send. notifyClients=true without confirm=true makes the tool refuse (the rest of the update is NOT applied). No effect when notifyClients is absent/false.',
|
|
3713
|
+
},
|
|
3714
|
+
},
|
|
3715
|
+
required: ['outageId'],
|
|
3716
|
+
},
|
|
3717
|
+
},
|
|
3718
|
+
{
|
|
3719
|
+
name: 'fcp_resolve_outage',
|
|
3720
|
+
description: 'Resolve and close an outage (soft resolve — sets status to resolved and stamps resolved_at). Adds a resolution timeline entry.',
|
|
3721
|
+
inputSchema: {
|
|
3722
|
+
type: 'object',
|
|
3723
|
+
properties: {
|
|
3724
|
+
outageId: { type: 'number', description: 'The outage ID to resolve' },
|
|
3725
|
+
},
|
|
3726
|
+
required: ['outageId'],
|
|
3727
|
+
},
|
|
3728
|
+
},
|
|
3729
|
+
// ===================== Alerting: bulk client notifications =====================
|
|
3730
|
+
{
|
|
3731
|
+
name: 'fcp_bulk_notify_clients',
|
|
3732
|
+
description: '⚠️ SENDS REAL NOTIFICATIONS (email/Teams/Slack/SMS) to all clients affected by the given outages. Requires admin. Use deliberately — this contacts real people.',
|
|
3733
|
+
inputSchema: {
|
|
3734
|
+
type: 'object',
|
|
3735
|
+
properties: {
|
|
3736
|
+
outageIds: {
|
|
3737
|
+
type: 'array',
|
|
3738
|
+
items: { type: 'number' },
|
|
3739
|
+
description: 'Non-empty array of outage IDs whose affected clients should be notified',
|
|
3740
|
+
},
|
|
3741
|
+
notificationType: {
|
|
3742
|
+
type: 'string',
|
|
3743
|
+
enum: ['outage_initial', 'outage_update', 'recovery'],
|
|
3744
|
+
description: 'Type of notification',
|
|
3745
|
+
},
|
|
3746
|
+
subject: { type: 'string', description: 'Notification subject (required)' },
|
|
3747
|
+
message: { type: 'string', description: 'Notification body (required)' },
|
|
3748
|
+
channels: {
|
|
3749
|
+
type: 'array',
|
|
3750
|
+
items: { type: 'string', enum: ['email', 'teams', 'slack', 'sms'] },
|
|
3751
|
+
description: 'Channels to send on (default: ["email"])',
|
|
3752
|
+
},
|
|
3753
|
+
sentBy: { type: 'string', description: 'Who sent the notification' },
|
|
3754
|
+
confirm: {
|
|
3755
|
+
type: 'boolean',
|
|
3756
|
+
description: 'REQUIRED safety gate. Must be explicitly true to actually dispatch. Omitting or false makes the tool refuse and report who would be contacted — call again with confirm=true only after a human has approved sending.',
|
|
3757
|
+
},
|
|
3758
|
+
},
|
|
3759
|
+
required: ['outageIds', 'notificationType', 'subject', 'message'],
|
|
3760
|
+
},
|
|
3761
|
+
},
|
|
3762
|
+
{
|
|
3763
|
+
name: 'fcp_get_bulk_notify_history',
|
|
3764
|
+
description: 'Get history of bulk client notifications (batches sent), most recent first.',
|
|
3765
|
+
inputSchema: {
|
|
3766
|
+
type: 'object',
|
|
3767
|
+
properties: {
|
|
3768
|
+
limit: { type: 'number', description: 'Max results (default 20)' },
|
|
3769
|
+
},
|
|
3770
|
+
required: [],
|
|
3771
|
+
},
|
|
3772
|
+
},
|
|
3086
3773
|
];
|
|
3087
3774
|
// Register tool handlers
|
|
3088
3775
|
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
@@ -4086,9 +4773,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
4086
4773
|
};
|
|
4087
4774
|
}
|
|
4088
4775
|
case 'fcp_shield_add_domain': {
|
|
4089
|
-
const { domain, origin_host, origin_port, origin_protocol, website_id, account_id, cache_profile, notes } = args;
|
|
4776
|
+
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;
|
|
4090
4777
|
const result = await client.shieldAddDomain({
|
|
4091
|
-
domain, origin_host, origin_port, origin_protocol, website_id, account_id, cache_profile, notes,
|
|
4778
|
+
domain, origin_type, origin_host, origin_port, origin_protocol, s3_bucket, s3_region, s3_origin_path, website_id, account_id, cache_profile, notes,
|
|
4092
4779
|
});
|
|
4093
4780
|
return {
|
|
4094
4781
|
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
|
|
@@ -4122,6 +4809,72 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
4122
4809
|
};
|
|
4123
4810
|
}
|
|
4124
4811
|
// ============================================================================
|
|
4812
|
+
// Trusted IP Range Handlers (FRUPRI-761)
|
|
4813
|
+
// ============================================================================
|
|
4814
|
+
case 'fcp_trusted_ip_list_ranges': {
|
|
4815
|
+
const { group, include_pending, include_expired } = args;
|
|
4816
|
+
const result = await client.trustedIpListRanges({
|
|
4817
|
+
group,
|
|
4818
|
+
include_pending,
|
|
4819
|
+
include_expired,
|
|
4820
|
+
});
|
|
4821
|
+
return {
|
|
4822
|
+
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
|
|
4823
|
+
};
|
|
4824
|
+
}
|
|
4825
|
+
case 'fcp_trusted_ip_add_range': {
|
|
4826
|
+
const { group_id, cidr, label, purpose, ticket_ref, expires_at, override_reason, } = args;
|
|
4827
|
+
const result = await client.trustedIpAddRange({
|
|
4828
|
+
group_id,
|
|
4829
|
+
cidr,
|
|
4830
|
+
label,
|
|
4831
|
+
purpose,
|
|
4832
|
+
ticket_ref,
|
|
4833
|
+
expires_at,
|
|
4834
|
+
override_reason,
|
|
4835
|
+
});
|
|
4836
|
+
return {
|
|
4837
|
+
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
|
|
4838
|
+
};
|
|
4839
|
+
}
|
|
4840
|
+
case 'fcp_trusted_ip_update_range': {
|
|
4841
|
+
const { id, label, purpose, ticket_ref, expires_at, approval_state } = args;
|
|
4842
|
+
const updates = {};
|
|
4843
|
+
if (label !== undefined)
|
|
4844
|
+
updates.label = label;
|
|
4845
|
+
if (purpose !== undefined)
|
|
4846
|
+
updates.purpose = purpose;
|
|
4847
|
+
if (ticket_ref !== undefined)
|
|
4848
|
+
updates.ticket_ref = ticket_ref;
|
|
4849
|
+
if (expires_at !== undefined)
|
|
4850
|
+
updates.expires_at = expires_at;
|
|
4851
|
+
if (approval_state !== undefined)
|
|
4852
|
+
updates.approval_state = approval_state;
|
|
4853
|
+
const result = await client.trustedIpUpdateRange(id, updates);
|
|
4854
|
+
return {
|
|
4855
|
+
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
|
|
4856
|
+
};
|
|
4857
|
+
}
|
|
4858
|
+
case 'fcp_trusted_ip_remove_range': {
|
|
4859
|
+
const { id } = args;
|
|
4860
|
+
const result = await client.trustedIpRemoveRange(id);
|
|
4861
|
+
return {
|
|
4862
|
+
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
|
|
4863
|
+
};
|
|
4864
|
+
}
|
|
4865
|
+
case 'fcp_trusted_ip_export': {
|
|
4866
|
+
const { format, group, purpose, separator } = args;
|
|
4867
|
+
const text = await client.trustedIpExport({
|
|
4868
|
+
format,
|
|
4869
|
+
group,
|
|
4870
|
+
purpose,
|
|
4871
|
+
separator,
|
|
4872
|
+
});
|
|
4873
|
+
return {
|
|
4874
|
+
content: [{ type: 'text', text }],
|
|
4875
|
+
};
|
|
4876
|
+
}
|
|
4877
|
+
// ============================================================================
|
|
4125
4878
|
// Backup Management Handlers
|
|
4126
4879
|
// ============================================================================
|
|
4127
4880
|
case 'fcp_backup_list_sites': {
|
|
@@ -4287,6 +5040,180 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
4287
5040
|
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
|
|
4288
5041
|
};
|
|
4289
5042
|
}
|
|
5043
|
+
// ===================== Alerting: incidents (alerts) =====================
|
|
5044
|
+
case 'fcp_list_alerts': {
|
|
5045
|
+
const result = await client.listAlerts(args);
|
|
5046
|
+
return {
|
|
5047
|
+
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
|
|
5048
|
+
};
|
|
5049
|
+
}
|
|
5050
|
+
case 'fcp_get_alert': {
|
|
5051
|
+
const { id } = args;
|
|
5052
|
+
const result = await client.getAlert(id);
|
|
5053
|
+
return {
|
|
5054
|
+
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
|
|
5055
|
+
};
|
|
5056
|
+
}
|
|
5057
|
+
case 'fcp_create_alert': {
|
|
5058
|
+
const { alertName, severity, summary, cluster, namespace, domain, websiteId } = args;
|
|
5059
|
+
const result = await client.createAlert({ alertName, severity, summary, cluster, namespace, domain, websiteId });
|
|
5060
|
+
return {
|
|
5061
|
+
content: [
|
|
5062
|
+
{
|
|
5063
|
+
type: 'text',
|
|
5064
|
+
text: JSON.stringify({ success: true, message: `Alert created: "${alertName}"`, alert: result?.incident ?? result }, null, 2),
|
|
5065
|
+
},
|
|
5066
|
+
],
|
|
5067
|
+
};
|
|
5068
|
+
}
|
|
5069
|
+
case 'fcp_update_alert': {
|
|
5070
|
+
const { id, ...updates } = args;
|
|
5071
|
+
const result = await client.updateAlert(id, updates);
|
|
5072
|
+
const verb = updates.status === 'resolved' ? 'closed (resolved)' : `updated${updates.status ? ` to ${updates.status}` : ''}`;
|
|
5073
|
+
return {
|
|
5074
|
+
content: [
|
|
5075
|
+
{
|
|
5076
|
+
type: 'text',
|
|
5077
|
+
text: JSON.stringify({ success: true, message: `Alert ${id} ${verb}`, alert: result?.incident ?? result }, null, 2),
|
|
5078
|
+
},
|
|
5079
|
+
],
|
|
5080
|
+
};
|
|
5081
|
+
}
|
|
5082
|
+
case 'fcp_get_alert_stats': {
|
|
5083
|
+
const result = await client.getAlertStats();
|
|
5084
|
+
return {
|
|
5085
|
+
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
|
|
5086
|
+
};
|
|
5087
|
+
}
|
|
5088
|
+
// ===================== Alerting: rules =====================
|
|
5089
|
+
case 'fcp_list_alert_rules': {
|
|
5090
|
+
const { accountId } = args;
|
|
5091
|
+
const result = await client.listAlertRules(accountId);
|
|
5092
|
+
return {
|
|
5093
|
+
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
|
|
5094
|
+
};
|
|
5095
|
+
}
|
|
5096
|
+
case 'fcp_get_alert_rule': {
|
|
5097
|
+
const { ruleId } = args;
|
|
5098
|
+
const result = await client.getAlertRule(ruleId);
|
|
5099
|
+
return {
|
|
5100
|
+
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
|
|
5101
|
+
};
|
|
5102
|
+
}
|
|
5103
|
+
case 'fcp_create_alert_rule': {
|
|
5104
|
+
const result = await client.createAlertRule(args);
|
|
5105
|
+
return {
|
|
5106
|
+
content: [
|
|
5107
|
+
{
|
|
5108
|
+
type: 'text',
|
|
5109
|
+
text: JSON.stringify({ success: true, message: 'Alert rule created', rule: result?.rule ?? result }, null, 2),
|
|
5110
|
+
},
|
|
5111
|
+
],
|
|
5112
|
+
};
|
|
5113
|
+
}
|
|
5114
|
+
case 'fcp_update_alert_rule': {
|
|
5115
|
+
const { ruleId, ...updates } = args;
|
|
5116
|
+
const result = await client.updateAlertRule(ruleId, updates);
|
|
5117
|
+
return {
|
|
5118
|
+
content: [
|
|
5119
|
+
{
|
|
5120
|
+
type: 'text',
|
|
5121
|
+
text: JSON.stringify({ success: true, message: `Alert rule ${ruleId} updated`, rule: result?.rule ?? result }, null, 2),
|
|
5122
|
+
},
|
|
5123
|
+
],
|
|
5124
|
+
};
|
|
5125
|
+
}
|
|
5126
|
+
case 'fcp_delete_alert_rule': {
|
|
5127
|
+
const { ruleId } = args;
|
|
5128
|
+
const result = await client.deleteAlertRule(ruleId);
|
|
5129
|
+
return {
|
|
5130
|
+
content: [
|
|
5131
|
+
{
|
|
5132
|
+
type: 'text',
|
|
5133
|
+
text: JSON.stringify({ success: true, message: `Alert rule ${ruleId} deleted`, result }, null, 2),
|
|
5134
|
+
},
|
|
5135
|
+
],
|
|
5136
|
+
};
|
|
5137
|
+
}
|
|
5138
|
+
// ===================== Alerting: outages =====================
|
|
5139
|
+
case 'fcp_list_outages': {
|
|
5140
|
+
const result = await client.listOutages(args);
|
|
5141
|
+
return {
|
|
5142
|
+
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
|
|
5143
|
+
};
|
|
5144
|
+
}
|
|
5145
|
+
case 'fcp_get_outage': {
|
|
5146
|
+
const { outageId } = args;
|
|
5147
|
+
const result = await client.getOutage(outageId);
|
|
5148
|
+
return {
|
|
5149
|
+
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
|
|
5150
|
+
};
|
|
5151
|
+
}
|
|
5152
|
+
case 'fcp_create_outage': {
|
|
5153
|
+
const result = await client.createOutage(args);
|
|
5154
|
+
return {
|
|
5155
|
+
content: [
|
|
5156
|
+
{
|
|
5157
|
+
type: 'text',
|
|
5158
|
+
text: JSON.stringify({ success: true, message: 'Outage created', outage: result?.outage ?? result }, null, 2),
|
|
5159
|
+
},
|
|
5160
|
+
],
|
|
5161
|
+
};
|
|
5162
|
+
}
|
|
5163
|
+
case 'fcp_update_outage': {
|
|
5164
|
+
const { outageId, confirm, ...updates } = args;
|
|
5165
|
+
// Safety gate: a notifying update must be explicitly confirmed. We refuse the
|
|
5166
|
+
// WHOLE update (not just the notify) so a half-applied state can't occur.
|
|
5167
|
+
if (updates.notifyClients === true && confirm !== true) {
|
|
5168
|
+
throw new Error(`Refusing to update: outage ${outageId} update has notifyClients=true, which SENDS REAL notifications ` +
|
|
5169
|
+
`to affected clients. Re-call with confirm=true to apply this update and notify, or drop notifyClients ` +
|
|
5170
|
+
`to update silently.`);
|
|
5171
|
+
}
|
|
5172
|
+
const result = await client.updateOutage(outageId, updates);
|
|
5173
|
+
return {
|
|
5174
|
+
content: [
|
|
5175
|
+
{
|
|
5176
|
+
type: 'text',
|
|
5177
|
+
text: JSON.stringify({ success: true, message: `Outage ${outageId} updated`, outage: result?.outage ?? result }, null, 2),
|
|
5178
|
+
},
|
|
5179
|
+
],
|
|
5180
|
+
};
|
|
5181
|
+
}
|
|
5182
|
+
case 'fcp_resolve_outage': {
|
|
5183
|
+
const { outageId } = args;
|
|
5184
|
+
const result = await client.resolveOutage(outageId);
|
|
5185
|
+
return {
|
|
5186
|
+
content: [
|
|
5187
|
+
{
|
|
5188
|
+
type: 'text',
|
|
5189
|
+
text: JSON.stringify({ success: true, message: `Outage ${outageId} resolved`, result }, null, 2),
|
|
5190
|
+
},
|
|
5191
|
+
],
|
|
5192
|
+
};
|
|
5193
|
+
}
|
|
5194
|
+
// ===================== Alerting: bulk client notifications =====================
|
|
5195
|
+
case 'fcp_bulk_notify_clients': {
|
|
5196
|
+
const { outageIds, notificationType, subject, message, channels, sentBy, confirm } = args;
|
|
5197
|
+
// Safety gate: refuse to email/SMS real clients unless explicitly confirmed.
|
|
5198
|
+
if (confirm !== true) {
|
|
5199
|
+
const ch = (channels && channels.length ? channels : ['email']).join(', ');
|
|
5200
|
+
const n = Array.isArray(outageIds) ? outageIds.length : 0;
|
|
5201
|
+
throw new Error(`Refusing to send: fcp_bulk_notify_clients dispatches REAL ${ch} notifications to clients ` +
|
|
5202
|
+
`affected by ${n} outage(s) [${Array.isArray(outageIds) ? outageIds.join(', ') : ''}]. ` +
|
|
5203
|
+
`Confirm with a human, then re-call with confirm=true to proceed.`);
|
|
5204
|
+
}
|
|
5205
|
+
const result = await client.bulkNotifyClients({ outageIds, notificationType, subject, message, channels, sentBy });
|
|
5206
|
+
return {
|
|
5207
|
+
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
|
|
5208
|
+
};
|
|
5209
|
+
}
|
|
5210
|
+
case 'fcp_get_bulk_notify_history': {
|
|
5211
|
+
const { limit } = args;
|
|
5212
|
+
const result = await client.getBulkNotifyHistory(limit);
|
|
5213
|
+
return {
|
|
5214
|
+
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
|
|
5215
|
+
};
|
|
5216
|
+
}
|
|
4290
5217
|
default:
|
|
4291
5218
|
throw new Error(`Unknown tool: ${name}`);
|
|
4292
5219
|
}
|
package/dist/skills-sync.d.ts
CHANGED
|
@@ -105,6 +105,17 @@ export declare function discoverLocalSkills(skillsDir: string): Promise<ParsedSk
|
|
|
105
105
|
* Returns null if pushable, or a reason string if not.
|
|
106
106
|
*/
|
|
107
107
|
export declare function pushSkipReason(skill: ParsedSkill): string | null;
|
|
108
|
+
/**
|
|
109
|
+
* Validate a slug that arrives from the *remote* knowledge graph before it is
|
|
110
|
+
* used to build a filesystem path on pull. `node.source_id`/`node.name` are
|
|
111
|
+
* attacker-influenceable (any team member or compromised KG can author a skill
|
|
112
|
+
* node with an arbitrary source_id), so an unchecked slug like
|
|
113
|
+
* `../../../.config/autostart/x` would let `join(skillsDir, slug, ...)` escape
|
|
114
|
+
* the skills directory and write attacker content anywhere the user can write.
|
|
115
|
+
* A safe slug is a single path component: no separators, no `..`, no leading
|
|
116
|
+
* dot/tilde, no control/whitespace. Returns null if safe, else a reason string.
|
|
117
|
+
*/
|
|
118
|
+
export declare function pullSlugReason(slug: string): string | null;
|
|
108
119
|
/**
|
|
109
120
|
* Detect control characters (other than tab, LF, CR) in the body. The Unroo
|
|
110
121
|
* capture-skill endpoint 500s on null bytes — and they're never legitimate
|
package/dist/skills-sync.js
CHANGED
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
*/
|
|
25
25
|
import { promises as fs, existsSync, readFileSync, writeFileSync, mkdirSync, } from 'fs';
|
|
26
26
|
import { homedir } from 'os';
|
|
27
|
-
import { join, basename } from 'path';
|
|
27
|
+
import { join, basename, resolve, sep } from 'path';
|
|
28
28
|
import { createHash } from 'crypto';
|
|
29
29
|
// ---------------------------------------------------------------------------
|
|
30
30
|
// Constants
|
|
@@ -268,6 +268,28 @@ export function pushSkipReason(skill) {
|
|
|
268
268
|
}
|
|
269
269
|
return null;
|
|
270
270
|
}
|
|
271
|
+
/**
|
|
272
|
+
* Validate a slug that arrives from the *remote* knowledge graph before it is
|
|
273
|
+
* used to build a filesystem path on pull. `node.source_id`/`node.name` are
|
|
274
|
+
* attacker-influenceable (any team member or compromised KG can author a skill
|
|
275
|
+
* node with an arbitrary source_id), so an unchecked slug like
|
|
276
|
+
* `../../../.config/autostart/x` would let `join(skillsDir, slug, ...)` escape
|
|
277
|
+
* the skills directory and write attacker content anywhere the user can write.
|
|
278
|
+
* A safe slug is a single path component: no separators, no `..`, no leading
|
|
279
|
+
* dot/tilde, no control/whitespace. Returns null if safe, else a reason string.
|
|
280
|
+
*/
|
|
281
|
+
export function pullSlugReason(slug) {
|
|
282
|
+
if (!slug)
|
|
283
|
+
return 'empty slug';
|
|
284
|
+
// Single, conservative path component. Mirrors the directory names the push
|
|
285
|
+
// side produces (kebab-case skill slugs) and rejects everything else.
|
|
286
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(slug)) {
|
|
287
|
+
return 'slug is not a safe single path component';
|
|
288
|
+
}
|
|
289
|
+
if (slug.includes('..'))
|
|
290
|
+
return 'slug contains ".."';
|
|
291
|
+
return null;
|
|
292
|
+
}
|
|
271
293
|
/**
|
|
272
294
|
* Detect control characters (other than tab, LF, CR) in the body. The Unroo
|
|
273
295
|
* capture-skill endpoint 500s on null bytes — and they're never legitimate
|
|
@@ -421,6 +443,12 @@ async function pullOnce(ctx, state, result) {
|
|
|
421
443
|
const slug = node.source_id || node.name;
|
|
422
444
|
if (!slug)
|
|
423
445
|
continue;
|
|
446
|
+
// Never let a remote-controlled slug escape the skills directory.
|
|
447
|
+
const unsafeReason = pullSlugReason(slug);
|
|
448
|
+
if (unsafeReason) {
|
|
449
|
+
result.skipped.push({ slug, why: unsafeReason });
|
|
450
|
+
continue;
|
|
451
|
+
}
|
|
424
452
|
const props = node.properties ?? {};
|
|
425
453
|
// The capture endpoint receives `bodyMarkdown` from the wire but persists
|
|
426
454
|
// it to kg_nodes.properties as `body_md` (see knowledge-graph.ts upsertSkill).
|
|
@@ -437,6 +465,13 @@ async function pullOnce(ctx, state, result) {
|
|
|
437
465
|
continue;
|
|
438
466
|
}
|
|
439
467
|
const localPath = join(ctx.skillsDir, slug, 'SKILL.md');
|
|
468
|
+
// Defense in depth: even with a validated slug, confirm the resolved path
|
|
469
|
+
// stays inside the skills directory before any mkdir/write.
|
|
470
|
+
const skillsRoot = resolve(ctx.skillsDir);
|
|
471
|
+
if (!resolve(localPath).startsWith(skillsRoot + sep)) {
|
|
472
|
+
result.skipped.push({ slug, why: 'resolved path escapes skills dir' });
|
|
473
|
+
continue;
|
|
474
|
+
}
|
|
440
475
|
const existed = existsSync(localPath);
|
|
441
476
|
const prev = state.skills[slug] ?? {};
|
|
442
477
|
// Local doesn't exist — straight pull (clean install or new team skill)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fruition/fcp-mcp-server",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.26.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",
|