@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/README.md +1 -0
- package/dist/cli.mjs +1 -1
- package/dist/generated/api-types.d.ts +11 -4
- package/dist/generated/api-types.d.ts.map +1 -1
- package/dist/index.mjs +1 -1
- package/dist/{org-8tn1Su68.js → org-C5dAa5In.js} +85 -56
- package/dist/tools/flags.d.ts.map +1 -1
- package/dist/tools/workflows/stale-flags.d.ts.map +1 -1
- package/package.json +1 -1
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-
|
|
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.
|
|
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
|
|
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 ${
|
|
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:
|
|
692
|
-
let
|
|
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",
|
|
694
|
+
let e = await n.api.request("GET", s, { query: {
|
|
695
695
|
archived: !1,
|
|
696
696
|
limit: 100,
|
|
697
|
-
cursor:
|
|
698
|
-
owner:
|
|
697
|
+
cursor: p,
|
|
698
|
+
owner: o
|
|
699
699
|
} });
|
|
700
|
-
|
|
701
|
-
} while (
|
|
702
|
-
|
|
703
|
-
let
|
|
704
|
-
for (let e of
|
|
705
|
-
let t = await n.api.request("GET", `${
|
|
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 =
|
|
708
|
-
(a ||
|
|
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" :
|
|
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
|
-
...
|
|
723
|
-
checked:
|
|
724
|
-
truncated:
|
|
725
|
-
...
|
|
726
|
-
|
|
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
|
|
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
|
-
],
|
|
773
|
+
], P = [
|
|
745
774
|
"browser",
|
|
746
775
|
"react",
|
|
747
776
|
"swift"
|
|
748
777
|
];
|
|
749
|
-
function
|
|
750
|
-
return
|
|
778
|
+
function F(e) {
|
|
779
|
+
return P.includes(e);
|
|
751
780
|
}
|
|
752
|
-
var
|
|
753
|
-
function
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
926
|
+
notes: L
|
|
898
927
|
};
|
|
899
928
|
}
|
|
900
929
|
}
|
|
901
930
|
//#endregion
|
|
902
931
|
//#region src/tools/workflows/wrap-feature.ts
|
|
903
|
-
function
|
|
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(
|
|
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
|
-
...
|
|
954
|
+
...F(s) ? { clientSideVisible: !0 } : {}
|
|
926
955
|
},
|
|
927
956
|
idempotencyKey: c
|
|
928
|
-
}), d =
|
|
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
|
|
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),
|
|
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
|
|
957
|
-
function
|
|
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
|
|
966
|
-
let t =
|
|
967
|
-
if (!t) throw Error(
|
|
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
|
|
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 {
|
|
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,
|
|
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;
|
|
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