@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/README.md +4 -2
- package/dist/cli.mjs +1 -1
- package/dist/generated/api-types.d.ts +240 -16
- package/dist/generated/api-types.d.ts.map +1 -1
- package/dist/index.mjs +1 -1
- package/dist/{org-DZ1ge1Mx.js → org-C5dAa5In.js} +170 -94
- package/dist/tools/flags.d.ts.map +1 -1
- package/dist/tools/owner.d.ts +10 -0
- package/dist/tools/owner.d.ts.map +1 -0
- package/dist/tools/workflows/stale-flags.d.ts.map +1 -1
- package/package.json +4 -4
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: [
|
|
@@ -43,15 +43,15 @@ var n = {
|
|
|
43
43
|
"smoke:docker": "node scripts/docker-smoke.mjs"
|
|
44
44
|
},
|
|
45
45
|
dependencies: {
|
|
46
|
-
"@modelcontextprotocol/server": "2.
|
|
46
|
+
"@modelcontextprotocol/server": "2.1.0",
|
|
47
47
|
zod: "^4.6.5"
|
|
48
48
|
},
|
|
49
49
|
devDependencies: {
|
|
50
|
-
"@modelcontextprotocol/client": "2.
|
|
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.
|
|
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
|
|
233
|
+
var v = t.enum([
|
|
223
234
|
"Boolean",
|
|
224
235
|
"String",
|
|
225
236
|
"Number",
|
|
226
237
|
"Json"
|
|
227
238
|
]);
|
|
228
|
-
function
|
|
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:
|
|
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,
|
|
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:
|
|
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
|
|
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,
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
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
|
|
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
|
|
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
|
|
388
|
+
var b = t.enum([
|
|
346
389
|
"SingleVariation",
|
|
347
390
|
"PercentageRollout",
|
|
348
391
|
"TargetedRollout"
|
|
349
392
|
]);
|
|
350
|
-
function
|
|
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:
|
|
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
|
|
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
|
-
}),
|
|
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(
|
|
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
|
|
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(
|
|
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
|
|
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
|
|
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
|
|
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 (
|
|
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 (
|
|
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. " +
|
|
538
|
-
inputSchema: t.object({ ...
|
|
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. " +
|
|
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
|
-
...
|
|
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. " +
|
|
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. " +
|
|
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
|
|
638
|
-
function
|
|
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
|
|
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
|
|
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",
|
|
694
|
+
let e = await n.api.request("GET", s, { query: {
|
|
651
695
|
archived: !1,
|
|
652
696
|
limit: 100,
|
|
653
|
-
cursor:
|
|
697
|
+
cursor: p,
|
|
698
|
+
owner: o
|
|
654
699
|
} });
|
|
655
|
-
|
|
656
|
-
} while (
|
|
657
|
-
|
|
658
|
-
let
|
|
659
|
-
for (let e of
|
|
660
|
-
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`);
|
|
661
706
|
if (t.length === 0) continue;
|
|
662
|
-
let
|
|
663
|
-
(
|
|
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:
|
|
669
|
-
|
|
670
|
-
|
|
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
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
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
|
|
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
|
-
],
|
|
773
|
+
], P = [
|
|
698
774
|
"browser",
|
|
699
775
|
"react",
|
|
700
776
|
"swift"
|
|
701
777
|
];
|
|
702
|
-
function
|
|
703
|
-
return
|
|
778
|
+
function F(e) {
|
|
779
|
+
return P.includes(e);
|
|
704
780
|
}
|
|
705
|
-
var
|
|
706
|
-
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) {
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
926
|
+
notes: L
|
|
851
927
|
};
|
|
852
928
|
}
|
|
853
929
|
}
|
|
854
930
|
//#endregion
|
|
855
931
|
//#region src/tools/workflows/wrap-feature.ts
|
|
856
|
-
function
|
|
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(
|
|
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
|
-
...
|
|
954
|
+
...F(s) ? { clientSideVisible: !0 } : {}
|
|
879
955
|
},
|
|
880
956
|
idempotencyKey: c
|
|
881
|
-
}), d =
|
|
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
|
|
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),
|
|
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
|
|
910
|
-
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) {
|
|
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
|
|
919
|
-
let t =
|
|
920
|
-
if (!t) throw Error(
|
|
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
|
|
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 {
|
|
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;
|
|
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"}
|