@featureflip/mcp 0.1.8 → 0.1.10

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.mjs CHANGED
@@ -1,2 +1,2 @@
1
- import { a as e, c as t, o as n, r, s as i, t as a } from "./org-DZ1ge1Mx.js";
1
+ import { a as e, c as t, o as n, r, s as i, t as a } from "./org-C5dAa5In.js";
2
2
  export { n as ApiError, i as FeatureflipApi, t as PKG_VERSION, e as createServer, r as loadConfig, a as resolveOrg };
@@ -4,7 +4,7 @@ import * as t from "zod/v4";
4
4
  //#region src/version.ts
5
5
  var n = {
6
6
  name: "@featureflip/mcp",
7
- version: "0.1.8",
7
+ version: "0.1.10",
8
8
  mcpName: "io.github.canopy-labs/featureflip",
9
9
  description: "MCP server for managing Featureflip feature flags from AI agents and editors",
10
10
  keywords: [
@@ -43,15 +43,15 @@ var n = {
43
43
  "smoke:docker": "node scripts/docker-smoke.mjs"
44
44
  },
45
45
  dependencies: {
46
- "@modelcontextprotocol/server": "2.0.0",
46
+ "@modelcontextprotocol/server": "2.1.0",
47
47
  zod: "^4.6.5"
48
48
  },
49
49
  devDependencies: {
50
- "@modelcontextprotocol/client": "2.0.0",
50
+ "@modelcontextprotocol/client": "2.1.0",
51
51
  "@typescript/typescript6": "^6.0.2",
52
52
  "@types/node": "^26.6.2",
53
53
  typescript: "^7.0.2",
54
- vite: "^8.3.0",
54
+ vite: "^8.3.1",
55
55
  "vite-plugin-dts": "^5.1.1",
56
56
  vitest: "^5.0.1"
57
57
  },
@@ -218,31 +218,46 @@ function h(e, n) {
218
218
  }, async ({ project: e, segment: t }) => d(async () => l(await n.api.request("GET", `/api/v1/orgs/${i(n.org)}/projects/${i(e)}/segments/${i(t)}`))));
219
219
  }
220
220
  //#endregion
221
+ //#region src/tools/owner.ts
222
+ var g = t.string().optional().describe("Only flags with this owner: an email, \"me\" for the token's own user, or \"none\" for unowned flags");
223
+ async function _(e, t) {
224
+ let n = t?.trim();
225
+ if (!n) return;
226
+ if (n.toLowerCase() !== "me") return n;
227
+ let r = await e.api.request("GET", "/api/v1/me");
228
+ if (r.type !== "user" || !r.email) throw Error("owner \"me\" needs a personal access token (ffp_...). A service token has no user, so it owns no flags; pass an owner email instead.");
229
+ return r.email;
230
+ }
231
+ //#endregion
221
232
  //#region src/tools/flags.ts
