@fruition/fcp-mcp-server 1.26.0 → 1.27.1

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.
Files changed (2) hide show
  1. package/dist/index.js +215 -34
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -39,6 +39,8 @@ import { runBackgroundSync } from './skills-sync.js';
39
39
  // Configuration
40
40
  const FCP_API_URL = process.env.FCP_API_URL || 'https://fcp.fru.io';
41
41
  const FCP_API_TOKEN = process.env.FCP_API_TOKEN || '';
42
+ // Small async sleep used by bounded polling loops (e.g. fcp_backup_get_database).
43
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
42
44
  // Unroo API can be called directly (legacy) or proxied through FCP (recommended)
43
45
  // When UNROO_API_KEY is not set, Unroo calls go through FCP's proxy at /api/mcp/unroo/*
44
46
  const UNROO_API_URL = process.env.UNROO_API_URL || 'https://app.unroo.io';
@@ -112,6 +114,7 @@ const TOOL_PERMISSIONS = {
112
114
  fcp_backup_update_pairing: 'admin',
113
115
  fcp_backup_download: 'admin',
114
116
  fcp_backup_download_prepared: 'admin',
117
+ fcp_backup_get_database: 'admin',
115
118
  fcp_kinsta_backup_download: 'admin',
116
119
  fcp_filesync_start_sync: 'admin',
117
120
  fcp_filesync_get_confirmation: 'admin',
@@ -198,19 +201,22 @@ const TOOL_PERMISSIONS = {
198
201
  fcp_get_bulk_notify_history: 'viewer',
199
202
  };
200
203
  // Resolved role for the current API key. Cached only on successful lookup so
201
- // transient FCP outages don't pin us to a failure for the rest of the session.
204
+ // transient FCP outages don't pin us to 'none' for the rest of the session.
202
205
  let cachedUserRole;
206
+ // The API key owner's email, as reported by /api/mcp/me. Used as the fallback
207
+ // identity for "my"-scoped queries when FCP_USER_EMAIL is not configured.
208
+ let cachedUserEmail;
203
209
  async function fetchUserRole() {
204
210
  if (cachedUserRole !== undefined)
205
- return { ok: true, role: cachedUserRole };
211
+ return cachedUserRole;
206
212
  // dev_bypass is the local-dev token; treat as super_admin to avoid
207
213
  // gating local development against role lookup.
208
214
  if (FCP_API_TOKEN === 'dev_bypass') {
209
215
  cachedUserRole = 'super_admin';
210
- return { ok: true, role: 'super_admin' };
216
+ return cachedUserRole;
211
217
  }
212
218
  if (!FCP_API_TOKEN) {
213
- return { ok: false, kind: 'no_token' };
219
+ return 'none';
214
220
  }
215
221
  try {
216
222
  const res = await fetch(`${FCP_API_URL}/api/mcp/me`, {
@@ -219,22 +225,32 @@ async function fetchUserRole() {
219
225
  });
220
226
  if (!res.ok) {
221
227
  console.error(`[MCP Server] Role lookup failed: HTTP ${res.status}`);
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 };
228
+ return 'none';
226
229
  }
227
230
  const data = await res.json();
228
231
  const role = data?.role ?? 'none';
229
232
  cachedUserRole = role;
233
+ if (data?.email)
234
+ cachedUserEmail = data.email;
230
235
  console.error(`[MCP Server] Resolved user role: ${role} (${data?.email ?? 'unknown'})`);
231
- return { ok: true, role };
236
+ return role;
232
237
  }
233
238
  catch (err) {
234
239
  console.error('[MCP Server] Role lookup error:', err);
235
- return { ok: false, kind: 'transient' };
240
+ return 'none';
236
241
  }
237
242
  }
243
+ // Resolve the acting user's email for "my"-scoped queries (e.g. unroo_get_my_tasks).
244
+ // Precedence: FCP_USER_EMAIL (the per-developer identity) -> the API key owner's
245
+ // email from /api/mcp/me (the shared key's creator, honoring the "based on API
246
+ // key" contract). Returns '' if neither resolves, so callers can surface a clear
247
+ // "set FCP_USER_EMAIL" message rather than silently returning the org-wide list.
248
+ async function resolveActingEmail() {
249
+ if (FCP_USER_EMAIL)
250
+ return FCP_USER_EMAIL;
251
+ await fetchUserRole(); // populates cachedUserEmail as a side effect
252
+ return cachedUserEmail ?? '';
253
+ }
238
254
  function isRoleAtLeast(actual, required) {
239
255
  if (actual === 'none')
240
256
  return required === 'none';
@@ -246,26 +262,7 @@ function isRoleAtLeast(actual, required) {
246
262
  }
247
263
  async function enforceToolPermission(toolName) {
248
264
  const required = TOOL_PERMISSIONS[toolName] ?? 'viewer';
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;
265
+ const actual = await fetchUserRole();
269
266
  if (!isRoleAtLeast(actual, required)) {
270
267
  throw new Error(`Permission denied: tool '${toolName}' requires ${required} role; ` +
271
268
  `your role is '${actual}'. Contact a super_admin (Brad, Mattox, or Andrea) ` +
@@ -2070,17 +2067,17 @@ const TOOLS = [
2070
2067
  },
2071
2068
  {
2072
2069
  name: 'unroo_get_my_tasks',
2073
- description: 'Get tasks assigned to the current user (based on API key). Useful for finding your work items.',
2070
+ description: 'Get tasks assigned to you. "You" resolves from FCP_USER_EMAIL (your Fruition email), falling back to the FCP API key owner if FCP_USER_EMAIL is unset. Returns an error asking you to set FCP_USER_EMAIL if neither can be resolved.',
2074
2071
  inputSchema: {
2075
2072
  type: 'object',
2076
2073
  properties: {
2077
2074
  status: {
2078
2075
  type: 'string',
2079
- description: 'Filter by status (comma-separated for multiple)',
2076
+ description: 'Filter by status: To Do, In Progress, Done, Blocked (case-sensitive, comma-separated for multiple)',
2080
2077
  },
2081
2078
  limit: {
2082
2079
  type: 'number',
2083
- description: 'Maximum number of tasks to return',
2080
+ description: 'Maximum number of tasks to return (default 50)',
2084
2081
  },
2085
2082
  },
2086
2083
  },
@@ -3240,6 +3237,36 @@ const TOOLS = [
3240
3237
  required: ['jobId'],
3241
3238
  },
3242
3239
  },
3240
+ {
3241
+ name: 'fcp_backup_get_database',
3242
+ description: 'Return a database backup for a site in ONE call. Reuses the most recent completed backup if it is younger than maxAgeHours, otherwise triggers a fresh one and waits. Production data is sanitized by default. Returns a presigned download URL (1h expiry) when ready. If the backup or sanitization is still running when waitSeconds is exhausted, returns status:"pending" with a handle (backupId or sanitizeJobId) so you can poll fcp_backup_get_status / fcp_backup_sanitize_status and re-call. Admin only.',
3243
+ inputSchema: {
3244
+ type: 'object',
3245
+ properties: {
3246
+ websiteId: {
3247
+ type: 'number',
3248
+ description: 'The website ID to back up',
3249
+ },
3250
+ maxAgeHours: {
3251
+ type: 'number',
3252
+ description: 'Reuse the latest completed backup if it is younger than this many hours (default: 24). Ignored when fresh=true.',
3253
+ },
3254
+ fresh: {
3255
+ type: 'boolean',
3256
+ description: 'Force a brand-new backup, ignoring any existing one (default: false).',
3257
+ },
3258
+ sanitize: {
3259
+ type: 'boolean',
3260
+ description: 'Whether to sanitize production data. Leave unset to use the server default (production is sanitized, non-production is returned raw). Set false to force a raw dump (admin, use with care).',
3261
+ },
3262
+ waitSeconds: {
3263
+ type: 'number',
3264
+ description: 'Total bounded wait budget in seconds across the trigger and sanitize polls (default: 180). On exhaustion the tool returns status:"pending" with a resume handle rather than hanging.',
3265
+ },
3266
+ },
3267
+ required: ['websiteId'],
3268
+ },
3269
+ },
3243
3270
  {
3244
3271
  name: 'fcp_backup_list_pairings',
3245
3272
  description: 'List site pairings for backup management. Optionally filter by site ID. Shows production/staging/dev relationships grouped by account.',
@@ -4414,9 +4441,30 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
4414
4441
  }
4415
4442
  case 'unroo_get_my_tasks': {
4416
4443
  const { status, limit } = args;
4417
- // Note: The API key determines the user, tasks are filtered server-side
4444
+ // "My tasks" must be scoped by an explicit assignee_email filter. There is
4445
+ // NO server-side auto-scoping: GET /api/external/fcp/tasks returns every
4446
+ // task unless assignee_email is supplied, and the shared FCP_API_TOKEN
4447
+ // resolves to the key creator (not the developer running Claude Code), so
4448
+ // the key alone cannot identify "me". Resolve the acting user's email and
4449
+ // pass it as the filter.
4450
+ const assignee_email = await resolveActingEmail();
4451
+ if (!assignee_email) {
4452
+ return {
4453
+ content: [
4454
+ {
4455
+ type: 'text',
4456
+ text: JSON.stringify({
4457
+ total: 0,
4458
+ tasks: [],
4459
+ error: 'Could not determine your identity. Set FCP_USER_EMAIL (your Fruition email) in your MCP server env config so "my tasks" can be scoped to you.',
4460
+ }, null, 2),
4461
+ },
4462
+ ],
4463
+ };
4464
+ }
4418
4465
  const result = await unrooClient.listTasks({
4419
4466
  status,
4467
+ assignee_email,
4420
4468
  limit: limit || 50,
4421
4469
  });
4422
4470
  return {
@@ -4425,6 +4473,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
4425
4473
  type: 'text',
4426
4474
  text: JSON.stringify({
4427
4475
  total: result.tasks.length,
4476
+ assignee_email,
4428
4477
  tasks: result.tasks,
4429
4478
  }, null, 2),
4430
4479
  },
@@ -4951,6 +5000,138 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
4951
5000
  content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
4952
5001
  };
4953
5002
  }
5003
+ case 'fcp_backup_get_database': {
5004
+ const { websiteId, maxAgeHours = 24, fresh = false, sanitize, waitSeconds = 180, } = args;
5005
+ const siteId = `site-${websiteId}`;
5006
+ const deadline = Date.now() + Math.max(0, waitSeconds) * 1000;
5007
+ const pollIntervalMs = 10_000;
5008
+ const isDbType = (t) => !!t && ['mysql', 'database', 'postgresql', 'mysqldump'].includes(String(t).toLowerCase());
5009
+ const respond = (payload) => ({
5010
+ content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }],
5011
+ });
5012
+ let backupId;
5013
+ let ageMinutes;
5014
+ // 1. Reuse the most recent completed DB backup unless a fresh one is requested.
5015
+ if (!fresh) {
5016
+ const list = await client.backupListBackups(siteId);
5017
+ const backups = Array.isArray(list) ? list : (list?.backups ?? []);
5018
+ const completed = backups
5019
+ .filter((b) => b?.status === 'completed' && isDbType(b?.backup_type) && b?.backup_location)
5020
+ .sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime());
5021
+ const latest = completed[0];
5022
+ if (latest?.created_at) {
5023
+ ageMinutes = (Date.now() - new Date(latest.created_at).getTime()) / 60_000;
5024
+ if (ageMinutes <= maxAgeHours * 60) {
5025
+ backupId = latest.backup_id;
5026
+ }
5027
+ }
5028
+ }
5029
+ // 2. Trigger a fresh backup if none reusable, then poll until completed.
5030
+ if (!backupId) {
5031
+ const trig = await client.backupTrigger(websiteId, 'mcp');
5032
+ if (!trig?.backupId) {
5033
+ return respond({
5034
+ status: 'error',
5035
+ stage: 'trigger',
5036
+ message: trig?.error || trig?.message || 'Failed to trigger backup',
5037
+ detail: trig,
5038
+ });
5039
+ }
5040
+ backupId = trig.backupId;
5041
+ ageMinutes = 0;
5042
+ let backupStatus = trig.status || 'queued';
5043
+ while (backupStatus !== 'completed') {
5044
+ if (Date.now() >= deadline) {
5045
+ return respond({
5046
+ status: 'pending',
5047
+ stage: 'backup',
5048
+ backupId,
5049
+ message: `Backup still '${backupStatus}' after ${waitSeconds}s. Poll fcp_backup_get_status({ backupId: "${backupId}" }), then re-call fcp_backup_get_database to reuse it once completed.`,
5050
+ });
5051
+ }
5052
+ await sleep(pollIntervalMs);
5053
+ const st = await client.backupGetStatus({ backupId });
5054
+ const job = st?.job ?? st;
5055
+ backupStatus = job?.status || backupStatus;
5056
+ if (backupStatus === 'failed') {
5057
+ return respond({
5058
+ status: 'error',
5059
+ stage: 'backup',
5060
+ backupId,
5061
+ message: job?.error_message || 'Backup job failed',
5062
+ });
5063
+ }
5064
+ }
5065
+ }
5066
+ // 3. Prepare the download (sanitizes production data by default).
5067
+ const prep = await client.backupDownloadPrepared({
5068
+ siteId,
5069
+ backupId: backupId,
5070
+ downloadType: 'database',
5071
+ sanitize,
5072
+ });
5073
+ // Non-production (or explicit sanitize:false): direct presigned URL.
5074
+ if (prep?.downloadUrl && prep?.sanitized !== true) {
5075
+ return respond({
5076
+ status: 'ready',
5077
+ backupId,
5078
+ sanitized: false,
5079
+ downloadUrl: prep.downloadUrl,
5080
+ fileName: prep.fileName,
5081
+ sizeBytes: prep.sizeBytes,
5082
+ backupType: prep.backupType,
5083
+ ageMinutes: ageMinutes != null ? Math.round(ageMinutes) : undefined,
5084
+ expiresIn: 3600,
5085
+ });
5086
+ }
5087
+ // Production: poll the sanitization job until it produces a download URL.
5088
+ if (prep?.jobId) {
5089
+ const sanitizeJobId = prep.jobId;
5090
+ for (;;) {
5091
+ if (Date.now() >= deadline) {
5092
+ return respond({
5093
+ status: 'pending',
5094
+ stage: 'sanitize',
5095
+ backupId,
5096
+ sanitizeJobId,
5097
+ message: `Sanitization still running after ${waitSeconds}s. Poll fcp_backup_sanitize_status({ jobId: "${sanitizeJobId}" }) for the download URL.`,
5098
+ });
5099
+ }
5100
+ await sleep(pollIntervalMs);
5101
+ const s = await client.backupSanitizeStatus(sanitizeJobId);
5102
+ if (s?.status === 'completed' && s?.downloadUrl) {
5103
+ return respond({
5104
+ status: 'ready',
5105
+ backupId,
5106
+ sanitizeJobId,
5107
+ sanitized: true,
5108
+ downloadUrl: s.downloadUrl,
5109
+ fileName: s.fileName,
5110
+ sizeBytes: s.sanitizedSizeBytes,
5111
+ ageMinutes: ageMinutes != null ? Math.round(ageMinutes) : undefined,
5112
+ expiresIn: 3600,
5113
+ });
5114
+ }
5115
+ if (s?.status === 'failed') {
5116
+ return respond({
5117
+ status: 'error',
5118
+ stage: 'sanitize',
5119
+ backupId,
5120
+ sanitizeJobId,
5121
+ message: s?.error || 'Sanitization failed',
5122
+ });
5123
+ }
5124
+ }
5125
+ }
5126
+ // Unexpected prepare response — surface it for debugging.
5127
+ return respond({
5128
+ status: 'error',
5129
+ stage: 'prepare',
5130
+ backupId,
5131
+ message: 'Unexpected response from backup download preparation',
5132
+ detail: prep,
5133
+ });
5134
+ }
4954
5135
  case 'fcp_backup_list_pairings': {
4955
5136
  const { siteId } = args;
4956
5137
  const result = await client.backupListPairings(siteId);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fruition/fcp-mcp-server",
3
- "version": "1.26.0",
3
+ "version": "1.27.1",
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",