@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 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 'none' for the rest of the session.
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 cachedUserRole;
210
+ return { ok: true, role: 'super_admin' };
184
211
  }
185
212
  if (!FCP_API_TOKEN) {
186
- return 'none';
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
- return 'none';
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 'none';
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 actual = await fetchUserRole();
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. Creates a Kubernetes Job that runs the scan and stores results in the FCP database.',
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. Returns DNS records (traffic CNAME + cert validation CNAME) that must be added at the registrar.',
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', 'origin_host'],
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
  }
@@ -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.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",