@fruition/fcp-mcp-server 1.18.0 → 1.20.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.
@@ -57,12 +57,20 @@ if [ -f ".unroo" ]; then
57
57
  PROJECT_SLUG=$(grep -E "^project_slug=" .unroo 2>/dev/null | cut -d= -f2)
58
58
  fi
59
59
 
60
- # Get machine ID (for multi-machine tracking)
60
+ # Get machine ID (for multi-machine tracking) — portable across Linux and macOS
61
61
  MACHINE_ID=""
62
62
  if [ -f /etc/machine-id ]; then
63
- MACHINE_ID=$(cat /etc/machine-id | head -c 8)
64
- elif command -v hostid &> /dev/null; then
65
- MACHINE_ID=$(hostid)
63
+ # Linux (systemd)
64
+ MACHINE_ID=$(head -c 8 /etc/machine-id 2>/dev/null)
65
+ elif [ -r /var/lib/dbus/machine-id ]; then
66
+ # Linux (dbus, no systemd)
67
+ MACHINE_ID=$(head -c 8 /var/lib/dbus/machine-id 2>/dev/null)
68
+ elif command -v ioreg >/dev/null 2>&1; then
69
+ # macOS — IOPlatformUUID is stable per machine
70
+ MACHINE_ID=$(ioreg -rd1 -c IOPlatformExpertDevice 2>/dev/null \
71
+ | awk -F'"' '/IOPlatformUUID/{print $4}' | head -c 8)
72
+ elif command -v hostid >/dev/null 2>&1; then
73
+ MACHINE_ID=$(hostid 2>/dev/null)
66
74
  fi
67
75
 
68
76
  # Build JSON payload
package/dist/index.js CHANGED
@@ -77,6 +77,151 @@ const UNROO_AVAILABLE = UNROO_API_KEY || (USE_FCP_UNROO_PROXY && FCP_API_TOKEN);
77
77
  // - Multiple processes on same machine (PID)
78
78
  // - Process restarts (timestamp)
79
79
  const INSTANCE_ID = `${process.env.HOSTNAME || 'local'}-${process.pid}-${Date.now()}`;
