@fruition/fcp-mcp-server 1.24.1 → 1.25.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 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,6 +174,28 @@ 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
201
  // transient FCP outages don't pin us to 'none' for the rest of the session.
@@ -368,6 +395,97 @@ class FCPClient {
368
395
  method: 'DELETE',
369
396
  });
370
397
  }
398
+ // --- Alerting: incidents (alert_events), rules, outages, bulk notifications ---
399
+ // Thin wrappers over the FCP alerting HTTP API. Request bodies are camelCase
400
+ // (the API maps to snake_case DB columns internally).
401
+ // Build a query string from defined params (skips undefined/empty values).
402
+ buildQuery(q) {
403
+ const entries = Object.entries(q)
404
+ .filter(([, v]) => v !== undefined && v !== null && v !== '')
405
+ .map(([k, v]) => [k, String(v)]);
406
+ const qs = new URLSearchParams(entries).toString();
407
+ return qs ? `?${qs}` : '';
408
+ }
409
+ // Incidents (individual alerts).
410
+ async listAlerts(q = {}) {
411
+ return this.fetch(`/api/incidents${this.buildQuery(q)}`);
412
+ }
413
+ async getAlert(id) {
414
+ return this.fetch(`/api/incidents/${id}`);
415
+ }
416
+ async createAlert(input) {
417
+ return this.fetch('/api/incidents', {
418
+ method: 'POST',
419
+ body: JSON.stringify(input),
420
+ });
421
+ }
422
+ async updateAlert(id, updates) {
423
+ return this.fetch(`/api/incidents/${id}`, {
424
+ method: 'PATCH',
425
+ body: JSON.stringify(updates),
426
+ });
427
+ }
428
+ async getAlertStats() {
429
+ return this.fetch('/api/incidents/stats');
430
+ }
431
+ // Alert rules (notification configuration).
432
+ async listAlertRules(accountId) {
433
+ return this.fetch(`/api/alerts/rules${this.buildQuery({ accountId })}`);
434
+ }
435
+ async getAlertRule(ruleId) {
436
+ return this.fetch(`/api/alerts/rules/${ruleId}`);
437
+ }
438
+ async createAlertRule(input) {
439
+ return this.fetch('/api/alerts/rules', {
440
+ method: 'POST',
441
+ body: JSON.stringify(input),
442
+ });
443
+ }
444
+ async updateAlertRule(ruleId, updates) {
445
+ return this.fetch(`/api/alerts/rules/${ruleId}`, {
446
+ method: 'PATCH',
447
+ body: JSON.stringify(updates),
448
+ });
449
+ }
450
+ async deleteAlertRule(ruleId) {
451
+ return this.fetch(`/api/alerts/rules/${ruleId}`, {
452
+ method: 'DELETE',
453
+ });
454
+ }
455
+ // Outages (admin outage tracking).
456
+ async listOutages(q = {}) {
457
+ return this.fetch(`/api/alerts/outages${this.buildQuery(q)}`);
458
+ }
459
+ async getOutage(outageId) {
460
+ return this.fetch(`/api/alerts/outages/${outageId}`);
461
+ }
462
+ async createOutage(input) {
463
+ return this.fetch('/api/alerts/outages', {
464
+ method: 'POST',
465
+ body: JSON.stringify(input),
466
+ });
467
+ }
468
+ async updateOutage(outageId, updates) {
469
+ return this.fetch(`/api/alerts/outages/${outageId}`, {
470
+ method: 'PATCH',
471
+ body: JSON.stringify(updates),
472
+ });
473
+ }
474
+ async resolveOutage(outageId) {
475
+ return this.fetch(`/api/alerts/outages/${outageId}`, {
476
+ method: 'DELETE',
477
+ });
478
+ }
479
+ // Bulk client notifications. Sends real emails/SMS to clients — handle with care.
480
+ async bulkNotifyClients(input) {
481
+ return this.fetch('/api/alerts/bulk-notify', {
482
+ method: 'POST',
483
+ body: JSON.stringify(input),
484
+ });
485
+ }
486
+ async getBulkNotifyHistory(limit) {
487
+ return this.fetch(`/api/alerts/bulk-notify${this.buildQuery({ limit })}`);
488
+ }
371
489
  async createLaunch(input) {
372
490
  return this.fetch('/api/launches', {
373
491
  method: 'POST',
@@ -586,6 +704,78 @@ class FCPClient {
586
704
  });
587
705
  }
588
706
  // ============================================================================
707
+ // Trusted IP Range Methods (FRUPRI-761)
708
+ // ============================================================================
709
+ // Text-returning sibling of fetch() for endpoints that emit text/plain or
710
+ // text/yaml (export) or an empty 204 body (delete). The base fetch() always
711
+ // calls response.json() and would throw on those.
712
+ async fetchText(path, options = {}, timeoutMs) {
713
+ const url = `${this.baseUrl}${path}`;
714
+ const headers = {
715
+ ...(options.headers || {}),
716
+ };
717
+ if (this.token) {
718
+ if (this.token === 'dev_bypass') {
719
+ headers['X-Dev-Bypass'] = 'true';
720
+ }
721
+ else {
722
+ headers['X-API-Key'] = this.token;
723
+ }
724
+ }
725
+ const response = await fetch(url, {
726
+ ...options,
727
+ headers,
728
+ signal: AbortSignal.timeout(timeoutMs || this.defaultTimeout),
729
+ });
730
+ if (!response.ok) {
731
+ const error = await response.text();
732
+ throw new Error(`FCP API error (${response.status}): ${error}`);
733
+ }
734
+ return response.text();
735
+ }
736
+ async trustedIpListRanges(filters) {
737
+ const params = new URLSearchParams();
738
+ if (filters?.group)
739
+ params.append('group', filters.group);
740
+ if (filters?.include_pending)
741
+ params.append('include_pending', 'true');
742
+ if (filters?.include_expired)
743
+ params.append('include_expired', 'true');
744
+ const qs = params.toString();
745
+ return this.fetch(`/api/infrastructure/trusted-ips/entries${qs ? `?${qs}` : ''}`);
746
+ }
747
+ async trustedIpAddRange(input) {
748
+ return this.fetch('/api/infrastructure/trusted-ips/entries', {
749
+ method: 'POST',
750
+ body: JSON.stringify(input),
751
+ });
752
+ }
753
+ async trustedIpUpdateRange(id, updates) {
754
+ return this.fetch(`/api/infrastructure/trusted-ips/entries/${id}`, {
755
+ method: 'PUT',
756
+ body: JSON.stringify(updates),
757
+ });
758
+ }
759
+ async trustedIpRemoveRange(id) {
760
+ await this.fetchText(`/api/infrastructure/trusted-ips/entries/${id}`, {
761
+ method: 'DELETE',
762
+ });
763
+ return { ok: true, id, message: `Removed trusted IP entry ${id}` };
764
+ }
765
+ async trustedIpExport(opts) {
766
+ const params = new URLSearchParams();
767
+ if (opts?.format)
768
+ params.append('format', opts.format);
769
+ if (opts?.group)
770
+ params.append('group', opts.group);
771
+ if (opts?.purpose)
772
+ params.append('purpose', opts.purpose);
773
+ if (opts?.separator)
774
+ params.append('separator', opts.separator);
775
+ const qs = params.toString();
776
+ return this.fetchText(`/api/infrastructure/trusted-ips/export${qs ? `?${qs}` : ''}`);
777
+ }
778
+ // ============================================================================
589
779
  // Backup Management Methods
590
780
  // ============================================================================
591
781
  async backupListSites() {
@@ -2161,7 +2351,7 @@ const TOOLS = [
2161
2351
  // Nuclei Security Scanning
2162
2352
  {
2163
2353
  name: 'fcp_trigger_nuclei_scan',
2164
- description: 'Trigger a Nuclei security scan for a website. Creates a Kubernetes Job that runs the scan and stores results in the FCP database.',
2354
+ 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
2355
  inputSchema: {
2166
2356
  type: 'object',
2167
2357
  properties: {
@@ -2179,7 +2369,7 @@ const TOOLS = [
2179
2369
  },
2180
2370
  templates: {
2181
2371
  type: 'string',
2182
- description: 'Comma-separated Nuclei template categories (default: cves,exposures,misconfiguration). Options: cves, vulnerabilities, exposures, misconfiguration, technologies',
2372
+ description: 'Comma-separated Nuclei template categories (default: cves,exposures,misconfiguration). Options: cves, vulnerabilities, exposures, misconfiguration, technologies, ssl, dns, headless',
2183
2373
  },
2184
2374
  },
2185
2375
  required: ['website_id'],
@@ -2730,6 +2920,126 @@ const TOOLS = [
2730
2920
  },
2731
2921
  },
2732
2922
  // ============================================================================
2923
+ // Trusted IP Range Tools (FRUPRI-761)
2924
+ // ============================================================================
2925
+ {
2926
+ name: 'fcp_trusted_ip_list_ranges',
2927
+ 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.',
2928
+ inputSchema: {
2929
+ type: 'object',
2930
+ properties: {
2931
+ group: {
2932
+ type: 'string',
2933
+ description: 'Group id or slug to scope to (optional; omit for all groups)',
2934
+ },
2935
+ include_pending: {
2936
+ type: 'boolean',
2937
+ description: 'Include entries awaiting approval (default false)',
2938
+ },
2939
+ include_expired: {
2940
+ type: 'boolean',
2941
+ description: 'Include entries past their expires_at (default false)',
2942
+ },
2943
+ },
2944
+ },
2945
+ },
2946
+ {
2947
+ name: 'fcp_trusted_ip_add_range',
2948
+ 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.',
2949
+ inputSchema: {
2950
+ type: 'object',
2951
+ properties: {
2952
+ group_id: { type: 'number', description: 'Target group id' },
2953
+ cidr: {
2954
+ type: 'string',
2955
+ description: 'CIDR or bare IP (e.g. 73.241.16.0/24 or 73.241.16.5)',
2956
+ },
2957
+ label: { type: 'string', description: 'Human label for the range' },
2958
+ purpose: {
2959
+ type: 'string',
2960
+ enum: ['employee-home', 'office', 'vendor', 'ci-runner', 'migration', 'partner', 'other'],
2961
+ description: 'Structured purpose',
2962
+ },
2963
+ ticket_ref: { type: 'string', description: 'Associated ticket reference' },
2964
+ expires_at: {
2965
+ type: 'string',
2966
+ description: 'ISO-8601 expiry timestamp, or null for permanent',
2967
+ },
2968
+ override_reason: {
2969
+ type: 'string',
2970
+ description: 'Reason (>=10 chars) to force-insert past an overlap',
2971
+ },
2972
+ },
2973
+ required: ['group_id', 'cidr'],
2974
+ },
2975
+ },
2976
+ {
2977
+ name: 'fcp_trusted_ip_update_range',
2978
+ 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.',
2979
+ inputSchema: {
2980
+ type: 'object',
2981
+ properties: {
2982
+ id: { type: 'number', description: 'Entry id to update' },
2983
+ label: { type: 'string', description: 'New label' },
2984
+ purpose: {
2985
+ type: 'string',
2986
+ enum: ['employee-home', 'office', 'vendor', 'ci-runner', 'migration', 'partner', 'other'],
2987
+ description: 'New purpose',
2988
+ },
2989
+ ticket_ref: { type: 'string', description: 'New ticket reference' },
2990
+ expires_at: {
2991
+ type: 'string',
2992
+ description: 'New ISO-8601 expiry, or null for permanent',
2993
+ },
2994
+ approval_state: {
2995
+ type: 'string',
2996
+ enum: ['approved', 'pending', 'denied'],
2997
+ description: 'New approval state',
2998
+ },
2999
+ },
3000
+ required: ['id'],
3001
+ },
3002
+ },
3003
+ {
3004
+ name: 'fcp_trusted_ip_remove_range',
3005
+ 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.',
3006
+ inputSchema: {
3007
+ type: 'object',
3008
+ properties: {
3009
+ id: { type: 'number', description: 'Entry id to remove' },
3010
+ },
3011
+ required: ['id'],
3012
+ },
3013
+ },
3014
+ {
3015
+ name: 'fcp_trusted_ip_export',
3016
+ 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.',
3017
+ inputSchema: {
3018
+ type: 'object',
3019
+ properties: {
3020
+ format: {
3021
+ type: 'string',
3022
+ enum: ['plain', 'json', 'yaml'],
3023
+ description: 'Output format (default plain)',
3024
+ },
3025
+ group: {
3026
+ type: 'string',
3027
+ description: 'Group id or slug to scope to (optional; omit for all groups)',
3028
+ },
3029
+ purpose: {
3030
+ type: 'string',
3031
+ enum: ['employee-home', 'office', 'vendor', 'ci-runner', 'migration', 'partner', 'other'],
3032
+ description: 'Optional purpose filter',
3033
+ },
3034
+ separator: {
3035
+ type: 'string',
3036
+ enum: ['newline', 'comma', 'space'],
3037
+ description: 'Separator for plain format (default newline)',
3038
+ },
3039
+ },
3040
+ },
3041
+ },
3042
+ // ============================================================================
2733
3043
  // Backup Management Tools
2734
3044
  // ============================================================================
2735
3045
  {
@@ -3083,6 +3393,344 @@ const TOOLS = [
3083
3393
  required: ['siteId'],
3084
3394
  },
3085
3395
  },
3396
+ // ===================== Alerting: incidents (alerts) =====================
3397
+ {
3398
+ name: 'fcp_list_alerts',
3399
+ 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.',
3400
+ inputSchema: {
3401
+ type: 'object',
3402
+ properties: {
3403
+ status: {
3404
+ type: 'string',
3405
+ description: 'Comma-separated statuses. Default "firing,acknowledged" (active). Use "resolved" for past, "silenced", or "all" for everything.',
3406
+ },
3407
+ source: {
3408
+ type: 'string',
3409
+ enum: ['alertmanager', 'gcp_monitoring', 'uptimerobot', 'cluster_health', 'manual'],
3410
+ description: 'Filter by alert source',
3411
+ },
3412
+ severity: {
3413
+ type: 'string',
3414
+ description: 'Comma-separated severities: critical, high, medium, low, info',
3415
+ },
3416
+ cluster: { type: 'string', description: 'Filter by cluster name' },
3417
+ namespace: { type: 'string', description: 'Filter by namespace' },
3418
+ domain: { type: 'string', description: 'Filter by domain (partial match)' },
3419
+ websiteId: { type: 'number', description: 'Filter by FCP website ID' },
3420
+ environment: { type: 'string', description: 'Filter by site environment (production, staging)' },
3421
+ from: { type: 'string', description: 'Start of time range (ISO timestamp), filters started_at' },
3422
+ to: { type: 'string', description: 'End of time range (ISO timestamp), filters started_at' },
3423
+ page: { type: 'number', description: 'Page number (default 1)' },
3424
+ limit: { type: 'number', description: 'Results per page (default 50, max 200)' },
3425
+ },
3426
+ required: [],
3427
+ },
3428
+ },
3429
+ {
3430
+ name: 'fcp_get_alert',
3431
+ description: 'Get full detail for a single alert (incident) by ID, including linked site/account info.',
3432
+ inputSchema: {
3433
+ type: 'object',
3434
+ properties: {
3435
+ id: { type: 'number', description: 'The alert (incident) ID' },
3436
+ },
3437
+ required: ['id'],
3438
+ },
3439
+ },
3440
+ {
3441
+ name: 'fcp_create_alert',
3442
+ 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".',
3443
+ inputSchema: {
3444
+ type: 'object',
3445
+ properties: {
3446
+ alertName: { type: 'string', description: 'Short alert name/title (required)' },
3447
+ severity: {
3448
+ type: 'string',
3449
+ enum: ['critical', 'high', 'medium', 'low', 'info'],
3450
+ description: 'Severity (default: medium)',
3451
+ },
3452
+ summary: { type: 'string', description: 'Human-readable summary of the alert' },
3453
+ cluster: { type: 'string', description: 'Affected cluster, if any' },
3454
+ namespace: { type: 'string', description: 'Affected namespace, if any' },
3455
+ domain: { type: 'string', description: 'Affected domain, if any' },
3456
+ websiteId: { type: 'number', description: 'Link to an FCP website ID, if applicable' },
3457
+ },
3458
+ required: ['alertName'],
3459
+ },
3460
+ },
3461
+ {
3462
+ name: 'fcp_update_alert',
3463
+ 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.',
3464
+ inputSchema: {
3465
+ type: 'object',
3466
+ properties: {
3467
+ id: { type: 'number', description: 'The alert (incident) ID' },
3468
+ status: {
3469
+ type: 'string',
3470
+ enum: ['acknowledged', 'resolved', 'silenced'],
3471
+ description: 'New status. "resolved" closes the alert; "silenced" requires silencedUntil.',
3472
+ },
3473
+ acknowledgedBy: { type: 'string', description: 'Who acknowledged (when status=acknowledged)' },
3474
+ silencedUntil: { type: 'string', description: 'ISO timestamp to silence until (required when status=silenced)' },
3475
+ silencedBy: { type: 'string', description: 'Who silenced (when status=silenced)' },
3476
+ adminNotes: { type: 'string', description: 'Internal admin notes' },
3477
+ websiteId: { type: 'number', description: 'Link/relink to an FCP website ID' },
3478
+ unrooTaskId: { type: 'number', description: 'Link to a Unroo task ID' },
3479
+ },
3480
+ required: ['id'],
3481
+ },
3482
+ },
3483
+ {
3484
+ name: 'fcp_get_alert_stats',
3485
+ description: 'Get alert summary statistics for the last 7 days: counts by status, by source, critical-active count, and per-cluster breakdown.',
3486
+ inputSchema: {
3487
+ type: 'object',
3488
+ properties: {},
3489
+ required: [],
3490
+ },
3491
+ },
3492
+ // ===================== Alerting: rules (notification config) =====================
3493
+ {
3494
+ name: 'fcp_list_alert_rules',
3495
+ description: 'List client alert rules (notification configuration). Optionally filter by account.',
3496
+ inputSchema: {
3497
+ type: 'object',
3498
+ properties: {
3499
+ accountId: { type: 'number', description: 'Filter rules by account ID' },
3500
+ },
3501
+ required: [],
3502
+ },
3503
+ },
3504
+ {
3505
+ name: 'fcp_get_alert_rule',
3506
+ description: 'Get a single alert rule by ID, with account and website context.',
3507
+ inputSchema: {
3508
+ type: 'object',
3509
+ properties: {
3510
+ ruleId: { type: 'number', description: 'The alert rule ID' },
3511
+ },
3512
+ required: ['ruleId'],
3513
+ },
3514
+ },
3515
+ {
3516
+ name: 'fcp_create_alert_rule',
3517
+ description: 'Create a client alert rule defining which events trigger notifications, on which channels, with flapping/business-hours controls.',
3518
+ inputSchema: {
3519
+ type: 'object',
3520
+ properties: {
3521
+ accountId: { type: 'number', description: 'Account this rule belongs to (required)' },
3522
+ websiteId: { type: 'number', description: 'Specific website, or omit/null for all sites in the account' },
3523
+ name: { type: 'string', description: 'Rule name (required)' },
3524
+ description: { type: 'string', description: 'Optional description' },
3525
+ eventType: {
3526
+ type: 'string',
3527
+ enum: ['uptime_down', 'uptime_up', 'maintenance', 'cve', 'ssl_expiry', 'domain_expiry'],
3528
+ description: 'Event type that triggers this rule (required)',
3529
+ },
3530
+ severityThreshold: {
3531
+ type: 'string',
3532
+ enum: ['critical', 'high', 'medium', 'low', 'info'],
3533
+ description: 'Minimum severity to trigger (default: info)',
3534
+ },
3535
+ notifyChannels: {
3536
+ type: 'array',
3537
+ items: { type: 'string', enum: ['email', 'teams', 'slack', 'sms'] },
3538
+ description: 'Channels to notify on',
3539
+ },
3540
+ delaySeconds: { type: 'number', description: 'Delay before notifying (flap prevention)' },
3541
+ cooldownSeconds: { type: 'number', description: 'Cooldown between repeat notifications' },
3542
+ businessHoursOnly: { type: 'boolean', description: 'Only notify during business hours' },
3543
+ businessHoursStart: { type: 'string', description: 'Business hours start (e.g. "09:00")' },
3544
+ businessHoursEnd: { type: 'string', description: 'Business hours end (e.g. "17:00")' },
3545
+ businessHoursTimezone: { type: 'string', description: 'IANA timezone for business hours' },
3546
+ createdBy: { type: 'string', description: 'Who created the rule' },
3547
+ },
3548
+ required: ['accountId', 'name', 'eventType'],
3549
+ },
3550
+ },
3551
+ {
3552
+ name: 'fcp_update_alert_rule',
3553
+ description: 'Update an existing alert rule. Only provided fields are changed.',
3554
+ inputSchema: {
3555
+ type: 'object',
3556
+ properties: {
3557
+ ruleId: { type: 'number', description: 'The alert rule ID (required)' },
3558
+ name: { type: 'string', description: 'Rule name' },
3559
+ description: { type: 'string', description: 'Description' },
3560
+ severityThreshold: {
3561
+ type: 'string',
3562
+ enum: ['critical', 'high', 'medium', 'low', 'info'],
3563
+ description: 'Minimum severity to trigger',
3564
+ },
3565
+ notifyChannels: {
3566
+ type: 'array',
3567
+ items: { type: 'string', enum: ['email', 'teams', 'slack', 'sms'] },
3568
+ description: 'Channels to notify on',
3569
+ },
3570
+ delaySeconds: { type: 'number', description: 'Delay before notifying' },
3571
+ cooldownSeconds: { type: 'number', description: 'Cooldown between repeat notifications' },
3572
+ businessHoursOnly: { type: 'boolean', description: 'Only notify during business hours' },
3573
+ businessHoursStart: { type: 'string', description: 'Business hours start' },
3574
+ businessHoursEnd: { type: 'string', description: 'Business hours end' },
3575
+ businessHoursTimezone: { type: 'string', description: 'IANA timezone' },
3576
+ isActive: { type: 'boolean', description: 'Enable/disable the rule' },
3577
+ updatedBy: { type: 'string', description: 'Who updated the rule' },
3578
+ },
3579
+ required: ['ruleId'],
3580
+ },
3581
+ },
3582
+ {
3583
+ name: 'fcp_delete_alert_rule',
3584
+ description: 'Permanently DELETE an alert rule (hard delete). Requires admin. This cannot be undone.',
3585
+ inputSchema: {
3586
+ type: 'object',
3587
+ properties: {
3588
+ ruleId: { type: 'number', description: 'The alert rule ID to delete' },
3589
+ },
3590
+ required: ['ruleId'],
3591
+ },
3592
+ },
3593
+ // ===================== Alerting: outages =====================
3594
+ {
3595
+ name: 'fcp_list_outages',
3596
+ description: 'List active outages (admin outage tracking). Filter by status (active/resolved/all), account, and limit.',
3597
+ inputSchema: {
3598
+ type: 'object',
3599
+ properties: {
3600
+ status: { type: 'string', description: 'Filter: active, resolved, or all (default: active)' },
3601
+ accountId: { type: 'number', description: 'Filter by account ID' },
3602
+ limit: { type: 'number', description: 'Max results' },
3603
+ },
3604
+ required: [],
3605
+ },
3606
+ },
3607
+ {
3608
+ name: 'fcp_get_outage',
3609
+ description: 'Get a single outage by ID, with its timeline updates, notification history, and affected client contacts.',
3610
+ inputSchema: {
3611
+ type: 'object',
3612
+ properties: {
3613
+ outageId: { type: 'number', description: 'The outage ID' },
3614
+ },
3615
+ required: ['outageId'],
3616
+ },
3617
+ },
3618
+ {
3619
+ name: 'fcp_create_outage',
3620
+ description: 'Create an outage record for centralized outage management, optionally linked to an incident.',
3621
+ inputSchema: {
3622
+ type: 'object',
3623
+ properties: {
3624
+ incidentId: { type: 'number', description: 'Linked incident ID (from downtime_incidents)' },
3625
+ websiteId: { type: 'number', description: 'Affected website ID' },
3626
+ accountId: { type: 'number', description: 'Affected account ID' },
3627
+ startedAt: { type: 'string', description: 'Outage start (ISO timestamp)' },
3628
+ domain: { type: 'string', description: 'Affected domain' },
3629
+ environment: { type: 'string', description: 'Environment (production, staging)' },
3630
+ status: {
3631
+ type: 'string',
3632
+ enum: ['active', 'investigating', 'identified', 'monitoring', 'resolved'],
3633
+ description: 'Outage status (default: active)',
3634
+ },
3635
+ severity: {
3636
+ type: 'string',
3637
+ enum: ['critical', 'high', 'medium', 'low', 'info'],
3638
+ description: 'Severity',
3639
+ },
3640
+ likelyCause: { type: 'string', description: 'Likely cause description' },
3641
+ },
3642
+ required: [],
3643
+ },
3644
+ },
3645
+ {
3646
+ name: 'fcp_update_outage',
3647
+ 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.',
3648
+ inputSchema: {
3649
+ type: 'object',
3650
+ properties: {
3651
+ outageId: { type: 'number', description: 'The outage ID (required)' },
3652
+ status: {
3653
+ type: 'string',
3654
+ enum: ['active', 'investigating', 'identified', 'monitoring', 'resolved'],
3655
+ description: 'New outage status',
3656
+ },
3657
+ severity: {
3658
+ type: 'string',
3659
+ enum: ['critical', 'high', 'medium', 'low', 'info'],
3660
+ description: 'New severity',
3661
+ },
3662
+ likelyCause: { type: 'string', description: 'Likely cause' },
3663
+ adminNotes: { type: 'string', description: 'Internal admin notes' },
3664
+ assignedTo: { type: 'string', description: 'Assignee' },
3665
+ updateMessage: { type: 'string', description: 'Timeline update message to append' },
3666
+ notifyClients: {
3667
+ type: 'boolean',
3668
+ description: '⚠️ If true, sends real notifications to affected clients. Default false.',
3669
+ },
3670
+ updatedBy: { type: 'string', description: 'Who made the update' },
3671
+ confirm: {
3672
+ type: 'boolean',
3673
+ 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.',
3674
+ },
3675
+ },
3676
+ required: ['outageId'],
3677
+ },
3678
+ },
3679
+ {
3680
+ name: 'fcp_resolve_outage',
3681
+ description: 'Resolve and close an outage (soft resolve — sets status to resolved and stamps resolved_at). Adds a resolution timeline entry.',
3682
+ inputSchema: {
3683
+ type: 'object',
3684
+ properties: {
3685
+ outageId: { type: 'number', description: 'The outage ID to resolve' },
3686
+ },
3687
+ required: ['outageId'],
3688
+ },
3689
+ },
3690
+ // ===================== Alerting: bulk client notifications =====================
3691
+ {
3692
+ name: 'fcp_bulk_notify_clients',
3693
+ description: '⚠️ SENDS REAL NOTIFICATIONS (email/Teams/Slack/SMS) to all clients affected by the given outages. Requires admin. Use deliberately — this contacts real people.',
3694
+ inputSchema: {
3695
+ type: 'object',
3696
+ properties: {
3697
+ outageIds: {
3698
+ type: 'array',
3699
+ items: { type: 'number' },
3700
+ description: 'Non-empty array of outage IDs whose affected clients should be notified',
3701
+ },
3702
+ notificationType: {
3703
+ type: 'string',
3704
+ enum: ['outage_initial', 'outage_update', 'recovery'],
3705
+ description: 'Type of notification',
3706
+ },
3707
+ subject: { type: 'string', description: 'Notification subject (required)' },
3708
+ message: { type: 'string', description: 'Notification body (required)' },
3709
+ channels: {
3710
+ type: 'array',
3711
+ items: { type: 'string', enum: ['email', 'teams', 'slack', 'sms'] },
3712
+ description: 'Channels to send on (default: ["email"])',
3713
+ },
3714
+ sentBy: { type: 'string', description: 'Who sent the notification' },
3715
+ confirm: {
3716
+ type: 'boolean',
3717
+ 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.',
3718
+ },
3719
+ },
3720
+ required: ['outageIds', 'notificationType', 'subject', 'message'],
3721
+ },
3722
+ },
3723
+ {
3724
+ name: 'fcp_get_bulk_notify_history',
3725
+ description: 'Get history of bulk client notifications (batches sent), most recent first.',
3726
+ inputSchema: {
3727
+ type: 'object',
3728
+ properties: {
3729
+ limit: { type: 'number', description: 'Max results (default 20)' },
3730
+ },
3731
+ required: [],
3732
+ },
3733
+ },
3086
3734
  ];
3087
3735
  // Register tool handlers
3088
3736
  server.setRequestHandler(ListToolsRequestSchema, async () => {
@@ -4122,6 +4770,72 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
4122
4770
  };
4123
4771
  }
4124
4772
  // ============================================================================
4773
+ // Trusted IP Range Handlers (FRUPRI-761)
4774
+ // ============================================================================
4775
+ case 'fcp_trusted_ip_list_ranges': {
4776
+ const { group, include_pending, include_expired } = args;
4777
+ const result = await client.trustedIpListRanges({
4778
+ group,
4779
+ include_pending,
4780
+ include_expired,
4781
+ });
4782
+ return {
4783
+ content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
4784
+ };
4785
+ }
4786
+ case 'fcp_trusted_ip_add_range': {
4787
+ const { group_id, cidr, label, purpose, ticket_ref, expires_at, override_reason, } = args;
4788
+ const result = await client.trustedIpAddRange({
4789
+ group_id,
4790
+ cidr,
4791
+ label,
4792
+ purpose,
4793
+ ticket_ref,
4794
+ expires_at,
4795
+ override_reason,
4796
+ });
4797
+ return {
4798
+ content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
4799
+ };
4800
+ }
4801
+ case 'fcp_trusted_ip_update_range': {
4802
+ const { id, label, purpose, ticket_ref, expires_at, approval_state } = args;
4803
+ const updates = {};
4804
+ if (label !== undefined)
4805
+ updates.label = label;
4806
+ if (purpose !== undefined)
4807
+ updates.purpose = purpose;
4808
+ if (ticket_ref !== undefined)
4809
+ updates.ticket_ref = ticket_ref;
4810
+ if (expires_at !== undefined)
4811
+ updates.expires_at = expires_at;
4812
+ if (approval_state !== undefined)
4813
+ updates.approval_state = approval_state;
4814
+ const result = await client.trustedIpUpdateRange(id, updates);
4815
+ return {
4816
+ content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
4817
+ };
4818
+ }
4819
+ case 'fcp_trusted_ip_remove_range': {
4820
+ const { id } = args;
4821
+ const result = await client.trustedIpRemoveRange(id);
4822
+ return {
4823
+ content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
4824
+ };
4825
+ }
4826
+ case 'fcp_trusted_ip_export': {
4827
+ const { format, group, purpose, separator } = args;
4828
+ const text = await client.trustedIpExport({
4829
+ format,
4830
+ group,
4831
+ purpose,
4832
+ separator,
4833
+ });
4834
+ return {
4835
+ content: [{ type: 'text', text }],
4836
+ };
4837
+ }
4838
+ // ============================================================================
4125
4839
  // Backup Management Handlers
4126
4840
  // ============================================================================
4127
4841
  case 'fcp_backup_list_sites': {
@@ -4287,6 +5001,180 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
4287
5001
  content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
4288
5002
  };
4289
5003
  }
5004
+ // ===================== Alerting: incidents (alerts) =====================
5005
+ case 'fcp_list_alerts': {
5006
+ const result = await client.listAlerts(args);
5007
+ return {
5008
+ content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
5009
+ };
5010
+ }
5011
+ case 'fcp_get_alert': {
5012
+ const { id } = args;
5013
+ const result = await client.getAlert(id);
5014
+ return {
5015
+ content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
5016
+ };
5017
+ }
5018
+ case 'fcp_create_alert': {
5019
+ const { alertName, severity, summary, cluster, namespace, domain, websiteId } = args;
5020
+ const result = await client.createAlert({ alertName, severity, summary, cluster, namespace, domain, websiteId });
5021
+ return {
5022
+ content: [
5023
+ {
5024
+ type: 'text',
5025
+ text: JSON.stringify({ success: true, message: `Alert created: "${alertName}"`, alert: result?.incident ?? result }, null, 2),
5026
+ },
5027
+ ],
5028
+ };
5029
+ }
5030
+ case 'fcp_update_alert': {
5031
+ const { id, ...updates } = args;
5032
+ const result = await client.updateAlert(id, updates);
5033
+ const verb = updates.status === 'resolved' ? 'closed (resolved)' : `updated${updates.status ? ` to ${updates.status}` : ''}`;
5034
+ return {
5035
+ content: [
5036
+ {
5037
+ type: 'text',
5038
+ text: JSON.stringify({ success: true, message: `Alert ${id} ${verb}`, alert: result?.incident ?? result }, null, 2),
5039
+ },
5040
+ ],
5041
+ };
5042
+ }
5043
+ case 'fcp_get_alert_stats': {
5044
+ const result = await client.getAlertStats();
5045
+ return {
5046
+ content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
5047
+ };
5048
+ }
5049
+ // ===================== Alerting: rules =====================
5050
+ case 'fcp_list_alert_rules': {
5051
+ const { accountId } = args;
5052
+ const result = await client.listAlertRules(accountId);
5053
+ return {
5054
+ content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
5055
+ };
5056
+ }
5057
+ case 'fcp_get_alert_rule': {
5058
+ const { ruleId } = args;
5059
+ const result = await client.getAlertRule(ruleId);
5060
+ return {
5061
+ content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
5062
+ };
5063
+ }
5064
+ case 'fcp_create_alert_rule': {
5065
+ const result = await client.createAlertRule(args);
5066
+ return {
5067
+ content: [
5068
+ {
5069
+ type: 'text',
5070
+ text: JSON.stringify({ success: true, message: 'Alert rule created', rule: result?.rule ?? result }, null, 2),
5071
+ },
5072
+ ],
5073
+ };
5074
+ }
5075
+ case 'fcp_update_alert_rule': {
5076
+ const { ruleId, ...updates } = args;
5077
+ const result = await client.updateAlertRule(ruleId, updates);
5078
+ return {
5079
+ content: [
5080
+ {
5081
+ type: 'text',
5082
+ text: JSON.stringify({ success: true, message: `Alert rule ${ruleId} updated`, rule: result?.rule ?? result }, null, 2),
5083
+ },
5084
+ ],
5085
+ };
5086
+ }
5087
+ case 'fcp_delete_alert_rule': {
5088
+ const { ruleId } = args;
5089
+ const result = await client.deleteAlertRule(ruleId);
5090
+ return {
5091
+ content: [
5092
+ {
5093
+ type: 'text',
5094
+ text: JSON.stringify({ success: true, message: `Alert rule ${ruleId} deleted`, result }, null, 2),
5095
+ },
5096
+ ],
5097
+ };
5098
+ }
5099
+ // ===================== Alerting: outages =====================
5100
+ case 'fcp_list_outages': {
5101
+ const result = await client.listOutages(args);
5102
+ return {
5103
+ content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
5104
+ };
5105
+ }
5106
+ case 'fcp_get_outage': {
5107
+ const { outageId } = args;
5108
+ const result = await client.getOutage(outageId);
5109
+ return {
5110
+ content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
5111
+ };
5112
+ }
5113
+ case 'fcp_create_outage': {
5114
+ const result = await client.createOutage(args);
5115
+ return {
5116
+ content: [
5117
+ {
5118
+ type: 'text',
5119
+ text: JSON.stringify({ success: true, message: 'Outage created', outage: result?.outage ?? result }, null, 2),
5120
+ },
5121
+ ],
5122
+ };
5123
+ }
5124
+ case 'fcp_update_outage': {
5125
+ const { outageId, confirm, ...updates } = args;
5126
+ // Safety gate: a notifying update must be explicitly confirmed. We refuse the
5127
+ // WHOLE update (not just the notify) so a half-applied state can't occur.
5128
+ if (updates.notifyClients === true && confirm !== true) {
5129
+ throw new Error(`Refusing to update: outage ${outageId} update has notifyClients=true, which SENDS REAL notifications ` +
5130
+ `to affected clients. Re-call with confirm=true to apply this update and notify, or drop notifyClients ` +
5131
+ `to update silently.`);
5132
+ }
5133
+ const result = await client.updateOutage(outageId, updates);
5134
+ return {
5135
+ content: [
5136
+ {
5137
+ type: 'text',
5138
+ text: JSON.stringify({ success: true, message: `Outage ${outageId} updated`, outage: result?.outage ?? result }, null, 2),
5139
+ },
5140
+ ],
5141
+ };
5142
+ }
5143
+ case 'fcp_resolve_outage': {
5144
+ const { outageId } = args;
5145
+ const result = await client.resolveOutage(outageId);
5146
+ return {
5147
+ content: [
5148
+ {
5149
+ type: 'text',
5150
+ text: JSON.stringify({ success: true, message: `Outage ${outageId} resolved`, result }, null, 2),
5151
+ },
5152
+ ],
5153
+ };
5154
+ }
5155
+ // ===================== Alerting: bulk client notifications =====================
5156
+ case 'fcp_bulk_notify_clients': {
5157
+ const { outageIds, notificationType, subject, message, channels, sentBy, confirm } = args;
5158
+ // Safety gate: refuse to email/SMS real clients unless explicitly confirmed.
5159
+ if (confirm !== true) {
5160
+ const ch = (channels && channels.length ? channels : ['email']).join(', ');
5161
+ const n = Array.isArray(outageIds) ? outageIds.length : 0;
5162
+ throw new Error(`Refusing to send: fcp_bulk_notify_clients dispatches REAL ${ch} notifications to clients ` +
5163
+ `affected by ${n} outage(s) [${Array.isArray(outageIds) ? outageIds.join(', ') : ''}]. ` +
5164
+ `Confirm with a human, then re-call with confirm=true to proceed.`);
5165
+ }
5166
+ const result = await client.bulkNotifyClients({ outageIds, notificationType, subject, message, channels, sentBy });
5167
+ return {
5168
+ content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
5169
+ };
5170
+ }
5171
+ case 'fcp_get_bulk_notify_history': {
5172
+ const { limit } = args;
5173
+ const result = await client.getBulkNotifyHistory(limit);
5174
+ return {
5175
+ content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
5176
+ };
5177
+ }
4290
5178
  default:
4291
5179
  throw new Error(`Unknown tool: ${name}`);
4292
5180
  }
@@ -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
@@ -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.24.1",
3
+ "version": "1.25.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",