@featureflip/mcp 0.1.9 → 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-8tn1Su68.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.9",
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: [
@@ -360,7 +360,7 @@ function y(e, n) {
360
360
  annotations: { destructiveHint: !0 }
361
361
  }, async ({ project: e, flag: t }) => d(async () => (await n.api.request("DELETE", `${r(e)}/${i(t)}`), l({ deleted: t })))), e.registerTool("archive_flag", {
362
362
  title: "Archive feature flag",
363
- 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.",
364
364
  inputSchema: t.object({
365
365
  project: t.string(),
366
366
  flag: t.string(),
@@ -677,59 +677,88 @@ function k(e, n) {
677
677
  }
678
678
  //#endregion
679
679
  //#region src/tools/workflows/stale-flags.ts
680
- var A = 50;
681
- function j(e, n) {
680
+ var A = t.enum(["dead", "stale"]), j = 50;
681
+ function M(e, n) {
682
682
  e.registerTool("find_stale_flags", {
683
683
  title: "Find stale flags",
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. Checks at most ${A} 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.`,
685
685
  inputSchema: t.object({
686
686
  project: t.string(),
687
687
  days: t.number().int().min(1).optional().default(30).describe("Minimum age in days since last update"),
688
688
  owner: g
689
689
  }),
690
690
  annotations: { readOnlyHint: !0 }
691
- }, async ({ project: e, days: t, owner: r }) => d(async () => {
692
- let a = await _(n, r), o = `/api/v1/orgs/${i(n.org)}/projects/${i(e)}/flags`, s = Date.now(), c = s - t * 24 * 60 * 60 * 1e3, u = (e) => !!e.expiresAtUtc && new Date(e.expiresAtUtc).getTime() <= s, d = [], f;
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;
693
693
  do {
694
- let e = await n.api.request("GET", o, { query: {
694
+ let e = await n.api.request("GET", s, { query: {
695
695
  archived: !1,
696
696
  limit: 100,
697
- cursor: f,
698
- owner: a
697
+ cursor: p,
698
+ owner: o
699
699
  } });
700
- d.push(...(e.items ?? []).filter((e) => !!e.key && !!e.name && !!e.updatedAt && (new Date(e.updatedAt).getTime() < c || u(e)))), f = e.next_cursor ?? void 0;
701
- } while (f);
702
- d.sort((e, t) => Number(u(t)) - Number(u(e)));
703
- let p = d.length > A, m = d.slice(0, A), h = [];
704
- for (let e of m) {
705
- let t = await n.api.request("GET", `${o}/${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`);
706
706
  if (t.length === 0) continue;
707
- let r = u(e), a = t.every((e) => e.isEnabled), s = t.every((e) => !e.isEnabled);
708
- (a || s || r) && h.push({
707
+ let r = d(e), a = t.every((e) => e.isEnabled), o = t.every((e) => !e.isEnabled);
708
+ (a || o || r) && g.push({
709
709
  key: e.key,
710
710
  name: e.name,
711
711
  updatedAt: e.updatedAt,
712
712
  expiresAtUtc: e.expiresAtUtc ?? null,
713
713
  expired: r,
714
714
  owner: e.owner ?? null,
715
- reason: a ? "enabled-everywhere" : s ? "disabled-everywhere" : "past-expiry",
716
- environments: t.length
715
+ reason: a ? "enabled-everywhere" : o ? "disabled-everywhere" : "past-expiry",
716
+ environments: t.length,
717
+ blockedBy: null
717
718
  });
718
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
+ }
719
737
  return l({
720
738
  project: e,
721
739
  olderThanDays: t,
722
- ...a ? { owner: a } : {},
723
- checked: m.length,
724
- truncated: p,
725
- ...p ? { note: `Only the first ${A} of ${d.length} candidates were checked.` } : {},
726
- stale: h
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
727
746
  });
728
- }));
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 }))));
729
758
  }
730
759
  //#endregion
731
760
  //#region src/tools/workflows/snippets.ts
732
- var M = [
761
+ var N = [
733
762
  "js",
734
763
  "node",
735
764
  "browser",
@@ -741,16 +770,16 @@ var M = [
741
770
  "php",
742
771
  "ruby",
743
772
  "swift"
744
- ], N = [
773
+ ], P = [
745
774
  "browser",
746
775
  "react",
747
776
  "swift"
748
777
  ];
749
- function P(e) {
750
- return N.includes(e);
778
+ function F(e) {
779
+ return P.includes(e);
751
780
  }
752
- var F = "Uses a SERVER-side SDK key (Featureflip dashboard → environment → SDK keys).", I = "Uses a CLIENT-side SDK key (safe to ship to browsers/apps).";
753
- function L(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) {
754
783
  switch (e) {
755
784
  case "js": return {
756
785
  install: "npm install @featureflip/js",
@@ -765,7 +794,7 @@ const enabled = client.boolVariation('${t}', { user_id: userId }, false);
765
794
  if (enabled) {
766
795
  // new code path
767
796
  }`,
768
- notes: F
797
+ notes: I
769
798
  };
770
799
  case "node": return {
771
800
  install: "npm install @featureflip/node",
@@ -776,7 +805,7 @@ const enabled = client.boolVariation('${t}', { user_id: userId }, false);
776
805
  if (enabled) {
777
806
  // new code path
778
807
  }`,
779
- notes: F
808
+ notes: I
780
809
  };
781
810
  case "browser": return {
782
811
  install: "npm install @featureflip/browser",
@@ -788,7 +817,7 @@ const enabled = client.boolVariation('${t}', false);
788
817
  if (enabled) {
789
818
  // new code path
790
819
  }`,
791
- notes: I
820
+ notes: L
792
821
  };
793
822
  case "react": return {
794
823
  install: "npm install @featureflip/react @featureflip/browser",
@@ -801,7 +830,7 @@ function MyComponent() {
801
830
  const enabled = useFeatureFlag('${t}', false);
802
831
  return enabled ? <NewExperience /> : <CurrentExperience />;
803
832
  }`,
804
- notes: I
833
+ notes: L
805
834
  };
806
835
  case "python": return {
807
836
  install: "pip install featureflip",
@@ -812,7 +841,7 @@ enabled = client.variation("${t}", {"user_id": user_id}, default=False)
812
841
  if enabled:
813
842
  # new code path
814
843
  ...`,
815
- notes: F
844
+ notes: I
816
845
  };
817
846
  case "csharp": return {
818
847
  install: "dotnet add package Featureflip.Client",
@@ -825,7 +854,7 @@ if (enabled)
825
854
  {
826
855
  // new code path
827
856
  }`,
828
- notes: F
857
+ notes: I
829
858
  };
830
859
  case "java": return {
831
860
  install: "implementation 'io.featureflip:featureflip-java:2.0.0'",
@@ -839,7 +868,7 @@ boolean enabled = client.boolVariation("${t}",
839
868
  if (enabled) {
840
869
  // new code path
841
870
  }`,
842
- notes: F
871
+ notes: I
843
872
  };
844
873
  case "go": return {
845
874
  install: "go get github.com/canopy-labs/featureflip-go/v2",
@@ -855,7 +884,7 @@ ctx := featureflip.EvaluationContext{UserID: userID}
855
884
  if client.BoolVariation("${t}", ctx, false) {
856
885
  // new code path
857
886
  }`,
858
- notes: F
887
+ notes: I
859
888
  };
860
889
  case "php": return {
861
890
  install: "composer require featureflip/featureflip-php",
@@ -870,7 +899,7 @@ $enabled = $client->boolVariation('${t}', ['user_id' => $userId], false);
870
899
  if ($enabled) {
871
900
  // new code path
872
901
  }`,
873
- notes: F + " Requires PSR-16 cache and PSR-18 HTTP implementations."
902
+ notes: I + " Requires PSR-16 cache and PSR-18 HTTP implementations."
874
903
  };
875
904
  case "ruby": return {
876
905
  install: "gem \"featureflip\"",
@@ -881,7 +910,7 @@ enabled = client.bool_variation("${t}", { "user_id" => user_id }, false)
881
910
  if enabled
882
911
  # new code path
883
912
  end`,
884
- notes: F
913
+ notes: I
885
914
  };
886
915
  case "swift": return {
887
916
  install: ".package(url: \"https://github.com/canopy-labs/featureflip-swift\", from: \"2.0.0\")",
@@ -894,13 +923,13 @@ let enabled = client.boolVariation("${t}", default: false)
894
923
  if enabled {
895
924
  // new code path
896
925
  }`,
897
- notes: I
926
+ notes: L
898
927
  };
899
928
  }
900
929
  }
901
930
  //#endregion
902
931
  //#region src/tools/workflows/wrap-feature.ts
903
- function R(e, n) {
932
+ function z(e, n) {
904
933
  e.registerTool("wrap_feature", {
905
934
  title: "Wrap a feature in a new flag",
906
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.",
@@ -910,7 +939,7 @@ function R(e, n) {
910
939
  name: t.string().optional().describe("Display name; defaults to the key"),
911
940
  description: t.string().optional().describe("What this flag guards"),
912
941
  tags: t.array(t.string()).optional(),
913
- language: t.enum(M).describe("SDK language of the codebase being edited"),
942
+ language: t.enum(N).describe("SDK language of the codebase being edited"),
914
943
  idempotency_key: t.string().optional()
915
944
  }),
916
945
  annotations: { destructiveHint: !1 }
@@ -922,10 +951,10 @@ function R(e, n) {
922
951
  type: "Boolean",
923
952
  description: a,
924
953
  tags: o,
925
- ...P(s) ? { clientSideVisible: !0 } : {}
954
+ ...F(s) ? { clientSideVisible: !0 } : {}
926
955
  },
927
956
  idempotencyKey: c
928
- }), d = L(s, t);
957
+ }), d = R(s, t);
929
958
  return l({
930
959
  flag: u,
931
960
  integration: {
@@ -944,17 +973,17 @@ function R(e, n) {
944
973
  }
945
974
  //#endregion
946
975
  //#region src/server.ts
947
- function z(t) {
976
+ function B(t) {
948
977
  let r = new e({
949
978
  name: "featureflip",
950
979
  version: n
951
980
  });
952
- 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), j(r, t), R(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;
953
982
  }
954
983
  //#endregion
955
984
  //#region src/config.ts
956
- var B = "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.";
957
- function V(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) {
958
987
  let t = e.FEATUREFLIP_TOKEN?.trim();
959
988
  return t ? {
960
989
  token: t,
@@ -962,14 +991,14 @@ function V(e = process.env) {
962
991
  org: e.FEATUREFLIP_ORG?.trim() || void 0
963
992
  } : null;
964
993
  }
965
- function H(e = process.env) {
966
- let t = V(e);
967
- if (!t) throw Error(B);
994
+ function U(e = process.env) {
995
+ let t = H(e);
996
+ if (!t) throw Error(V);
968
997
  return t;
969
998
  }
970
999
  //#endregion
971
1000
  //#region src/org.ts
972
- async function U(e, t) {
1001
+ async function W(e, t) {
973
1002
  if (t) return t;
974
1003
  let n = (await e.request("GET", "/api/v1/orgs", { query: { limit: 100 } })).items ?? [];
975
1004
  if (n.length === 1) {
@@ -980,4 +1009,4 @@ async function U(e, t) {
980
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(", "));
981
1010
  }
982
1011
  //#endregion
983
- export { z as a, n as c, V as i, B as n, r as o, H as r, c as s, U 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;AAKhD,wBAAgB,iBAAiB,CAAC,MAAM,EAAE,SAAS,EAAE,GAAG,EAAE,WAAW,GAAG,IAAI,CAgQ3E"}
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"}
@@ -1 +1 @@
1
- {"version":3,"file":"stale-flags.d.ts","sourceRoot":"","sources":["../../../src/tools/workflows/stale-flags.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,8BAA8B,CAAC;AAI9D,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,eAAe,CAAC;AAajD,wBAAgB,sBAAsB,CAAC,MAAM,EAAE,SAAS,EAAE,GAAG,EAAE,WAAW,GAAG,IAAI,CAoGhF"}
1
+ {"version":3,"file":"stale-flags.d.ts","sourceRoot":"","sources":["../../../src/tools/workflows/stale-flags.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,8BAA8B,CAAC;AAI9D,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,eAAe,CAAC;AAsBjD,wBAAgB,sBAAsB,CAAC,MAAM,EAAE,SAAS,EAAE,GAAG,EAAE,WAAW,GAAG,IAAI,CAmKhF"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@featureflip/mcp",
3
- "version": "0.1.9",
3
+ "version": "0.1.10",
4
4
  "mcpName": "io.github.canopy-labs/featureflip",
5
5
  "description": "MCP server for managing Featureflip feature flags from AI agents and editors",
6
6
  "keywords": [