80
+ const ROLE_HIERARCHY = [
81
+ 'super_admin',
82
+ 'admin',
83
+ 'billing_admin',
84
+ 'operator',
85
+ 'viewer',
86
+ 'none',
87
+ ];
88
+ const TOOL_PERMISSIONS = {
89
+ // --- super_admin only: destructive, irreversible, or move raw prod data ---
90
+ fcp_create_site: 'super_admin',
91
+ fcp_delete_site: 'super_admin',
92
+ fcp_delete_launch: 'super_admin',
93
+ fcp_clone_to_staging: 'super_admin',
94
+ fcp_clone_confirm: 'super_admin',
95
+ fcp_shield_remove_domain: 'super_admin',
96
+ fcp_backup_delete_pairing: 'super_admin',
97
+ fcp_filesync_cancel_sync: 'super_admin',
98
+ // --- admin+: mutating ops with real-world side effects ---
99
+ fcp_create_launch: 'admin',
100
+ fcp_update_launch: 'admin',
101
+ fcp_update_site: 'admin',
102
+ fcp_update_client_profile: 'admin',
103
+ fcp_delete_checklist_item: 'admin',
104
+ fcp_shield_add_domain: 'admin',
105
+ fcp_shield_update_domain: 'admin',
106
+ fcp_backup_enable: 'admin',
107
+ fcp_backup_trigger: 'admin',
108
+ fcp_backup_check_trigger: 'admin',
109
+ fcp_backup_update_pairing: 'admin',
110
+ fcp_backup_download: 'admin',
111
+ fcp_backup_download_prepared: 'admin',
112
+ fcp_kinsta_backup_download: 'admin',
113
+ fcp_filesync_start_sync: 'admin',
114
+ fcp_filesync_get_confirmation: 'admin',
115
+ fcp_trigger_nuclei_scan: 'admin',
116
+ fcp_scan_security_headers: 'admin',
117
+ // --- operator+: routine writes (checklists, progress notes, Unroo tasks) ---
118
+ fcp_add_checklist_item: 'operator',
119
+ fcp_update_checklist_item: 'operator',
120
+ fcp_validate_checklist_item: 'operator',
121
+ fcp_add_progress_note: 'operator',
122
+ unroo_create_task: 'operator',
123
+ unroo_update_task: 'operator',
124
+ unroo_add_comment: 'operator',
125
+ unroo_create_follow_up: 'operator',
126
+ unroo_log_future_work: 'operator',
127
+ unroo_convert_to_backlog: 'operator',
128
+ unroo_start_session: 'operator',
129
+ unroo_end_session: 'operator',
130
+ // --- viewer (read-only) — explicit for clarity; any unmapped tool also defaults here ---
131
+ fcp_list_launches: 'viewer',
132
+ fcp_get_launch: 'viewer',
133
+ fcp_get_legacy_access: 'viewer',
134
+ fcp_get_checklist: 'viewer',
135
+ fcp_get_claude_md: 'viewer',
136
+ fcp_get_success_factors: 'viewer',
137
+ fcp_get_unroo_section: 'viewer',
138
+ fcp_filesync_list_configs: 'viewer',
139
+ fcp_filesync_get_config: 'viewer',
140
+ fcp_filesync_get_job_status: 'viewer',
141
+ fcp_get_nuclei_results: 'viewer',
142
+ fcp_get_security_headers_results: 'viewer',
143
+ fcp_list_sites: 'viewer',
144
+ fcp_search_sites: 'viewer',
145
+ fcp_get_site: 'viewer',
146
+ fcp_list_clients: 'viewer',
147
+ fcp_get_client: 'viewer',
148
+ fcp_get_local_setup_guide: 'viewer',
149
+ fcp_shield_list_domains: 'viewer',
150
+ fcp_shield_get_domain: 'viewer',
151
+ fcp_shield_get_metrics: 'viewer',
152
+ fcp_backup_list_sites: 'viewer',
153
+ fcp_backup_get_config: 'viewer',
154
+ fcp_backup_list_eligible: 'viewer',
155
+ fcp_backup_list_backups: 'viewer',
156
+ fcp_backup_get_status: 'viewer',
157
+ fcp_backup_sanitize_status: 'viewer',
158
+ fcp_backup_list_pairings: 'viewer',
159
+ fcp_kinsta_backup_list_sites: 'viewer',
160
+ fcp_kinsta_backup_list: 'viewer',
161
+ fcp_clone_status: 'viewer',
162
+ fcp_clone_list: 'viewer',
163
+ fcp_get_dev_environment_info: 'viewer',
164
+ unroo_list_projects: 'viewer',
165
+ unroo_list_tasks: 'viewer',
166
+ unroo_get_task: 'viewer',
167
+ unroo_list_comments: 'viewer',
168
+ unroo_get_my_tasks: 'viewer',
169
+ unroo_get_parking_lot: 'viewer',
170
+ unroo_get_backlog: 'viewer',
171
+ };
172
+ // Resolved role for the current API key. Cached only on successful lookup so
173
+ // transient FCP outages don't pin us to 'none' for the rest of the session.
174
+ let cachedUserRole;
175
+ async function fetchUserRole() {
176
+ if (cachedUserRole !== undefined)
177
+ return cachedUserRole;
178
+ // dev_bypass is the local-dev token; treat as super_admin to avoid
179
+ // gating local development against role lookup.
180
+ if (FCP_API_TOKEN === 'dev_bypass') {
181
+ cachedUserRole = 'super_admin';
182
+ return cachedUserRole;
183
+ }
184
+ if (!FCP_API_TOKEN) {
185
+ return 'none';
186
+ }
187
+ try {
188
+ const res = await fetch(`${FCP_API_URL}/api/mcp/me`, {
189
+ headers: { 'X-API-Key': FCP_API_TOKEN },
190
+ signal: AbortSignal.timeout(10_000),
191
+ });
192
+ if (!res.ok) {
193
+ console.error(`[MCP Server] Role lookup failed: HTTP ${res.status}`);
194
+ return 'none';
195
+ }
196
+ const data = await res.json();
197
+ const role = data?.role ?? 'none';
198
+ cachedUserRole = role;
199
+ console.error(`[MCP Server] Resolved user role: ${role} (${data?.email ?? 'unknown'})`);
200
+ return role;
201
+ }
202
+ catch (err) {
203
+ console.error('[MCP Server] Role lookup error:', err);
204
+ return 'none';
205
+ }
206
+ }
207
+ function isRoleAtLeast(actual, required) {
208
+ if (actual === 'none')
209
+ return required === 'none';
210
+ const a = ROLE_HIERARCHY.indexOf(actual);
211
+ const r = ROLE_HIERARCHY.indexOf(required);
212
+ if (a === -1 || r === -1)
213
+ return false;
214
+ return a <= r;
215
+ }
216
+ async function enforceToolPermission(toolName) {
217
+ const required = TOOL_PERMISSIONS[toolName] ?? 'viewer';
218
+ const actual = await fetchUserRole();
219
+ if (!isRoleAtLeast(actual, required)) {
220
+ throw new Error(`Permission denied: tool '${toolName}' requires ${required} role; ` +
221
+ `your role is '${actual}'. Contact a super_admin (Brad, Mattox, or Andrea) ` +
222
+ `if you need access.`);
223
+ }
224
+ }
80
225
  let currentProject = null;
