@fruition/fcp-mcp-server 1.27.0 → 1.28.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/README.md CHANGED
@@ -16,6 +16,15 @@ MCP (Model Context Protocol) server that gives Claude Code direct access to the
16
16
  | `fcp_add_progress_note` | Add progress notes to document work done |
17
17
  | `fcp_get_claude_md` | Generate CLAUDE.md content for a launch |
18
18
 
19
+ ### Tasker Freeze Management Tools
20
+
21
+ | Tool | Description |
22
+ |------|-------------|
23
+ | `fcp_list_freezes` | List active + scheduled auto-merge freezes and the available repos |
24
+ | `fcp_freeze_repo` | Schedule a freeze (by days or explicit window) to hold a repo's deploys |
25
+ | `fcp_update_freeze` | Edit an existing freeze's window or reason |
26
+ | `fcp_unfreeze` | Remove a freeze by id, or clear all freezes for a repo |
27
+
19
28
  ### Unroo Task Management Tools
20
29
 
21
30
  | Tool | Description |
package/dist/index.d.ts CHANGED
@@ -7,4 +7,401 @@
7
7
  * - Update checklist item status
8
8
  * - Get project context for migrations
9
9
  */
10
+ interface Launch {
11
+ id: number;
12
+ name: string;
13
+ description: string | null;
14
+ platform: string;
15
+ launch_type: string;
16
+ status: string;
17
+ priority: string;
18
+ target_launch_date: string;
19
+ domain: string | null;
20
+ legacy_domain: string | null;
21
+ legacy_access_info: Record<string, any>;
22
+ [key: string]: any;
23
+ }
24
+ interface ChecklistItem {
25
+ id: number;
26
+ title: string;
27
+ description: string | null;
28
+ status: string;
29
+ category: string;
30
+ is_blocker: boolean;
31
+ claude_instructions: string | null;
32
+ [key: string]: any;
33
+ }
34
+ interface LaunchDetail {
35
+ launch: Launch;
36
+ checklist: ChecklistItem[];
37
+ [key: string]: any;
38
+ }
39
+ export declare class FCPClient {
40
+ private baseUrl;
41
+ private token;
42
+ private defaultTimeout;
43
+ constructor(baseUrl: string, token: string);
44
+ private fetch;
45
+ listLaunches(filters?: {
46
+ status?: string;
47
+ platform?: string;
48
+ upcoming?: number;
49
+ limit?: number;
50
+ }): Promise<{
51
+ launches: Launch[];
52
+ }>;
53
+ getLaunch(id: number): Promise<LaunchDetail>;
54
+ createChecklistItem(launchId: number, input: {
55
+ title: string;
56
+ category: string;
57
+ description?: string;
58
+ role?: string;
59
+ environment?: string;
60
+ assigned_to_id?: number;
61
+ due_date?: string;
62
+ sort_order?: number;
63
+ depends_on_item_id?: number;
64
+ is_required?: boolean;
65
+ is_blocking?: boolean;
66
+ external_link?: string;
67
+ }): Promise<ChecklistItem>;
68
+ updateChecklistItem(launchId: number, itemId: number, updates: {
69
+ status?: string;
70
+ notes?: string;
71
+ title?: string;
72
+ description?: string | null;
73
+ category?: string;
74
+ role?: string;
75
+ environment?: string;
76
+ assigned_to_id?: number | null;
77
+ due_date?: string | null;
78
+ sort_order?: number;
79
+ depends_on_item_id?: number | null;
80
+ is_required?: boolean;
81
+ is_blocking?: boolean;
82
+ external_link?: string | null;
83
+ evidence_url?: string | null;
84
+ jira_ticket_key?: string | null;
85
+ completion_notes?: string | null;
86
+ }): Promise<ChecklistItem>;
87
+ deleteChecklistItem(launchId: number, itemId: number): Promise<{
88
+ success: boolean;
89
+ }>;
90
+ private buildQuery;
91
+ listAlerts(q?: {
92
+ status?: string;
93
+ source?: string;
94
+ severity?: string;
95
+ cluster?: string;
96
+ namespace?: string;
97
+ domain?: string;
98
+ websiteId?: number;
99
+ environment?: string;
100
+ from?: string;
101
+ to?: string;
102
+ page?: number;
103
+ limit?: number;
104
+ }): Promise<any>;
105
+ getAlert(id: number): Promise<any>;
106
+ createAlert(input: {
107
+ alertName: string;
108
+ severity?: string;
109
+ summary?: string;
110
+ cluster?: string;
111
+ namespace?: string;
112
+ domain?: string;
113
+ websiteId?: number;
114
+ }): Promise<any>;
115
+ updateAlert(id: number, updates: {
116
+ status?: string;
117
+ acknowledgedBy?: string;
118
+ silencedUntil?: string;
119
+ silencedBy?: string;
120
+ adminNotes?: string;
121
+ websiteId?: number;
122
+ unrooTaskId?: number;
123
+ }): Promise<any>;
124
+ getAlertStats(): Promise<any>;
125
+ listAlertRules(accountId?: number): Promise<any>;
126
+ getAlertRule(ruleId: number): Promise<any>;
127
+ createAlertRule(input: Record<string, any>): Promise<any>;
128
+ updateAlertRule(ruleId: number, updates: Record<string, any>): Promise<any>;
129
+ deleteAlertRule(ruleId: number): Promise<any>;
130
+ listOutages(q?: {
131
+ status?: string;
132
+ accountId?: number;
133
+ limit?: number;
134
+ }): Promise<any>;
135
+ getOutage(outageId: number): Promise<any>;
136
+ createOutage(input: Record<string, any>): Promise<any>;
137
+ updateOutage(outageId: number, updates: Record<string, any>): Promise<any>;
138
+ resolveOutage(outageId: number): Promise<any>;
139
+ bulkNotifyClients(input: {
140
+ outageIds: number[];
141
+ notificationType: string;
142
+ subject: string;
143
+ message: string;
144
+ channels?: string[];
145
+ sentBy?: string;
146
+ }): Promise<any>;
147
+ getBulkNotifyHistory(limit?: number): Promise<any>;
148
+ createLaunch(input: {
149
+ name: string;
150
+ platform: string;
151
+ launch_type: string;
152
+ target_launch_date: string;
153
+ website_id?: number;
154
+ account_id?: number;
155
+ description?: string;
156
+ soft_launch_date?: string;
157
+ kickoff_date?: string;
158
+ priority?: string;
159
+ jira_project_key?: string;
160
+ unroo_project_id?: number;
161
+ webops_lead_id?: number;
162
+ devops_lead_id?: number;
163
+ dev_lead_id?: number;
164
+ seo_lead_id?: number;
165
+ client_contact?: string;
166
+ client_contact_email?: string;
167
+ use_default_checklist?: boolean;
168
+ }): Promise<{
169
+ launch: Launch;
170
+ }>;
171
+ updateLaunch(id: number, updates: {
172
+ website_id?: number | null;
173
+ account_id?: number | null;
174
+ name?: string;
175
+ description?: string;
176
+ platform?: string;
177
+ launch_type?: string;
178
+ target_launch_date?: string;
179
+ soft_launch_date?: string | null;
180
+ actual_launch_date?: string | null;
181
+ kickoff_date?: string | null;
182
+ status?: string;
183
+ priority?: string;
184
+ jira_project_key?: string | null;
185
+ unroo_project_id?: number | null;
186
+ webops_lead_id?: number | null;
187
+ devops_lead_id?: number | null;
188
+ dev_lead_id?: number | null;
189
+ seo_lead_id?: number | null;
190
+ client_contact?: string | null;
191
+ client_contact_email?: string | null;
192
+ }): Promise<any>;
193
+ deleteLaunch(id: number): Promise<{
194
+ success: boolean;
195
+ }>;
196
+ getSuccessFactors(launchId: number, itemId: number): Promise<{
197
+ factors: any[];
198
+ }>;
199
+ validateChecklistItem(launchId: number, itemId: number): Promise<any>;
200
+ getClaudeMd(launchId: number): Promise<{
201
+ content: string;
202
+ }>;
203
+ addNote(launchId: number, content: string): Promise<any>;
204
+ listFileSyncConfigs(filters?: {
205
+ enabled?: boolean;
206
+ search?: string;
207
+ sync_direction?: string;
208
+ }): Promise<{
209
+ success: boolean;
210
+ data: any[];
211
+ count: number;
212
+ }>;
213
+ getFileSyncJobs(filters?: {
214
+ config_id?: number;
215
+ status?: string;
216
+ limit?: number;
217
+ }): Promise<{
218
+ success: boolean;
219
+ data: any[];
220
+ count: number;
221
+ }>;
222
+ startFileSync(input: {
223
+ config_id: number;
224
+ trigger_type?: string;
225
+ triggered_by?: string;
226
+ confirmation_token?: string;
227
+ }): Promise<{
228
+ success: boolean;
229
+ data: any;
230
+ message: string;
231
+ }>;
232
+ cancelFileSync(jobId: string): Promise<{
233
+ success: boolean;
234
+ message: string;
235
+ }>;
236
+ getFileSyncConfirmation(configId: number): Promise<{
237
+ success: boolean;
238
+ data: {
239
+ token: string;
240
+ config_id: number;
241
+ expires_at: string;
242
+ ttl_seconds: number;
243
+ warnings: string[];
244
+ };
245
+ }>;
246
+ triggerNucleiScan(websiteId: number, options?: {
247
+ url?: string;
248
+ severity?: string;
249
+ templates?: string;
250
+ }): Promise<any>;
251
+ getNucleiResults(websiteId: number, options?: {
252
+ scan_id?: string;
253
+ limit?: number;
254
+ status?: string;
255
+ }): Promise<any>;
256
+ scanSecurityHeaders(websiteId: number): Promise<any>;
257
+ getSecurityHeadersResults(websiteId: number, scanId?: string): Promise<any>;
258
+ listSites(filters?: {
259
+ account_id?: number;
260
+ cms?: string;
261
+ environment?: string;
262
+ fru_hosted?: boolean;
263
+ retired?: string;
264
+ limit?: number;
265
+ offset?: number;
266
+ }): Promise<any>;
267
+ searchSites(query: string, limit?: number): Promise<any>;
268
+ getSite(siteId: number): Promise<any>;
269
+ createSite(production: {
270
+ account_id: number;
271
+ domain: string;
272
+ cms: string;
273
+ url_full?: string;
274
+ git_provider?: string;
275
+ git_link?: string;
276
+ hosting_provider?: string;
277
+ fru_hosted?: boolean;
278
+ k8s_cluster?: string;
279
+ k8s_namespace?: string;
280
+ }, staging?: Array<{
281
+ domain: string;
282
+ k8s_namespace: string;
283
+ }>): Promise<any>;
284
+ updateSite(siteId: number, updates: Record<string, any>): Promise<any>;
285
+ deleteSite(siteId: number, options?: {
286
+ reason?: string;
287
+ forward_url?: string;
288
+ }): Promise<any>;
289
+ getLocalSetupGuide(siteId: number): Promise<any>;
290
+ listClients(filters?: {
291
+ q?: string;
292
+ webops_lead?: string;
293
+ contract_type?: string;
294
+ tier?: string;
295
+ marketing?: boolean;
296
+ status?: string;
297
+ limit?: number;
298
+ }): Promise<any>;
299
+ getClient(accountId: number): Promise<any>;
300
+ updateClientProfile(accountId: number, updates: Record<string, any>): Promise<any>;
301
+ shieldListDomains(filters?: {
302
+ status?: string;
303
+ account_id?: number;
304
+ }): Promise<any>;
305
+ shieldAddDomain(input: {
306
+ domain: string;
307
+ origin_type?: 'custom' | 's3';
308
+ origin_host?: string;
309
+ origin_port?: number;
310
+ origin_protocol?: string;
311
+ s3_bucket?: string;
312
+ s3_region?: string;
313
+ s3_origin_path?: string;
314
+ website_id?: number;
315
+ account_id?: number;
316
+ cache_profile?: string;
317
+ waf_profile?: string;
318
+ geo_block_countries?: string[];
319
+ bot_control_enabled?: boolean;
320
+ notes?: string;
321
+ }): Promise<any>;
322
+ shieldGetDomain(domainId: number): Promise<any>;
323
+ shieldUpdateDomain(domainId: number, updates: Record<string, any>): Promise<any>;
324
+ shieldRemoveDomain(domainId: number): Promise<any>;
325
+ shieldGetMetrics(): Promise<any>;
326
+ shieldVerifyDomain(domainId: number): Promise<any>;
327
+ private fetchText;
328
+ trustedIpListRanges(filters?: {
329
+ group?: string;
330
+ include_pending?: boolean;
331
+ include_expired?: boolean;
332
+ }): Promise<any>;
333
+ trustedIpAddRange(input: {
334
+ group_id: number;
335
+ cidr: string;
336
+ label?: string;
337
+ purpose?: string;
338
+ ticket_ref?: string;
339
+ expires_at?: string;
340
+ override_reason?: string;
341
+ }): Promise<any>;
342
+ trustedIpUpdateRange(id: number, updates: Record<string, unknown>): Promise<any>;
343
+ trustedIpRemoveRange(id: number): Promise<any>;
344
+ trustedIpExport(opts?: {
345
+ format?: string;
346
+ group?: string;
347
+ purpose?: string;
348
+ separator?: string;
349
+ }): Promise<string>;
350
+ listFreezes(): Promise<any>;
351
+ freezeRepo(input: {
352
+ repo: string;
353
+ reason: string;
354
+ days?: number;
355
+ starts_at?: string;
356
+ ends_at?: string;
357
+ }): Promise<any>;
358
+ updateFreeze(id: number, updates: Record<string, unknown>): Promise<any>;
359
+ unfreeze(input: {
360
+ id?: number;
361
+ repo?: string;
362
+ }): Promise<any>;
363
+ backupListSites(): Promise<any>;
364
+ backupGetConfig(): Promise<any>;
365
+ backupListEligible(): Promise<any>;
366
+ backupEnable(siteId: number): Promise<any>;
367
+ backupTrigger(websiteId: number, triggerType?: string): Promise<any>;
368
+ backupCheckTrigger(websiteId: number): Promise<any>;
369
+ backupListBackups(siteId: string): Promise<any>;
370
+ backupGetStatus(options: {
371
+ backupId?: string;
372
+ websiteId?: number;
373
+ }): Promise<any>;
374
+ backupDownload(backupId: string): Promise<any>;
375
+ backupDownloadPrepared(input: {
376
+ siteId: string;
377
+ backupId: string;
378
+ downloadType: string;
379
+ format?: string;
380
+ sanitize?: boolean;
381
+ }): Promise<any>;
382
+ backupSanitizeStatus(jobId: string): Promise<any>;
383
+ backupListPairings(siteId?: number): Promise<any>;
384
+ backupUpdatePairing(siteId: number, pairingConfig: Record<string, any>): Promise<any>;
385
+ backupDeletePairing(siteId: number): Promise<any>;
386
+ kinstaBackupListSites(): Promise<any>;
387
+ kinstaBackupListBackups(siteId: string): Promise<any>;
388
+ kinstaBackupDownload(s3Key: string): Promise<any>;
389
+ cloneToStagingConfirm(productionSiteId: number, stagingSiteId: number): Promise<any>;
390
+ cloneToStaging(params: {
391
+ productionSiteId: number;
392
+ stagingSiteId: number;
393
+ includeFiles?: boolean;
394
+ includeDatabase?: boolean;
395
+ runSearchReplace?: boolean;
396
+ confirmationToken: string;
397
+ backupId?: string;
398
+ }): Promise<any>;
399
+ getCloneStatus(cloneId: string): Promise<any>;
400
+ listCloneOperations(params?: {
401
+ productionSiteId?: number;
402
+ stagingSiteId?: number;
403
+ limit?: number;
404
+ }): Promise<any>;
405
+ getDevEnvironmentInfo(siteId: number): Promise<any>;
406
+ }
10
407
  export {};