222
- var g = t.enum([
233
+ var v = t.enum([
223
234
  "Boolean",
224
235
  "String",
225
236
  "Number",
226
237
  "Json"
227
238
  ]);
228
- function _(e, n) {
239
+ function y(e, n) {
229
240
  let r = (e) => `/api/v1/orgs/${i(n.org)}/projects/${i(e)}/flags`;
230
241
  e.registerTool("list_flags", {
231
242
  title: "List feature flags",
232
- description: "List feature flags in a project. Filter with search (key/name substring), tag, type, archived. Paginated via cursor.",
243
+ description: "List feature flags in a project. Filter with search (key/name substring), tag, type, archived, owner. Each flag carries its owner ({id, email, name}, or null when unowned). Paginated via cursor.",
233
244
  inputSchema: t.object({
234
245
  project: t.string().describe("Project key"),
235
246
  search: t.string().optional(),
236
247
  tag: t.string().optional(),
237
- type: g.optional(),
248
+ type: v.optional(),
238
249
  archived: t.boolean().optional().describe("true = only archived, false = only active"),
250
+ owner: g,
239
251
  limit: t.number().int().min(1).max(100).optional(),
240
252
  cursor: t.string().optional()
241
253
  }),
242
254
  annotations: { readOnlyHint: !0 }
243
- }, async ({ project: e, ...t }) => d(async () => l(await n.api.request("GET", r(e), { query: t })))), e.registerTool("get_flag", {
255
+ }, async ({ project: e, owner: t, ...i }) => d(async () => l(await n.api.request("GET", r(e), { query: {
256
+ ...i,
257
+ owner: await _(n, t)
258
+ } })))), e.registerTool("get_flag", {
244
259
  title: "Get feature flag",
245
- description: "Get one feature flag with its variations and metadata. Address by flag key or id.",
260
+ description: "Get one feature flag with its variations and metadata, including its owner (null when unowned). Address by flag key or id.",
246
261
  inputSchema: t.object({
247
262
  project: t.string().describe("Project key"),
248
263
  flag: t.string().describe("Flag key or id")
@@ -255,7 +270,7 @@ function _(e, n) {
255
270
  project: t.string().describe("Project key"),
256
271
  key: t.string().describe("Unique flag key, e.g. checkout-v2"),
257
272
  name: t.string(),
258
- type: g,
273
+ type: v,
259
274
  description: t.string().optional(),
260
275
  tags: t.array(t.string()).optional(),
261
276
  clientSideVisible: t.boolean().optional().describe("Expose to client-side SDKs (browser/mobile)"),
@@ -265,7 +280,8 @@ function _(e, n) {
265
280
  value: t.string().describe("Variation value serialized as a string"),
266
281
  description: t.string().optional()
267
282
  })).optional(),
268
- expiresAtUtc: t.string().optional().describe("Optional ISO-8601 UTC instant the flag is expected to be removed by (see set_flag_expiry)"),
283
+ expiresAtUtc: t.string().optional().describe("Optional date (YYYY-MM-DD, end of that UTC day) or ISO-8601 timestamp the flag is expected to be removed by (see set_flag_expiry)"),
284
+ ownerEmail: t.string().optional().describe("Optional owner: the email of an active member (Pro plan and above, see set_flag_owner). Omitted, a personal access token makes its own user the owner and a service token leaves the flag unowned"),
269
285
  idempotency_key: t.string().optional().describe("Idempotency-Key header for safe retries")
270
286
  }),
271
287
  annotations: { destructiveHint: !1 }
@@ -274,7 +290,7 @@ function _(e, n) {
274
290
  idempotencyKey: t
275
291
  })))), e.registerTool("update_flag", {
276
292
  title: "Update feature flag metadata",
277
- description: "Update flag name/description/tags/clientSideVisible. Key and type are immutable. Use toggle_flag / update_targeting for behavior changes.",
293
+ description: "Update flag name/description/tags/clientSideVisible. Changes only the fields you pass and keeps the rest; pass description: \"\" to clear it. Key and type are immutable. Use toggle_flag / update_targeting for behavior changes.",
278
294
  inputSchema: t.object({
279
295
  project: t.string(),
280
296
  flag: t.string().describe("Flag key or id"),
@@ -284,17 +300,25 @@ function _(e, n) {
284
300
  clientSideVisible: t.boolean().optional()
285
301
  }),
286
302
  annotations: { destructiveHint: !0 }
287
- }, async ({ project: e, flag: t, ...a }) => d(async () => (await n.api.request("PUT", `${r(e)}/${i(t)}`, { body: a }), l({
288
- project: e,
289
- flag: t,
290
- updated: !0
291
- })))), e.registerTool("set_flag_expiry", {
303
+ }, async ({ project: e, flag: t, name: a, description: o, tags: s, clientSideVisible: c }) => d(async () => {
304
+ let u = `${r(e)}/${i(t)}`, d = a === void 0 || o === void 0 ? await n.api.request("GET", u) : void 0, f = {
305
+ name: a ?? d?.name,
306
+ description: o ?? d?.description,
307
+ tags: s,
308
+ clientSideVisible: c
309
+ };
310
+ return await n.api.request("PUT", u, { body: f }), l({
311
+ project: e,
312
+ flag: t,
313
+ updated: !0
314
+ });
315
+ })), e.registerTool("set_flag_expiry", {
292
316
  title: "Set or clear flag expiry",
293
- description: "Set the date a flag is expected to be removed by, or pass expiresAtUtc: null to clear it. Expiry is advisory: evaluation never changes. Once the date passes, find_stale_flags reports the flag as expired. expiresAtUtc is an exact ISO-8601 UTC instant; a bare date means 00:00 UTC that day, so pass e.g. 2026-12-31T23:59:59Z for the end of a day. Refused with EXPIRY_IN_PAST for a time that has already passed, and with FEATURE_NOT_ENABLED while flag expiration is not enabled for the organization. Clearing is always allowed.",
317
+ description: "Set the date a flag is expected to be removed by, or pass expiresAtUtc: null to clear it. Expiry is advisory: evaluation never changes. Once the date passes, find_stale_flags reports the flag as expired. expiresAtUtc takes a date (2026-12-31), which means the end of that day in UTC, the same as picking it in the dashboard, or a full ISO-8601 timestamp, which is stored exactly. Refused with EXPIRY_IN_PAST for a time that has already passed (today's date is allowed), and with FEATURE_NOT_ENABLED while flag expiration is not enabled for the organization. Clearing is always allowed.",
294
318
  inputSchema: t.object({
295
319
  project: t.string(),
296
320
  flag: t.string().describe("Flag key or id"),
297
- expiresAtUtc: t.string().nullable().describe("ISO-8601 UTC instant, or null to clear the expiry")
321
+ expiresAtUtc: t.string().nullable().describe("Date (YYYY-MM-DD, end of that UTC day) or ISO-8601 timestamp, or null to clear the expiry")
298
322
  }),
299
323
  annotations: {
300
324
  destructiveHint: !1,
@@ -307,6 +331,25 @@ function _(e, n) {
307
331
  flag: t,
308
332
  expiresAtUtc: a
309
333
  });
334
+ })), e.registerTool("set_flag_owner", {
335
+ title: "Set or clear flag owner",
336
+ description: "Name the person responsible for a flag, or pass owner: null to leave it unowned. owner is the email of an active member of the organization, or \"me\" for the token's own user (needs a personal access token). The owner gets the flag's cleanup notices; evaluation never changes. Refused with OWNER_NOT_MEMBER for an email that is not an active member, and with PLAN_FEATURE_UNAVAILABLE below the Pro plan. Clearing is always allowed.",
337
+ inputSchema: t.object({
338
+ project: t.string(),
339
+ flag: t.string().describe("Flag key or id"),
340
+ owner: t.string().nullable().describe("Owner email, \"me\" for the token's own user, or null (or \"none\") to clear the owner")
341
+ }),
342
+ annotations: {
343
+ destructiveHint: !1,
344
+ idempotentHint: !0
345
+ }
346
+ }, async ({ project: e, flag: t, owner: a }) => d(async () => {
347
+ let o = `${r(e)}/${i(t)}/owner`, s = a?.trim().toLowerCase() === "none" ? void 0 : await _(n, a ?? void 0);
348
+ return s === void 0 ? await n.api.request("DELETE", o) : await n.api.request("PUT", o, { body: { email: s } }), l({
349
+ project: e,
350
+ flag: t,
351
+ owner: s ?? null
352
+ });
310
353
  })), e.registerTool("delete_flag", {
311
354
  title: "Delete feature flag",
312
355
  description: "PERMANENTLY delete a flag across all environments. Fails with FLAG_HAS_DEPENDENTS if other flags use it as a prerequisite. Prefer archive_flag unless the flag must be fully removed.",
@@ -317,7 +360,7 @@ function _(e, n) {
317
360
  annotations: { destructiveHint: !0 }
318
361
  }, async ({ project: e, flag: t }) => d(async () => (await n.api.request("DELETE", `${r(e)}/${i(t)}`), l({ deleted: t })))), e.registerTool("archive_flag", {
319
362
  title: "Archive feature flag",
320
- description: "Archive a flag (soft-hide, evaluation stops serving it). Reversible with restore_flag. Refused with FLAG_RECENTLY_EVALUATED while live traffic is still evaluating the flag, because archiving makes every caller fall back to its own hardcoded default — normally that means the code removal has merged but not deployed yet, and the refusal clears itself once it has.",
363
+ description: "Archive a flag (soft-hide, evaluation stops serving it). Reversible with restore_flag. Archive dependents first: refused with FLAG_HAS_DEPENDENTS while another live flag lists this one as a prerequisite, so archive those flags (blockedBy in find_stale_flags / list_removal_candidates) first. Refused with FLAG_RECENTLY_EVALUATED while live traffic is still evaluating the flag, because archiving makes every caller fall back to its own hardcoded default — normally that means the code removal has merged but not deployed yet, and the refusal clears itself once it has.",
321
364
  inputSchema: t.object({
322
365
  project: t.string(),
323
366
  flag: t.string(),
@@ -342,12 +385,12 @@ function _(e, n) {
342
385
  }
343
386
  //#endregion
344
387
  //#region src/tools/flag-environments.ts
345
- var v = t.enum([
388
+ var b = t.enum([
346
389
  "SingleVariation",
347
390
  "PercentageRollout",
348
391
  "TargetedRollout"
349
392
  ]);
350
- function y(e, n) {
393
+ function x(e, n) {
351
394
  let r = (e, t) => `/api/v1/orgs/${i(n.org)}/projects/${i(e)}/flags/${i(t)}`;
352
395
  e.registerTool("toggle_flag", {
353
396
  title: "Toggle flag in environment",
@@ -372,7 +415,7 @@ function y(e, n) {
372
415
  flag: t.string(),
373
416
  environment: t.string(),
374
417
  defaultVariationId: t.string().describe("Variation id to serve on fallthrough — required by the API"),
375
- strategy: v.optional(),
418
+ strategy: b.optional(),
376
419
  prerequisites: t.array(t.object({
377
420
  prerequisiteFlagKey: t.string(),
378
421
  expectedVariationKey: t.string()
@@ -412,22 +455,22 @@ function y(e, n) {
412
455
  }
413
456
  //#endregion
414
457
  //#region src/tools/targeting.ts
415
- var b = t.object({
458
+ var S = t.object({
416
459
  attribute: t.string().describe("Context attribute name, e.g. plan, country, user_id"),
417
460
  operator: t.string().describe("OperatorType member name, case-sensitive — e.g. Equals, NotEquals, Contains, In, GreaterThan"),
418
461
  values: t.array(t.string()),
419
462
  negate: t.boolean().optional().default(!1)
420
- }), x = t.object({
463
+ }), C = t.object({
421
464
  description: t.string().optional(),
422
465
  variationId: t.string().optional().describe("Variation served when the rule matches"),
423
466
  rolloutPercentage: t.number().int().min(0).max(100).optional().describe("Percentage rollout for this rule"),
424
467
  userSegmentId: t.string().optional().describe("Match users in this segment (see list_segments)"),
425
468
  conditionGroups: t.array(t.object({
426
469
  operator: t.enum(["And", "Or"]).describe("How conditions inside this group combine"),
427
- conditions: t.array(b)
470
+ conditions: t.array(S)
428
471
  })).optional().describe("Groups are ANDed together; conditions inside a group use the group operator")
429
472
  });
430
- function S(e, n) {
473
+ function w(e, n) {
431
474
  let r = (e, t, r) => `/api/v1/orgs/${i(n.org)}/projects/${i(e)}/flags/${i(t)}/environments/${i(r)}/targeting`;
432
475
  e.registerTool("get_targeting", {
433
476
  title: "Get targeting rules",
@@ -445,7 +488,7 @@ function S(e, n) {
445
488
  project: t.string(),
446
489
  flag: t.string(),
447
490
  environment: t.string(),
448
- rules: t.array(x)
491
+ rules: t.array(C)
449
492
  }),
450
493
  annotations: { destructiveHint: !0 }
451
494
  }, async ({ project: e, flag: t, environment: i, rules: a }) => d(async () => (await n.api.request("PUT", r(e, t, i), { body: { rules: a } }), l({
@@ -457,7 +500,7 @@ function S(e, n) {
457
500
  }
458
501
  //#endregion
459
502
  //#region src/tools/variations.ts
460
- function C(e, n) {
503
+ function T(e, n) {
461
504
  e.registerTool("manage_variation", {
462
505
  title: "Add, update, or remove a variation",
463
506
  description: "Manage a flag's variations. action=add requires key + value; action=update/remove require variationId (get ids via get_flag). Removing a variation fails with VARIATION_HAS_DEPENDENTS if other flags depend on it.",
@@ -492,16 +535,16 @@ function C(e, n) {
492
535
  }
493
536
  //#endregion
494
537
  //#region src/tools/webhooks.ts
495
- var w = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i, T = "Requires an Admin token. A 404 on every webhook call means webhooks are not enabled for the organization.", E = {
538
+ var E = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i, D = "Requires an Admin token. A 404 on every webhook call means webhooks are not enabled for the organization.", O = {
496
539
  limit: t.number().int().min(1).max(100).optional().describe("Page size (default 20)"),
497
540
  cursor: t.string().optional().describe("next_cursor from a previous response")
498
541
  };
499
- function D(e, n) {
542
+ function k(e, n) {
500
543
  let r = `/api/v1/orgs/${i(n.org)}/webhooks`, a = (e) => `${r}/${i(e)}`;
501
544
  async function o(e) {
502
545
  let t = [];
503
546
  for (let r of e) {
504
- if (w.test(r)) {
547
+ if (E.test(r)) {
505
548
  t.push(r);
506
549
  continue;
507
550
  }
@@ -513,7 +556,7 @@ function D(e, n) {
513
556
  }
514
557
  async function s(e) {
515
558
  let t = e.map((e) => {
516
- if (w.test(e)) return { id: e };
559
+ if (E.test(e)) return { id: e };
517
560
  let t = e.indexOf("/"), n = e.slice(0, t), r = e.slice(t + 1);
518
561
  if (t < 0 || !n || !r) throw Error(`Environment "${e}" needs its project: pass "<project>/<environment>", e.g. "web/production", or the environment's id.`);
519
562
  return {
@@ -534,8 +577,8 @@ function D(e, n) {
534
577
  }
535
578
  e.registerTool("list_webhooks", {
536
579
  title: "List webhook subscriptions",
537
- description: "List the organization's outbound webhook subscriptions (url, event/project/environment filters, enabled state, failure count, signing secret ids — never secret values), plus availableEventTypes: the event types a subscription can filter on. Empty filter lists mean \"all\". Paginated via cursor. " + T,
538
- inputSchema: t.object({ ...E }),
580
+ description: "List the organization's outbound webhook subscriptions (url, event/project/environment filters, enabled state, failure count, signing secret ids — never secret values), plus availableEventTypes: the event types a subscription can filter on. Empty filter lists mean \"all\". Paginated via cursor. " + D,
581
+ inputSchema: t.object({ ...O }),
539
582
  annotations: { readOnlyHint: !0 }
540
583
  }, async ({ limit: e, cursor: t }) => d(async () => {
541
584
  let [i, a] = await Promise.all([n.api.request("GET", r, { query: {
@@ -548,10 +591,10 @@ function D(e, n) {
548
591
  });
549
592
  })), e.registerTool("list_webhook_deliveries", {
550
593
  title: "List webhook deliveries",
551
- description: "List one subscription's delivery attempts, most recent first. status is Pending (awaiting an attempt, backing off, or in flight), Succeeded or DeadLettered; lastResponseStatusCode and lastError explain a failure. Paginated via cursor. " + T,
594
+ description: "List one subscription's delivery attempts, most recent first. status is Pending (awaiting an attempt, backing off, or in flight), Succeeded or DeadLettered; lastResponseStatusCode and lastError explain a failure. Paginated via cursor. " + D,
552
595
  inputSchema: t.object({
553
596
  id: t.string().describe("Subscription id (from list_webhooks)"),
554
- ...E
597
+ ...O
555
598
  }),
556
599
  annotations: { readOnlyHint: !0 }
557
600
  }, async ({ id: e, limit: t, cursor: r }) => d(async () => l(await n.api.request("GET", `${a(e)}/deliveries`, { query: {
@@ -559,7 +602,7 @@ function D(e, n) {
559
602
  cursor: r
560
603
  } })))), e.registerTool("manage_webhook", {
561
604
  title: "Create, update, or delete a webhook subscription, or rotate its secret",
562
- description: "action=create requires name + url and returns the signing secret. action=update/delete/rotate_secret/retire_secret require id. update changes only the fields you pass and keeps the rest; pass an empty list to widen that filter back to \"all\". projects take project keys or ids; environments take \"<project>/<environment>\" keys (e.g. \"web/production\") or ids. eventTypes come from list_webhooks → availableEventTypes. create and rotate_secret return a secret that is NEVER shown again: relay it to the user verbatim so they can store it. rotate_secret adds a new active secret while the old one keeps signing; retire_secret (secretId from list_webhooks) removes one and is refused for the last active secret. A project-restricted service token must list at least one project, all in its scope. " + T,
605
+ description: "action=create requires name + url and returns the signing secret. action=update/delete/rotate_secret/retire_secret require id. update changes only the fields you pass and keeps the rest; pass an empty list to widen that filter back to \"all\". projects take project keys or ids; environments take \"<project>/<environment>\" keys (e.g. \"web/production\") or ids. eventTypes come from list_webhooks → availableEventTypes. create and rotate_secret return a secret that is NEVER shown again: relay it to the user verbatim so they can store it. rotate_secret adds a new active secret while the old one keeps signing; retire_secret (secretId from list_webhooks) removes one and is refused for the last active secret. A project-restricted service token must list at least one project, all in its scope. " + D,
563
606
  inputSchema: t.object({
564
607
  action: t.enum([
565
608
  "create",
@@ -617,7 +660,7 @@ function D(e, n) {
617
660
  })) : u(/* @__PURE__ */ Error("action=retire_secret requires secretId (see list_webhooks)"));
618
661
  })), e.registerTool("deliver_webhook", {
619
662
  title: "Send a test event or redeliver",
620
- description: "action=test queues one synthetic flag.toggled event to this subscription alone; action=redeliver (deliveryId from list_webhook_deliveries) re-sends a finished delivery, restarting its retry schedule. Delivery is asynchronous: read list_webhook_deliveries for the outcome. Both are refused for a disabled subscription, and redeliver for a delivery that is still Pending. " + T,
663
+ description: "action=test queues one synthetic flag.toggled event to this subscription alone; action=redeliver (deliveryId from list_webhook_deliveries) re-sends a finished delivery, restarting its retry schedule. Delivery is asynchronous: read list_webhook_deliveries for the outcome. Both are refused for a disabled subscription, and redeliver for a delivery that is still Pending. " + D,
621
664
  inputSchema: t.object({
622
665
  action: t.enum(["test", "redeliver"]),
623
666
  id: t.string().describe("Subscription id (from list_webhooks)"),
@@ -634,55 +677,88 @@ function D(e, n) {
634
677
  }
635
678
  //#endregion
636
679
  //#region src/tools/workflows/stale-flags.ts
637
- var O = 50;
638
- function k(e, n) {
680
+ var A = t.enum(["dead", "stale"]), j = 50;
681
+ function M(e, n) {
639
682
  e.registerTool("find_stale_flags", {
640
683
  title: "Find stale flags",
641
- description: `Find flags that look ready for code cleanup: not updated in N days AND either enabled in every environment (verify rollout is complete before removing — per-rule percentage ramps are not inspected) or disabled in every environment (dead — remove flag and code path). A flag whose expiry date (set_flag_expiry) has passed is always a candidate, however recently it was edited; if it is on in some environments and off in others its reason is past-expiry, meaning the owner has to decide which way to fold it. Every result carries expiresAtUtc and expired. Checks at most ${O} candidates per call, expired flags first.`,
684
+ description: `Find flags that look ready for code cleanup: not updated in N days AND either enabled in every environment (verify rollout is complete before removing — per-rule percentage ramps are not inspected) or disabled in every environment (dead — remove flag and code path). A flag whose expiry date (set_flag_expiry) has passed is always a candidate, however recently it was edited; if it is on in some environments and off in others its reason is past-expiry, meaning the owner has to decide which way to fold it. Every result carries expiresAtUtc, expired and owner (null when unowned). Pass owner to see one person's stale flags ("me" for your own) or "none" for the unowned ones. Every result also carries blockedBy: the live flags that still list it as a prerequisite, which have to be removed and archived before it ([] when none). blockedBy is null when the server did not classify the flag as a removal candidate, which means unknown, not unblocked. A flag that other live flags list as a prerequisite has to be removed LAST, dependents first: archiving it while a dependent still names it makes that dependent fail its prerequisite check and serve its off variation, so archive_flag refuses it with FLAG_HAS_DEPENDENTS. Checks at most ${j} candidates per call, expired flags first.`,
642
685
  inputSchema: t.object({
643
686
  project: t.string(),
644
- days: t.number().int().min(1).optional().default(30).describe("Minimum age in days since last update")
687
+ days: t.number().int().min(1).optional().default(30).describe("Minimum age in days since last update"),
688
+ owner: g
645
689
  }),
646
690
  annotations: { readOnlyHint: !0 }
647
- }, async ({ project: e, days: t }) => d(async () => {
648
- let r = `/api/v1/orgs/${i(n.org)}/projects/${i(e)}/flags`, a = Date.now(), o = a - t * 24 * 60 * 60 * 1e3, s = (e) => !!e.expiresAtUtc && new Date(e.expiresAtUtc).getTime() <= a, c = [], u;
691
+ }, async ({ project: e, days: t, owner: a }) => d(async () => {
692
+ let o = await _(n, a), s = `/api/v1/orgs/${i(n.org)}/projects/${i(e)}/flags`, c = Date.now(), u = c - t * 24 * 60 * 60 * 1e3, d = (e) => !!e.expiresAtUtc && new Date(e.expiresAtUtc).getTime() <= c, f = [], p;
649
693
  do {
650
- let e = await n.api.request("GET", r, { query: {
694
+ let e = await n.api.request("GET", s, { query: {
651
695
  archived: !1,
652
696
  limit: 100,
653
- cursor: u
697
+ cursor: p,
698
+ owner: o
654
699
  } });
655
- c.push(...(e.items ?? []).filter((e) => !!e.key && !!e.name && !!e.updatedAt && (new Date(e.updatedAt).getTime() < o || s(e)))), u = e.next_cursor ?? void 0;
656
- } while (u);
657
- c.sort((e, t) => Number(s(t)) - Number(s(e)));
658
- let d = c.length > O, f = c.slice(0, O), p = [];
659
- for (let e of f) {
660
- let t = await n.api.request("GET", `${r}/${i(e.key)}/environments`);
700
+ f.push(...(e.items ?? []).filter((e) => !!e.key && !!e.name && !!e.updatedAt && (new Date(e.updatedAt).getTime() < u || d(e)))), p = e.next_cursor ?? void 0;
701
+ } while (p);
702
+ f.sort((e, t) => Number(d(t)) - Number(d(e)));
703
+ let m = f.length > j, h = f.slice(0, j), g = [];
704
+ for (let e of h) {
705
+ let t = await n.api.request("GET", `${s}/${i(e.key)}/environments`);
661
706
  if (t.length === 0) continue;
662
- let a = s(e), o = t.every((e) => e.isEnabled), c = t.every((e) => !e.isEnabled);
663
- (o || c || a) && p.push({
707
+ let r = d(e), a = t.every((e) => e.isEnabled), o = t.every((e) => !e.isEnabled);
708
+ (a || o || r) && g.push({
664
709
  key: e.key,
665
710
  name: e.name,
666
711
  updatedAt: e.updatedAt,
667
712
  expiresAtUtc: e.expiresAtUtc ?? null,
668
- expired: a,
669
- reason: o ? "enabled-everywhere" : c ? "disabled-everywhere" : "past-expiry",
670
- environments: t.length
713
+ expired: r,
714
+ owner: e.owner ?? null,
715
+ reason: a ? "enabled-everywhere" : o ? "disabled-everywhere" : "past-expiry",
716
+ environments: t.length,
717
+ blockedBy: null
671
718
  });
672
719
  }
720
+ let v;
721
+ if (g.length > 0) try {
722
+ let e = /* @__PURE__ */ new Map(), t;
723
+ do {
724
+ let r = await n.api.request("GET", `${s}/removal-candidates`, { query: {
725
+ staleness: "stale",
726
+ limit: 100,
727
+ cursor: t
728
+ } });
729
+ for (let t of r.items ?? []) t.key && t.blockedBy && e.set(t.key, t.blockedBy);
730
+ t = r.next_cursor ?? void 0;
731
+ } while (t);
732
+ for (let t of g) t.blockedBy = e.get(t.key) ?? null;
733
+ } catch (e) {
734
+ if (!(e instanceof r)) throw e;
735
+ v = `Could not read removal-candidates (${e.status} ${e.envelope.error}), so blockedBy is unknown for every flag. archive_flag still refuses any flag that a live flag depends on (FLAG_HAS_DEPENDENTS).`;
736
+ }
673
737
  return l({
674
738
  project: e,
675
739
  olderThanDays: t,
676
- checked: f.length,
677
- truncated: d,
678
- ...d ? { note: `Only the first ${O} of ${c.length} candidates were checked.` } : {},
679
- stale: p
740
+ ...o ? { owner: o } : {},
741
+ checked: h.length,
742
+ truncated: m,
743
+ ...m ? { note: `Only the first ${j} of ${f.length} candidates were checked.` } : {},
744
+ ...v ? { blockedByNote: v } : {},
745
+ stale: g
680
746
  });
681
- }));
747
+ })), e.registerTool("list_removal_candidates", {
748
+ title: "List removal candidates",
749
+ description: "List the flags the server is confident can be removed from code, the same list the Featureflip flag cleanup GitHub Action works from. Each item has key, reason, status (Dead or Stale), treatment (true = keep the on-branch, false = keep the off-branch) and blockedBy: the live flags that still list it as a prerequisite. Only remove a flag whose blockedBy is empty. A flag that other live flags list as a prerequisite has to be removed LAST, dependents first: archiving it while a dependent still names it makes that dependent fail its prerequisite check and serve its off variation, so archive_flag refuses it with FLAG_HAS_DEPENDENTS. staleness \"dead\" (the default) returns only dead flags, \"stale\" adds stale ones. Paginated via cursor.",
750
+ inputSchema: t.object({
751
+ project: t.string().describe("Project key"),
752
+ staleness: A.optional().describe("Minimum staleness tier: dead (default) or stale"),
753
+ limit: t.number().int().min(1).max(100).optional(),
754
+ cursor: t.string().optional()
755
+ }),
756
+ annotations: { readOnlyHint: !0 }
757
+ }, async ({ project: e, ...t }) => d(async () => l(await n.api.request("GET", `/api/v1/orgs/${i(n.org)}/projects/${i(e)}/flags/removal-candidates`, { query: t }))));
682
758
  }
683
759
  //#endregion
684
760
  //#region src/tools/workflows/snippets.ts
685
- var A = [
761
+ var N = [
686
762
  "js",
687
763
  "node",
688
764
  "browser",
@@ -694,16 +770,16 @@ var A = [
694
770
  "php",
695
771
  "ruby",
696
772
  "swift"
697
- ], j = [
773
+ ], P = [
698
774
  "browser",
699
775
  "react",
700
776
  "swift"
701
777
  ];
702
- function M(e) {
703
- return j.includes(e);
778
+ function F(e) {
779
+ return P.includes(e);
704
780
  }
705
- var N = "Uses a SERVER-side SDK key (Featureflip dashboard → environment → SDK keys).", P = "Uses a CLIENT-side SDK key (safe to ship to browsers/apps).";
706
- function F(e, t) {
781
+ var I = "Uses a SERVER-side SDK key (Featureflip dashboard → environment → SDK keys).", L = "Uses a CLIENT-side SDK key (safe to ship to browsers/apps).";
782
+ function R(e, t) {
707
783
  switch (e) {
708
784
  case "js": return {
709
785
  install: "npm install @featureflip/js",
@@ -718,7 +794,7 @@ const enabled = client.boolVariation('${t}', { user_id: userId }, false);
718
794
  if (enabled) {
719
795
  // new code path
720
796
  }`,
721
- notes: N
797
+ notes: I
722
798
  };
723
799
  case "node": return {
724
800
  install: "npm install @featureflip/node",
@@ -729,7 +805,7 @@ const enabled = client.boolVariation('${t}', { user_id: userId }, false);
729
805
  if (enabled) {
730
806
  // new code path
731
807
  }`,
732
- notes: N
808
+ notes: I
733
809
  };
734
810
  case "browser": return {
735
811
  install: "npm install @featureflip/browser",
@@ -741,7 +817,7 @@ const enabled = client.boolVariation('${t}', false);
741
817
  if (enabled) {
742
818
  // new code path
743
819
  }`,
744
- notes: P
820
+ notes: L
745
821
  };
746
822
  case "react": return {
747
823
  install: "npm install @featureflip/react @featureflip/browser",
@@ -754,7 +830,7 @@ function MyComponent() {
754
830
  const enabled = useFeatureFlag('${t}', false);
755
831
  return enabled ? <NewExperience /> : <CurrentExperience />;
756
832
  }`,
757
- notes: P
833
+ notes: L
758
834
  };
759
835
  case "python": return {
760
836
  install: "pip install featureflip",
@@ -765,7 +841,7 @@ enabled = client.variation("${t}", {"user_id": user_id}, default=False)
765
841
  if enabled:
766
842
  # new code path
767
843
  ...`,
768
- notes: N
844
+ notes: I
769
845
  };
770
846
  case "csharp": return {
771
847
  install: "dotnet add package Featureflip.Client",
@@ -778,7 +854,7 @@ if (enabled)
778
854
  {
779
855
  // new code path
780
856
  }`,
781
- notes: N
857
+ notes: I
782
858
  };
783
859
  case "java": return {
784
860
  install: "implementation 'io.featureflip:featureflip-java:2.0.0'",
@@ -792,7 +868,7 @@ boolean enabled = client.boolVariation("${t}",
792
868
  if (enabled) {
793
869
  // new code path
794
870
  }`,
795
- notes: N
871
+ notes: I
796
872
  };
797
873
  case "go": return {
798
874
  install: "go get github.com/canopy-labs/featureflip-go/v2",
@@ -808,7 +884,7 @@ ctx := featureflip.EvaluationContext{UserID: userID}
808
884
  if client.BoolVariation("${t}", ctx, false) {
809
885
  // new code path
810
886
  }`,
811
- notes: N
887
+ notes: I
812
888
  };
813
889
  case "php": return {
814
890
  install: "composer require featureflip/featureflip-php",
@@ -823,7 +899,7 @@ $enabled = $client->boolVariation('${t}', ['user_id' => $userId], false);
823
899
  if ($enabled) {
824
900
  // new code path
825
901
  }`,
826
- notes: N + " Requires PSR-16 cache and PSR-18 HTTP implementations."
902
+ notes: I + " Requires PSR-16 cache and PSR-18 HTTP implementations."
827
903
  };
828
904
  case "ruby": return {
829
905
  install: "gem \"featureflip\"",
@@ -834,7 +910,7 @@ enabled = client.bool_variation("${t}", { "user_id" => user_id }, false)
834
910
  if enabled
835
911
  # new code path
836
912
  end`,
837
- notes: N
913
+ notes: I
838
914
  };
839
915
  case "swift": return {
840
916
  install: ".package(url: \"https://github.com/canopy-labs/featureflip-swift\", from: \"2.0.0\")",
@@ -847,13 +923,13 @@ let enabled = client.boolVariation("${t}", default: false)
847
923
  if enabled {
848
924
  // new code path
849
925
  }`,
850
- notes: P
926
+ notes: L
851
927
  };
852
928
  }
853
929
  }
854
930
  //#endregion
855
931
  //#region src/tools/workflows/wrap-feature.ts
856
- function I(e, n) {
932
+ function z(e, n) {
857
933
  e.registerTool("wrap_feature", {
858
934
  title: "Wrap a feature in a new flag",
859
935
  description: "Create a Boolean feature flag and get back the SDK code snippet to guard the new code path with it. Returns the snippet only — apply the edit yourself. The flag starts DISABLED in every environment; enable it with toggle_flag when ready.",
@@ -863,7 +939,7 @@ function I(e, n) {
863
939
  name: t.string().optional().describe("Display name; defaults to the key"),
864
940
  description: t.string().optional().describe("What this flag guards"),
865
941
  tags: t.array(t.string()).optional(),
866
- language: t.enum(A).describe("SDK language of the codebase being edited"),
942
+ language: t.enum(N).describe("SDK language of the codebase being edited"),
867
943
  idempotency_key: t.string().optional()
868
944
  }),
869
945
  annotations: { destructiveHint: !1 }
@@ -875,10 +951,10 @@ function I(e, n) {
875
951
  type: "Boolean",
876
952
  description: a,
877
953
  tags: o,
878
- ...M(s) ? { clientSideVisible: !0 } : {}
954
+ ...F(s) ? { clientSideVisible: !0 } : {}
879
955
  },
880
956
  idempotencyKey: c
881
- }), d = F(s, t);
957
+ }), d = R(s, t);
882
958
  return l({
883
959
  flag: u,
884
960
  integration: {
@@ -897,17 +973,17 @@ function I(e, n) {
897
973
  }
898
974
  //#endregion
899
975
  //#region src/server.ts
900
- function L(t) {
976
+ function B(t) {
901
977
  let r = new e({
902
978
  name: "featureflip",
903
979
  version: n
904
980
  });
905
- return p(r, t), m(r, t), h(r, t), _(r, t), y(r, t), S(r, t), C(r, t), D(r, t), k(r, t), I(r, t), r;
981
+ return p(r, t), m(r, t), h(r, t), y(r, t), x(r, t), w(r, t), T(r, t), k(r, t), M(r, t), z(r, t), r;
906
982
  }
907
983
  //#endregion
908
984
  //#region src/config.ts
909
- var R = "FEATUREFLIP_TOKEN is required. Create one in the Featureflip dashboard — Settings → API Tokens (personal token, ffp_...) or Organization Settings → Service Tokens (ffs_...) — and set it in your MCP client config.";
910
- function z(e = process.env) {
985
+ var V = "FEATUREFLIP_TOKEN is required. Create one in the Featureflip dashboard — Settings → API Tokens (personal token, ffp_...) or Organization Settings → Service Tokens (ffs_...) — and set it in your MCP client config.";
986
+ function H(e = process.env) {
911
987
  let t = e.FEATUREFLIP_TOKEN?.trim();
912
988
  return t ? {
913
989
  token: t,
@@ -915,14 +991,14 @@ function z(e = process.env) {
915
991
  org: e.FEATUREFLIP_ORG?.trim() || void 0
916
992
  } : null;
917
993
  }
918
- function B(e = process.env) {
919
- let t = z(e);
920
- if (!t) throw Error(R);
994
+ function U(e = process.env) {
995
+ let t = H(e);
996
+ if (!t) throw Error(V);
921
997
  return t;
922
998
  }
923
999
  //#endregion
924
1000
  //#region src/org.ts
925
- async function V(e, t) {
1001
+ async function W(e, t) {
926
1002
  if (t) return t;
927
1003
  let n = (await e.request("GET", "/api/v1/orgs", { query: { limit: 100 } })).items ?? [];
928
1004
  if (n.length === 1) {
@@ -933,4 +1009,4 @@ async function V(e, t) {
933
1009
  throw n.length === 0 ? Error("The token can see no organizations. Check the token in the Featureflip dashboard.") : Error(`The token can see ${n.length} organizations — set FEATUREFLIP_ORG to one of: ` + n.map((e) => e.slug ?? "?").join(", "));
934
1010
  }
935
1011
  //#endregion
936
- export { L as a, n as c, z as i, R as n, r as o, B as r, c as s, V as t };
1012
+ export { B as a, n as c, H as i, V as n, r as o, U as r, c as s, W as t };
@@ -1 +1 @@
1
- {"version":3,"file":"flags.d.ts","sourceRoot":"","sources":["../../src/tools/flags.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,8BAA8B,CAAC;AAI9D,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,cAAc,CAAC;AAIhD,wBAAgB,iBAAiB,CAAC,MAAM,EAAE,SAAS,EAAE,GAAG,EAAE,WAAW,GAAG,IAAI,CAmM3E"}
1
+ {"version":3,"file":"flags.d.ts","sourceRoot":"","sources":["../../src/tools/flags.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,8BAA8B,CAAC;AAI9D,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,cAAc,CAAC;AAKhD,wBAAgB,iBAAiB,CAAC,MAAM,EAAE,SAAS,EAAE,GAAG,EAAE,WAAW,GAAG,IAAI,CAkQ3E"}
@@ -0,0 +1,10 @@
1
+ import { ToolContext } from './context.js';
2
+ import * as z from 'zod/v4';
3
+ export declare const ownerFilter: z.ZodOptional<z.ZodString>;
4
+ /**
5
+ * Turns the tool's `owner` argument into the /api/v1 `owner=` value, which takes only an email or
6
+ * `none`. `me` is looked up through /api/v1/me. A service token has no user, so `me` is refused:
7
+ * dropping the filter would silently answer "my flags" with every flag.
8
+ */
9
+ export declare function resolveOwner(ctx: ToolContext, owner: string | undefined): Promise<string | undefined>;
10
+ //# sourceMappingURL=owner.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"owner.d.ts","sourceRoot":"","sources":["../../src/tools/owner.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,CAAC,MAAM,QAAQ,CAAC;AAE5B,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,cAAc,CAAC;AAIhD,eAAO,MAAM,WAAW,4BAGwF,CAAC;AAEjH;;;;GAIG;AACH,wBAAsB,YAAY,CAAC,GAAG,EAAE,WAAW,EAAE,KAAK,EAAE,MAAM,GAAG,SAAS,GAAG,OAAO,CAAC,MAAM,GAAG,SAAS,CAAC,CAY3G"}