81
226
  /**
82
227
  * Detect the current git remote URL
@@ -372,6 +517,35 @@ class FCPClient {
372
517
  async getLocalSetupGuide(siteId) {
373
518
  return this.fetch(`/api/sites/${siteId}/local-setup`);
374
519
  }
520
+ // WebOps Client Directory
521
+ async listClients(filters) {
522
+ const params = new URLSearchParams();
523
+ if (filters?.q)
524
+ params.set('q', filters.q);
525
+ if (filters?.webops_lead)
526
+ params.set('webops_lead', filters.webops_lead);
527
+ if (filters?.contract_type)
528
+ params.set('contract_type', filters.contract_type);
529
+ if (filters?.tier)
530
+ params.set('tier', filters.tier);
531
+ if (filters?.marketing !== undefined)
532
+ params.set('marketing', String(filters.marketing));
533
+ if (filters?.status)
534
+ params.set('status', filters.status);
535
+ if (filters?.limit)
536
+ params.set('limit', String(filters.limit));
537
+ const qs = params.toString();
538
+ return this.fetch(`/api/clients${qs ? `?${qs}` : ''}`);
539
+ }
540
+ async getClient(accountId) {
541
+ return this.fetch(`/api/clients/${accountId}/profile`);
542
+ }
543
+ async updateClientProfile(accountId, updates) {
544
+ return this.fetch(`/api/clients/${accountId}/profile`, {
545
+ method: 'PUT',
546
+ body: JSON.stringify(updates),
547
+ });
548
+ }
375
549
  // Shield Domain Management
376
550
  async shieldListDomains(filters) {
377
551
  const params = new URLSearchParams();
@@ -2313,6 +2487,89 @@ const TOOLS = [
2313
2487
  required: ['website_id'],
2314
2488
  },
2315
2489
  },
2490
+ // WebOps Client Directory
2491
+ {
2492
+ name: 'fcp_list_clients',
2493
+ description: 'List client accounts from the WebOps Client Directory with their WebOps profile (lead, sponsor, contract type, service tier, site count). Use to answer "who manages X", "list all retainer clients", "which clients does Janine lead".',
2494
+ inputSchema: {
2495
+ type: 'object',
2496
+ properties: {
2497
+ q: { type: 'string', description: 'Search by client/account name (substring match)' },
2498
+ webops_lead: { type: 'string', description: 'Filter by WebOps lead (e.g. "Janine", "Mike")' },
2499
+ contract_type: {
2500
+ type: 'string',
2501
+ description: 'Filter by contract type: tm, block, tm_or_block, retainer, pro_bono, other',
2502
+ },
2503
+ tier: {
2504
+ type: 'string',
2505
+ description: 'Filter by account service tier: standard, frucare, ada, redteam',
2506
+ },
2507
+ marketing: {
2508
+ type: 'boolean',
2509
+ description: 'Filter to marketing clients only (true) or non-marketing (false)',
2510
+ },
2511
+ status: {
2512
+ type: 'string',
2513
+ description: 'Profile status: active (default), deactivated, or all',
2514
+ },
2515
+ limit: { type: 'number', description: 'Max rows to return (default 300)' },
2516
+ },
2517
+ },
2518
+ },
2519
+ {
2520
+ name: 'fcp_get_client',
2521
+ description: "Get the full WebOps profile for one client account: relationship/commercial data, primary contacts, and all of the account's websites with detected analytics (GA4/GTM/Clarity/Bugherd).",
2522
+ inputSchema: {
2523
+ type: 'object',
2524
+ properties: {
2525
+ account_id: { type: 'number', description: 'The account ID (required)' },
2526
+ },
2527
+ required: ['account_id'],
2528
+ },
2529
+ },
2530
+ {
2531
+ name: 'fcp_update_client_profile',
2532
+ description: "Update business/relationship fields on a client's WebOps profile. Technical facts (CMS, hosting, FruCare) are NOT editable here -- FCP detects/owns those. Only provided fields change.",
2533
+ inputSchema: {
2534
+ type: 'object',
2535
+ properties: {
2536
+ account_id: { type: 'number', description: 'The account ID to update (required)' },
2537
+ webops_lead: { type: 'string', description: 'Canonical WebOps lead' },
2538
+ relationship_sponsor: { type: 'string', description: 'Relationship sponsor' },
2539
+ contract_type: {
2540
+ type: 'string',
2541
+ description: 'tm, block, tm_or_block, retainer, pro_bono, none, other',
2542
+ },
2543
+ contract_rate: { type: 'string', description: 'Freeform rate (e.g. "$155/hour")' },
2544
+ contract_notes: { type: 'string', description: 'Contract notes' },
2545
+ is_marketing_client: { type: 'boolean', description: 'Whether this is a marketing client' },
2546
+ comms_platform: {
2547
+ type: 'string',
2548
+ description: 'Client collaboration platform (Google / MS Teams)',
2549
+ },
2550
+ client_time_zone: { type: 'string', description: 'Client time zone' },
2551
+ meeting_cadence: { type: 'string', description: 'Meeting cadence' },
2552
+ meeting_attendees: { type: 'string', description: 'Meeting attendees' },
2553
+ last_client_contact: { type: 'string', description: 'Last contact note' },
2554
+ looker_studio_url: { type: 'string', description: 'Looker Studio report URL' },
2555
+ shared_drive_url: { type: 'string', description: 'Shared drive URL' },
2556
+ client_assets_url: { type: 'string', description: 'Client assets URL' },
2557
+ unroo_board_url: { type: 'string', description: 'Unroo board URL' },
2558
+ client_goals: { type: 'string', description: 'Client goals & focus areas' },
2559
+ what_you_need_to_know: {
2560
+ type: 'string',
2561
+ description: 'Key things to know about this client',
2562
+ },
2563
+ general_notes: { type: 'string', description: 'General notes' },
2564
+ status: { type: 'string', description: 'active or deactivated' },
2565
+ mark_reviewed: {
2566
+ type: 'boolean',
2567
+ description: 'Set true to stamp last_reviewed_at = now (confirms the profile is accurate)',
2568
+ },
2569
+ },
2570
+ required: ['account_id'],
2571
+ },
2572
+ },
2316
2573
  // Fruition Shield - Shared WAF Gateway
2317
2574
  {
2318
2575
  name: 'fcp_shield_list_domains',
@@ -2805,6 +3062,10 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2805
3062
  // Track tool call for session management
2806
3063
  await sessionTracker.trackToolCall(name, `Called ${name}`);
2807
3064
  try {
3065
+ // Role-based access control: gate destructive / sensitive tools by RBAC role
3066
+ // resolved from /api/auth/me. Throws on insufficient privilege; the catch
3067
+ // block below converts the error into the standard MCP error response.
3068
+ await enforceToolPermission(name);
2808
3069
  switch (name) {
2809
3070
  case 'fcp_list_launches': {
2810
3071
  const result = await client.listLaunches(args);
@@ -3107,6 +3368,28 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
3107
3368
  ],
3108
3369
  };
3109
3370
  }
3371
+ // WebOps Client Directory Handlers
3372
+ case 'fcp_list_clients': {
3373
+ const filters = args;
3374
+ const result = await client.listClients(filters);
3375
+ return {
3376
+ content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
3377
+ };
3378
+ }
3379
+ case 'fcp_get_client': {
3380
+ const { account_id } = args;
3381
+ const result = await client.getClient(account_id);
3382
+ return {
3383
+ content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
3384
+ };
3385
+ }
3386
+ case 'fcp_update_client_profile': {
3387
+ const { account_id, ...updates } = args;
3388
+ const result = await client.updateClientProfile(account_id, updates);
3389
+ return {
3390
+ content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
3391
+ };
3392
+ }
3110
3393
  // Launch CRUD Handlers
3111
3394
  case 'fcp_create_launch': {
3112
3395
  const { name, platform, launch_type, target_launch_date, ...rest } = args;
@@ -4049,10 +4332,15 @@ async function main() {
4049
4332
  // Sync ~/.claude/skills with the shared Unroo KG (non-blocking, opt-in).
4050
4333
  // First run on a fresh workstation is dry-run only; user opts in via
4051
4334
  // `fcp-mcp-server sync-skills --enable`.
4335
+ // Passing the FCP key lets skills-sync route through FCP's Unroo proxy when
4336
+ // no personal UNROO_API_KEY is set — so the single `claude mcp add` key
4337
+ // covers skill sync too.
4052
4338
  runBackgroundSync({
4053
4339
  unrooApiKey: process.env.UNROO_API_KEY ?? "",
4054
4340
  userEmail: FCP_USER_EMAIL,
4055
4341
  unrooApiUrl: process.env.UNROO_API_URL,
4342
+ fcpApiUrl: FCP_API_URL,
4343
+ fcpApiToken: FCP_API_TOKEN,
4056
4344
  });
4057
4345
  // Handle graceful shutdown — idempotent, safe to call from any signal/event
4058
4346
  let shuttingDown = false;
@@ -47,10 +47,15 @@ Options:
47
47
  --enable Persist the one-time opt-in flag and run a real sync
48
48
  --help, -h Show this help
49
49
 
50
- Environment:
51
- UNROO_API_KEY Required. Same key the FCP MCP server uses.
52
- FCP_USER_EMAIL Required. Your Fruition team email; gates access to the KG.
53
- UNROO_API_URL Override (default https://app.unroo.io).
50
+ Environment (proxy mode — default, recommended):
51
+ FCP_API_TOKEN FCP API key. With this set and no UNROO_API_KEY, sync routes
52
+ through FCP's Unroo proxy no personal Unroo key needed.
53
+ FCP_API_URL FCP base URL (default https://fcp.fru.io).
54
+
55
+ Environment (direct mode — optional, legacy):
56
+ UNROO_API_KEY Personal Unroo key. If set, sync talks to Unroo directly.
57
+ FCP_USER_EMAIL Your Fruition team email; required for direct-mode attribution.
58
+ UNROO_API_URL Unroo base URL override (default https://app.unroo.io).
54
59
 
55
60
  State file: ${defaultStateFile(defaultSkillsDir())}
56
61
  `.trim();
@@ -95,6 +100,9 @@ export async function runSkillsCli(argv) {
95
100
  unrooApiKey: process.env.UNROO_API_KEY ?? '',
96
101
  userEmail: process.env.FCP_USER_EMAIL ?? '',
97
102
  unrooApiUrl: process.env.UNROO_API_URL,
103
+ // FCP key enables proxy mode when no personal UNROO_API_KEY is set.
104
+ fcpApiUrl: process.env.FCP_API_URL,
105
+ fcpApiToken: process.env.FCP_API_TOKEN,
98
106
  // --enable forces a real run; otherwise let lib decide based on opt-in flag
99
107
  dryRun: args.enable ? false : args.dryRun,
100
108
  };
@@ -51,6 +51,8 @@ export interface SyncOptions {
51
51
  stateFile?: string;
52
52
  unrooApiUrl?: string;
53
53
  unrooApiKey?: string;
54
+ fcpApiUrl?: string;
55
+ fcpApiToken?: string;
54
56
  userEmail?: string;
55
57
  dryRun?: boolean;
56
58
  log?: (msg: string) => void;
@@ -31,8 +31,18 @@ import { createHash } from 'crypto';
31
31
  // ---------------------------------------------------------------------------
32
32
  const STATE_VERSION = 1;
33
33
  const DEFAULT_UNROO_URL = 'https://app.unroo.io';
34
+ const DEFAULT_FCP_URL = 'https://fcp.fru.io';
35
+ // Direct mode: skills-sync talks straight to Unroo's knowledge-graph endpoints
36
+ // with a personal UNROO_API_KEY.
34
37
  const SEARCH_PATH = '/api/external/fcp/knowledge/search';
35
38
  const CAPTURE_PATH = '/api/external/fcp/knowledge/capture-skill';
39
+ // Proxy mode: skills-sync talks to FCP, which forwards to the same Unroo
40
+ // endpoints using FCP's own service key. This lets a single FCP API key (the
41
+ // one from `claude mcp add`) cover skill sync — no personal Unroo key to
42
+ // obtain, export, or rotate. Mirrors USE_FCP_UNROO_PROXY in the MCP server.
43
+ // See app/api/mcp/unroo/[...path]/route.ts.
44
+ const PROXY_SEARCH_PATH = '/api/mcp/unroo/knowledge/search';
45
+ const PROXY_CAPTURE_PATH = '/api/mcp/unroo/knowledge/capture-skill';
36
46
  // Refuse to upload bodies containing strings that look like a baked-in secret.
37
47
  // We capture the leading "label" and the candidate "value" separately so we
38
48
  // can reject obvious placeholder shapes (env-var names, template variables,
@@ -294,21 +304,20 @@ export function detectSecret(body) {
294
304
  export function hashBody(body) {
295
305
  return createHash('sha256').update(body, 'utf-8').digest('hex');
296
306
  }
297
- async function postCapture(baseUrl, apiKey, email, payload, fetchImpl) {
298
- const res = await fetchImpl(`${baseUrl}${CAPTURE_PATH}`, {
307
+ async function postCapture(ctx, payload) {
308
+ const res = await ctx.fetchImpl(ctx.captureUrl, {
299
309
  method: 'POST',
300
310
  headers: {
301
311
  'Content-Type': 'application/json',
302
312
  Accept: 'application/json',
303
- 'X-API-Key': apiKey,
304
- 'X-FCP-User-Email': email,
313
+ ...ctx.authHeaders,
305
314
  },
306
315
  body: JSON.stringify(payload),
307
316
  });
308
317
  const body = await res.text();
309
318
  return { ok: res.ok, status: res.status, body };
310
319
  }
311
- async function getSearch(baseUrl, apiKey, email, fetchImpl) {
320
+ async function getSearch(ctx) {
312
321
  const all = [];
313
322
  let cursor = null;
314
323
  // Defensive cap: 50 pages × 100 = 5000 skills, far more than the team will
@@ -317,11 +326,10 @@ async function getSearch(baseUrl, apiKey, email, fetchImpl) {
317
326
  const qs = new URLSearchParams({ node_type: 'skill', limit: '100' });
318
327
  if (cursor)
319
328
  qs.set('cursor', cursor);
320
- const res = await fetchImpl(`${baseUrl}${SEARCH_PATH}?${qs.toString()}`, {
329
+ const res = await ctx.fetchImpl(`${ctx.searchUrl}?${qs.toString()}`, {
321
330
  headers: {
322
331
  Accept: 'application/json',
323
- 'X-API-Key': apiKey,
324
- 'X-FCP-User-Email': email,
332
+ ...ctx.authHeaders,
325
333
  },
326
334
  });
327
335
  if (!res.ok) {
@@ -384,7 +392,7 @@ async function pushOnce(ctx, state, result) {
384
392
  continue;
385
393
  }
386
394
  try {
387
- const res = await postCapture(ctx.baseUrl, ctx.apiKey, ctx.email, payload, ctx.fetchImpl);
395
+ const res = await postCapture(ctx, payload);
388
396
  if (!res.ok) {
389
397
  ctx.log(`[skills-sync] push ${skill.slug} failed (HTTP ${res.status}): ${res.body.slice(0, 200)}`);
390
398
  continue;
@@ -403,7 +411,7 @@ async function pushOnce(ctx, state, result) {
403
411
  async function pullOnce(ctx, state, result) {
404
412
  let remoteSkills;
405
413
  try {
406
- remoteSkills = await getSearch(ctx.baseUrl, ctx.apiKey, ctx.email, ctx.fetchImpl);
414
+ remoteSkills = await getSearch(ctx);
407
415
  }
408
416
  catch (err) {
409
417
  ctx.log(`[skills-sync] pull failed: ${err.message}`);
@@ -475,18 +483,80 @@ async function pullOnce(ctx, state, result) {
475
483
  result.pulled.push(slug);
476
484
  }
477
485
  }
478
- // ---------------------------------------------------------------------------
479
- // Public entrypoints
480
- // ---------------------------------------------------------------------------
481
- function makeCtx(opts, dryRunOverride) {
486
+ /**
487
+ * Decide how skills-sync reaches the knowledge graph.
488
+ *
489
+ * Direct mode: a personal UNROO_API_KEY talks straight to Unroo. Used when the
490
+ * caller explicitly supplies an Unroo key (legacy / power-user setup).
491
+ *
492
+ * Proxy mode: the FCP API key talks to FCP, which forwards to the same Unroo
493
+ * endpoints with its own service key. This is the default — it means a
494
+ * developer only needs the one FCP key from `claude mcp add`, with no second
495
+ * key to obtain, export, or rotate. Mirrors USE_FCP_UNROO_PROXY in index.ts.
496
+ */
497
+ function resolveTransport(opts) {
498
+ const email = opts.userEmail ?? '';
499
+ const unrooKey = opts.unrooApiKey ?? '';
500
+ const fcpToken = opts.fcpApiToken ?? '';
501
+ // Direct mode wins when a personal Unroo key is explicitly provided.
502
+ if (unrooKey) {
503
+ if (!email) {
504
+ return {
505
+ configured: false,
506
+ reason: 'UNROO_API_KEY is set but FCP_USER_EMAIL is not — cannot attribute sync',
507
+ mode: 'direct', searchUrl: '', captureUrl: '', authHeaders: {},
508
+ };
509
+ }
510
+ const base = opts.unrooApiUrl ?? DEFAULT_UNROO_URL;
511
+ return {
512
+ configured: true,
513
+ mode: 'direct',
514
+ searchUrl: `${base}${SEARCH_PATH}`,
515
+ captureUrl: `${base}${CAPTURE_PATH}`,
516
+ authHeaders: { 'X-API-Key': unrooKey, 'X-FCP-User-Email': email },
517
+ };
518
+ }
519
+ // Proxy mode: the FCP key carries the sync.
520
+ if (fcpToken) {
521
+ if (fcpToken === 'dev_bypass') {
522
+ return {
523
+ configured: false,
524
+ reason: 'FCP_API_TOKEN=dev_bypass (local dev) — skill sync skipped',
525
+ mode: 'proxy', searchUrl: '', captureUrl: '', authHeaders: {},
526
+ };
527
+ }
528
+ const base = opts.fcpApiUrl ?? DEFAULT_FCP_URL;
529
+ // The FCP proxy reads X-Acting-User-Email for attribution; it honors the
530
+ // override only when it matches the key owner (or an Auth0 session),
531
+ // otherwise it falls back to the key owner — itself a real Fruition user,
532
+ // so the knowledge graph's Fruition-org gate passes either way.
533
+ const authHeaders = { 'X-API-Key': fcpToken };
534
+ if (email)
535
+ authHeaders['X-Acting-User-Email'] = email;
536
+ return {
537
+ configured: true,
538
+ mode: 'proxy',
539
+ searchUrl: `${base}${PROXY_SEARCH_PATH}`,
540
+ captureUrl: `${base}${PROXY_CAPTURE_PATH}`,
541
+ authHeaders,
542
+ };
543
+ }
544
+ return {
545
+ configured: false,
546
+ reason: 'neither UNROO_API_KEY nor FCP_API_TOKEN is set — cannot reach the knowledge graph',
547
+ mode: 'proxy', searchUrl: '', captureUrl: '', authHeaders: {},
548
+ };
549
+ }
550
+ function makeCtx(opts, dryRunOverride, transport) {
482
551
  const skillsDir = opts.skillsDir ?? defaultSkillsDir();
483
552
  const stateFile = opts.stateFile ?? defaultStateFile(skillsDir);
484
553
  return {
485
554
  skillsDir,
486
555
  stateFile,
487
- baseUrl: opts.unrooApiUrl ?? DEFAULT_UNROO_URL,
488
- apiKey: opts.unrooApiKey ?? '',
489
- email: opts.userEmail ?? '',
556
+ mode: transport.mode,
557
+ searchUrl: transport.searchUrl,
558
+ captureUrl: transport.captureUrl,
559
+ authHeaders: transport.authHeaders,
490
560
  fetchImpl: opts.fetchImpl ?? fetch,
491
561
  log: opts.log ?? ((m) => console.error(m)),
492
562
  dryRun: dryRunOverride,
@@ -509,12 +579,13 @@ export async function pushSkills(opts = {}) {
509
579
  // Fresh workstation -> force dry-run unless explicitly opted in OR caller
510
580
  // passed dryRun:false meaning "I know what I'm doing".
511
581
  const dryRun = opts.dryRun !== undefined ? opts.dryRun : state.optedIn !== true;
512
- const ctx = makeCtx(opts, dryRun);
582
+ const transport = resolveTransport(opts);
583
+ const ctx = makeCtx(opts, dryRun, transport);
513
584
  const result = emptyResult(dryRun);
514
- if (!ctx.apiKey || !ctx.email) {
515
- ctx.log('[skills-sync] skipping push: UNROO_API_KEY or FCP_USER_EMAIL not set');
585
+ if (!transport.configured) {
586
+ ctx.log(`[skills-sync] skipping push: ${transport.reason}`);
516
587
  result.ok = false;
517
- result.reason = 'missing auth';
588
+ result.reason = transport.reason ?? 'transport not configured';
518
589
  return result;
519
590
  }
520
591
  await pushOnce(ctx, state, result);
@@ -525,12 +596,13 @@ export async function pushSkills(opts = {}) {
525
596
  export async function pullSkills(opts = {}) {
526
597
  const state = loadState(opts.stateFile ?? defaultStateFile(opts.skillsDir ?? defaultSkillsDir()));
527
598
  const dryRun = opts.dryRun !== undefined ? opts.dryRun : state.optedIn !== true;
528
- const ctx = makeCtx(opts, dryRun);
599
+ const transport = resolveTransport(opts);
600
+ const ctx = makeCtx(opts, dryRun, transport);
529
601
  const result = emptyResult(dryRun);
530
- if (!ctx.apiKey || !ctx.email) {
531
- ctx.log('[skills-sync] skipping pull: UNROO_API_KEY or FCP_USER_EMAIL not set');
602
+ if (!transport.configured) {
603
+ ctx.log(`[skills-sync] skipping pull: ${transport.reason}`);
532
604
  result.ok = false;
533
- result.reason = 'missing auth';
605
+ result.reason = transport.reason ?? 'transport not configured';
534
606
  return result;
535
607
  }
536
608
  await pullOnce(ctx, state, result);
@@ -541,12 +613,13 @@ export async function pullSkills(opts = {}) {
541
613
  export async function syncSkills(opts = {}) {
542
614
  const state = loadState(opts.stateFile ?? defaultStateFile(opts.skillsDir ?? defaultSkillsDir()));
543
615
  const dryRun = opts.dryRun !== undefined ? opts.dryRun : state.optedIn !== true;
544
- const ctx = makeCtx(opts, dryRun);
616
+ const transport = resolveTransport(opts);
617
+ const ctx = makeCtx(opts, dryRun, transport);
545
618
  const result = emptyResult(dryRun);
546
- if (!ctx.apiKey || !ctx.email) {
547
- ctx.log('[skills-sync] skipping sync: UNROO_API_KEY or FCP_USER_EMAIL not set');
619
+ if (!transport.configured) {
620
+ ctx.log(`[skills-sync] skipping sync: ${transport.reason}`);
548
621
  result.ok = false;
549
- result.reason = 'missing auth';
622
+ result.reason = transport.reason ?? 'transport not configured';
550
623
  return result;
551
624
  }
552
625
  // Pull first so a brand-new workstation gets the team library before
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fruition/fcp-mcp-server",
3
- "version": "1.18.0",
3
+ "version": "1.20.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",
@@ -39,7 +39,7 @@
39
39
  "@modelcontextprotocol/sdk": "^1.0.0"
40
40
  },
41
41
  "devDependencies": {
42
- "@types/node": "^20.0.0",
42
+ "@types/node": "^25.6.2",
43
43
  "tsx": "^4.7.0",
44
44
  "typescript": "^5.3.0"
45
45
  }