package/dist/index.js CHANGED
@@ -98,6 +98,7 @@ const TOOL_PERMISSIONS = {
98
98
  fcp_trusted_ip_remove_range: 'super_admin',
99
99
  fcp_backup_delete_pairing: 'super_admin',
100
100
  fcp_filesync_cancel_sync: 'super_admin',
101
+ fcp_unfreeze: 'super_admin', // lifting a freeze releases a held deploy
101
102
  // --- admin+: mutating ops with real-world side effects ---
102
103
  fcp_create_launch: 'admin',
103
104
  fcp_update_launch: 'admin',
@@ -108,6 +109,8 @@ const TOOL_PERMISSIONS = {
108
109
  fcp_shield_update_domain: 'admin',
109
110
  fcp_trusted_ip_add_range: 'admin',
110
111
  fcp_trusted_ip_update_range: 'admin',
112
+ fcp_freeze_repo: 'admin',
113
+ fcp_update_freeze: 'admin',
111
114
  fcp_backup_enable: 'admin',
112
115
  fcp_backup_trigger: 'admin',
113
116
  fcp_backup_check_trigger: 'admin',
@@ -157,6 +160,7 @@ const TOOL_PERMISSIONS = {
157
160
  fcp_shield_get_metrics: 'viewer',
158
161
  fcp_trusted_ip_list_ranges: 'viewer',
159
162
  fcp_trusted_ip_export: 'viewer',
163
+ fcp_list_freezes: 'viewer',
160
164
  fcp_backup_list_sites: 'viewer',
161
165
  fcp_backup_get_config: 'viewer',
162
166
  fcp_backup_list_eligible: 'viewer',
@@ -203,6 +207,9 @@ const TOOL_PERMISSIONS = {
203
207
  // Resolved role for the current API key. Cached only on successful lookup so
204
208
  // transient FCP outages don't pin us to 'none' for the rest of the session.
205
209
  let cachedUserRole;
210
+ // The API key owner's email, as reported by /api/mcp/me. Used as the fallback
211
+ // identity for "my"-scoped queries when FCP_USER_EMAIL is not configured.
212
+ let cachedUserEmail;
206
213
  async function fetchUserRole() {
207
214
  if (cachedUserRole !== undefined)
208
215
  return cachedUserRole;
@@ -227,6 +234,8 @@ async function fetchUserRole() {
227
234
  const data = await res.json();
228
235
  const role = data?.role ?? 'none';
229
236
  cachedUserRole = role;
237
+ if (data?.email)
238
+ cachedUserEmail = data.email;
230
239
  console.error(`[MCP Server] Resolved user role: ${role} (${data?.email ?? 'unknown'})`);
231
240
  return role;
232
241
  }
@@ -235,6 +244,17 @@ async function fetchUserRole() {
235
244
  return 'none';
236
245
  }
237
246
  }
247
+ // Resolve the acting user's email for "my"-scoped queries (e.g. unroo_get_my_tasks).
248
+ // Precedence: FCP_USER_EMAIL (the per-developer identity) -> the API key owner's
249
+ // email from /api/mcp/me (the shared key's creator, honoring the "based on API
250
+ // key" contract). Returns '' if neither resolves, so callers can surface a clear
251
+ // "set FCP_USER_EMAIL" message rather than silently returning the org-wide list.
252
+ async function resolveActingEmail() {
253
+ if (FCP_USER_EMAIL)
254
+ return FCP_USER_EMAIL;
255
+ await fetchUserRole(); // populates cachedUserEmail as a side effect
256
+ return cachedUserEmail ?? '';
257
+ }
238
258
  function isRoleAtLeast(actual, required) {
239
259
  if (actual === 'none')
240
260
  return required === 'none';
@@ -330,7 +350,7 @@ async function initializeProjectDetection() {
330
350
  }
331
351
  }
332
352
  // API Client
333
- class FCPClient {
353
+ export class FCPClient {
334
354
  baseUrl;
335
355
  token;
336
356
  defaultTimeout = 15000; // 15 seconds
@@ -779,6 +799,36 @@ class FCPClient {
779
799
  return this.fetchText(`/api/infrastructure/trusted-ips/export${qs ? `?${qs}` : ''}`);
780
800
  }
781
801
  // ============================================================================
802
+ // Tasker Freeze Management Methods
803
+ // ============================================================================
804
+ async listFreezes() {
805
+ return this.fetch('/api/ticket-workflow/freeze');
806
+ }
807
+ async freezeRepo(input) {
808
+ return this.fetch('/api/ticket-workflow/freeze', {
809
+ method: 'POST',
810
+ body: JSON.stringify(input),
811
+ });
812
+ }
813
+ async updateFreeze(id, updates) {
814
+ return this.fetch('/api/ticket-workflow/freeze', {
815
+ method: 'PATCH',
816
+ body: JSON.stringify({ id, ...updates }),
817
+ });
818
+ }
819
+ async unfreeze(input) {
820
+ const hasId = input.id !== undefined && input.id !== null;
821
+ const hasRepo = typeof input.repo === 'string' && input.repo.length > 0;
822
+ if (hasId === hasRepo) {
823
+ throw new Error('unfreeze requires exactly one of "id" or "repo"');
824
+ }
825
+ const body = hasId ? { id: input.id } : { repo: input.repo };
826
+ return this.fetch('/api/ticket-workflow/freeze', {
827
+ method: 'DELETE',
828
+ body: JSON.stringify(body),
829
+ });
830
+ }
831
+ // ============================================================================
782
832
  // Backup Management Methods
783
833
  // ============================================================================
784
834
  async backupListSites() {
@@ -2051,17 +2101,17 @@ const TOOLS = [
2051
2101
  },
2052
2102
  {
2053
2103
  name: 'unroo_get_my_tasks',
2054
- description: 'Get tasks assigned to the current user (based on API key). Useful for finding your work items.',
2104
+ 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.',
2055
2105
  inputSchema: {
2056
2106
  type: 'object',
2057
2107
  properties: {
2058
2108
  status: {
2059
2109
  type: 'string',
2060
- description: 'Filter by status (comma-separated for multiple)',
2110
+ description: 'Filter by status: To Do, In Progress, Done, Blocked (case-sensitive, comma-separated for multiple)',
2061
2111
  },
2062
2112
  limit: {
2063
2113
  type: 'number',
2064
- description: 'Maximum number of tasks to return',
2114
+ description: 'Maximum number of tasks to return (default 50)',
2065
2115
  },
2066
2116
  },
2067
2117
  },
@@ -3059,6 +3109,55 @@ const TOOLS = [
3059
3109
  },
3060
3110
  },
3061
3111
  },
3112
+ {
3113
+ name: 'fcp_list_freezes',
3114
+ description: 'List Tasker auto-merge freezes. Returns frozen_repos (active now), scheduled_repos (future windows), and available_repos (every repo you may freeze, with project keys + domains). A frozen repo has its release/security PRs held from the 6-7 AM MT deployment window. Call this before fcp_freeze_repo to pick a valid repo and avoid duplicate freezes.',
3115
+ inputSchema: {
3116
+ type: 'object',
3117
+ properties: {},
3118
+ },
3119
+ },
3120
+ {
3121
+ name: 'fcp_freeze_repo',
3122
+ description: 'Schedule a Tasker auto-merge freeze for a repo (blocks release-PR auto-promotion + security auto-merge while active). Provide either days (1-90) or an explicit ends_at; starts_at defaults to now and cannot be backdated >5 min. The repo must exist in github_repo_mappings (else 404 \u2014 use fcp_list_freezes available_repos). Appends a new window each call.',
3123
+ inputSchema: {
3124
+ type: 'object',
3125
+ properties: {
3126
+ repo: { type: 'string', description: 'owner/repo, e.g. "fruition/metroairport"' },
3127
+ reason: { type: 'string', description: 'Why the repo is being frozen (required)' },
3128
+ days: { type: 'number', description: 'Freeze length in days (1-90); alternative to ends_at' },
3129
+ starts_at: { type: 'string', description: 'ISO-8601 start (optional; defaults to now, no backdating >5 min)' },
3130
+ ends_at: { type: 'string', description: 'ISO-8601 end (alternative to days)' },
3131
+ },
3132
+ required: ['repo', 'reason'],
3133
+ },
3134
+ },
3135
+ {
3136
+ name: 'fcp_update_freeze',
3137
+ description: 'Edit an existing freeze by id (from fcp_list_freezes). Change the window (starts_at / ends_at, or days as a shorthand for ends_at) and/or the reason. Window rules match fcp_freeze_repo (<=90 days, no backdating >5 min).',
3138
+ inputSchema: {
3139
+ type: 'object',
3140
+ properties: {
3141
+ id: { type: 'number', description: 'Freeze id to edit' },
3142
+ starts_at: { type: 'string', description: 'New ISO-8601 start' },
3143
+ ends_at: { type: 'string', description: 'New ISO-8601 end' },
3144
+ days: { type: 'number', description: 'New length in days (recomputes ends_at from starts_at)' },
3145
+ reason: { type: 'string', description: 'New reason' },
3146
+ },
3147
+ required: ['id'],
3148
+ },
3149
+ },
3150
+ {
3151
+ name: 'fcp_unfreeze',
3152
+ description: 'Remove a Tasker freeze. Pass exactly one of: id (remove that single freeze) or repo (remove ALL active + scheduled freezes for that owner/repo). Lifting a freeze lets a held deploy through, so this is a super_admin action.',
3153
+ inputSchema: {
3154
+ type: 'object',
3155
+ properties: {
3156
+ id: { type: 'number', description: 'Freeze id to remove (single)' },
3157
+ repo: { type: 'string', description: 'owner/repo to clear all active+scheduled freezes for' },
3158
+ },
3159
+ },
3160
+ },
3062
3161
  // ============================================================================
3063
3162
  // Backup Management Tools
3064
3163
  // ============================================================================
@@ -4425,9 +4524,30 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
4425
4524
  }
4426
4525
  case 'unroo_get_my_tasks': {
4427
4526
  const { status, limit } = args;
4428
- // Note: The API key determines the user, tasks are filtered server-side
4527
+ // "My tasks" must be scoped by an explicit assignee_email filter. There is
4528
+ // NO server-side auto-scoping: GET /api/external/fcp/tasks returns every
4529
+ // task unless assignee_email is supplied, and the shared FCP_API_TOKEN
4530
+ // resolves to the key creator (not the developer running Claude Code), so
4531
+ // the key alone cannot identify "me". Resolve the acting user's email and
4532
+ // pass it as the filter.
4533
+ const assignee_email = await resolveActingEmail();
4534
+ if (!assignee_email) {
4535
+ return {
4536
+ content: [
4537
+ {
4538
+ type: 'text',
4539
+ text: JSON.stringify({
4540
+ total: 0,
4541
+ tasks: [],
4542
+ 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.',
4543
+ }, null, 2),
4544
+ },
4545
+ ],
4546
+ };
4547
+ }
4429
4548
  const result = await unrooClient.listTasks({
4430
4549
  status,
4550
+ assignee_email,
4431
4551
  limit: limit || 50,
4432
4552
  });
4433
4553
  return {
@@ -4436,6 +4556,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
4436
4556
  type: 'text',
4437
4557
  text: JSON.stringify({
4438
4558
  total: result.tasks.length,
4559
+ assignee_email,
4439
4560
  tasks: result.tasks,
4440
4561
  }, null, 2),
4441
4562
  },
@@ -4886,6 +5007,45 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
4886
5007
  };
4887
5008
  }
4888
5009
  // ============================================================================
5010
+ // Tasker Freeze Management Handlers
5011
+ // ============================================================================
5012
+ case 'fcp_list_freezes': {
5013
+ const result = await client.listFreezes();
5014
+ return {
5015
+ content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
5016
+ };
5017
+ }
5018
+ case 'fcp_freeze_repo': {
5019
+ const { repo, reason, days, starts_at, ends_at } = args;
5020
+ const result = await client.freezeRepo({ repo, reason, days, starts_at, ends_at });
5021
+ return {
5022
+ content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
5023
+ };
5024
+ }
5025
+ case 'fcp_update_freeze': {
5026
+ const { id, starts_at, ends_at, days, reason } = args;
5027
+ const updates = {};
5028
+ if (starts_at !== undefined)
5029
+ updates.starts_at = starts_at;
5030
+ if (ends_at !== undefined)
5031
+ updates.ends_at = ends_at;
5032
+ if (days !== undefined)
5033
+ updates.days = days;
5034
+ if (reason !== undefined)
5035
+ updates.reason = reason;
5036
+ const result = await client.updateFreeze(id, updates);
5037
+ return {
5038
+ content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
5039
+ };
5040
+ }
5041
+ case 'fcp_unfreeze': {
5042
+ const { id, repo } = args;
5043
+ const result = await client.unfreeze({ id, repo });
5044
+ return {
5045
+ content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
5046
+ };
5047
+ }
5048
+ // ============================================================================
4889
5049
  // Backup Management Handlers
4890
5050
  // ============================================================================
4891
5051
  case 'fcp_backup_list_sites': {
@@ -5543,7 +5703,7 @@ if (process.argv[2] === 'sync-skills') {
5543
5703
  process.exit(1);
5544
5704
  });
5545
5705
  }
5546
- else {
5706
+ else if (!process.env.VITEST) {
5547
5707
  main().catch((error) => {
5548
5708
  console.error('Fatal error:', error);
5549
5709
  process.exit(1);
@@ -22,6 +22,7 @@
22
22
  * - `private: true` in frontmatter also opts out.
23
23
  * - Network failures and missing auth never block startup — we log + continue.
24
24
  */
25
+ import { basename } from 'path';
25
26
  export interface SkillFrontmatter {
26
27
  name?: string;
27
28
  description?: string;
@@ -151,5 +152,5 @@ export declare const __test__: {
151
152
  detectSecret: typeof detectSecret;
152
153
  pushSkipReason: typeof pushSkipReason;
153
154
  hashBody: typeof hashBody;
154
- basename: (path: string, suffix?: string) => string;
155
+ basename: typeof basename;
155
156
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fruition/fcp-mcp-server",
3
- "version": "1.27.0",
3
+ "version": "1.28.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",