@fruition/fcp-mcp-server 1.24.0 → 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',
@@ -699,6 +704,78 @@ class FCPClient {
699
704
  });
700
705
  }
701
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
+ // ============================================================================
702
779
  // Backup Management Methods
703
780
  // ============================================================================
704
781
  async backupListSites() {
@@ -2274,7 +2351,7 @@ const TOOLS = [
2274
2351
  // Nuclei Security Scanning
2275
2352
  {
2276
2353
  name: 'fcp_trigger_nuclei_scan',
2277
- 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.',
2278
2355
  inputSchema: {
2279
2356
  type: 'object',
2280
2357
  properties: {
@@ -2292,7 +2369,7 @@ const TOOLS = [
2292
2369
  },
2293
2370
  templates: {
2294
2371
  type: 'string',
2295
- 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',
2296
2373
  },
2297
2374
  },
2298
2375
  required: ['website_id'],
@@ -2843,6 +2920,126 @@ const TOOLS = [
2843
2920
  },
2844
2921
  },
2845
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
+ // ============================================================================
2846
3043
  // Backup Management Tools
2847
3044
  // ============================================================================
2848
3045
  {
@@ -4573,6 +4770,72 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
4573
4770
  };
4574
4771
  }
4575
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
+ // ============================================================================
4576
4839
  // Backup Management Handlers
4577
4840
  // ============================================================================
4578
4841
  case 'fcp_backup_list_sites': {
@@ -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,13 +443,35 @@ 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
- const bodyMarkdown = typeof props.bodyMarkdown === 'string' ? props.bodyMarkdown : '';
453
+ // The capture endpoint receives `bodyMarkdown` from the wire but persists
454
+ // it to kg_nodes.properties as `body_md` (see knowledge-graph.ts upsertSkill).
455
+ // The search endpoint returns properties verbatim, so the pull side reads
456
+ // `body_md`. Accept either for forward-compat in case the storage shape
457
+ // is normalized later.
458
+ const bodyMarkdown = typeof props.body_md === 'string'
459
+ ? props.body_md
460
+ : typeof props.bodyMarkdown === 'string'
461
+ ? props.bodyMarkdown
462
+ : '';
426
463
  if (!bodyMarkdown) {
427
- result.skipped.push({ slug, why: 'remote has no bodyMarkdown' });
464
+ result.skipped.push({ slug, why: 'remote has no body_md' });
428
465
  continue;
429
466
  }
430
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
+ }
431
475
  const existed = existsSync(localPath);
432
476
  const prev = state.skills[slug] ?? {};
433
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.0",
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",