@frontmcp/plugin-feature-flags 1.8.2 → 1.8.4
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/esm/index.mjs +57 -17
- package/esm/package.json +2 -2
- package/feature-flag.context.d.ts +1 -1
- package/feature-flag.context.d.ts.map +1 -1
- package/feature-flag.plugin.d.ts +10 -0
- package/feature-flag.plugin.d.ts.map +1 -1
- package/index.js +72 -34
- package/package.json +2 -2
package/esm/index.mjs
CHANGED
|
@@ -247,11 +247,13 @@ import {
|
|
|
247
247
|
FlowHooksOf,
|
|
248
248
|
FRONTMCP_CONTEXT,
|
|
249
249
|
FrontMcpContextStorage,
|
|
250
|
+
isEntryGatedBy,
|
|
250
251
|
ListResourcesHook,
|
|
251
252
|
ListResourceTemplatesHook,
|
|
252
253
|
ListToolsHook,
|
|
253
254
|
Plugin,
|
|
254
255
|
ProviderScope as ProviderScope2,
|
|
256
|
+
ScopeEntry,
|
|
255
257
|
ToolHook
|
|
256
258
|
} from "@frontmcp/sdk";
|
|
257
259
|
|
|
@@ -297,12 +299,21 @@ var StaticFeatureFlagAdapter = class {
|
|
|
297
299
|
};
|
|
298
300
|
|
|
299
301
|
// plugins/plugin-feature-flags/src/feature-flag.context.ts
|
|
302
|
+
import { isAnonymousSubject, STATELESS_SESSION_ID } from "@frontmcp/sdk";
|
|
303
|
+
function verifiedSessionId(ctx) {
|
|
304
|
+
const verified = ctx.authInfo?.sessionId ?? ctx.authInfo?.extra?.["sessionId"];
|
|
305
|
+
return typeof verified === "string" && verified.length > 0 && verified !== STATELESS_SESSION_ID ? verified : void 0;
|
|
306
|
+
}
|
|
307
|
+
function signedInUserId(ctx) {
|
|
308
|
+
const userId = ctx.authInfo?.extra?.["sub"] ?? ctx.authInfo?.extra?.["userId"] ?? ctx.authInfo?.clientId;
|
|
309
|
+
return isAnonymousSubject(userId) ? void 0 : userId;
|
|
310
|
+
}
|
|
300
311
|
function buildFeatureFlagContext(ctx, config) {
|
|
301
312
|
if (!ctx) return {};
|
|
302
|
-
const userId = config.userIdResolver ? config.userIdResolver(ctx) : ctx
|
|
313
|
+
const userId = config.userIdResolver ? config.userIdResolver(ctx) : signedInUserId(ctx);
|
|
303
314
|
return {
|
|
304
315
|
userId: userId ?? void 0,
|
|
305
|
-
sessionId: ctx
|
|
316
|
+
sessionId: verifiedSessionId(ctx),
|
|
306
317
|
attributes: config.attributesResolver ? config.attributesResolver(ctx) : {}
|
|
307
318
|
};
|
|
308
319
|
}
|
|
@@ -410,13 +421,14 @@ var FeatureFlagPlugin = class extends DynamicPlugin {
|
|
|
410
421
|
async filterListTools(flowCtx) {
|
|
411
422
|
const { tools } = flowCtx.state;
|
|
412
423
|
if (!tools || tools.length === 0) return;
|
|
413
|
-
const
|
|
424
|
+
const judged = this.judgedItems(tools, (item) => ({ tool: item.tool }));
|
|
425
|
+
const flaggedTools = this.collectFlagRefs(judged, (item) => item.tool.metadata.featureFlag);
|
|
414
426
|
if (flaggedTools.size === 0) return;
|
|
415
427
|
const adapter = this.get(FeatureFlagAdapterToken);
|
|
416
428
|
const flagResults = await this.batchEvaluateRefs(adapter, flaggedTools);
|
|
417
429
|
const filtered = tools.filter((item) => {
|
|
418
430
|
const ref = item.tool.metadata?.featureFlag;
|
|
419
|
-
if (!ref) return true;
|
|
431
|
+
if (!ref || !judged.has(item)) return true;
|
|
420
432
|
return this.isRefEnabled(ref, flagResults);
|
|
421
433
|
});
|
|
422
434
|
flowCtx.state.set("tools", filtered);
|
|
@@ -424,13 +436,14 @@ var FeatureFlagPlugin = class extends DynamicPlugin {
|
|
|
424
436
|
async filterListResources(flowCtx) {
|
|
425
437
|
const { resources } = flowCtx.state;
|
|
426
438
|
if (!resources || resources.length === 0) return;
|
|
427
|
-
const
|
|
439
|
+
const judged = this.judgedItems(resources, (item) => ({ resource: item.resource }));
|
|
440
|
+
const flaggedResources = this.collectFlagRefs(judged, (item) => item.resource.metadata.featureFlag);
|
|
428
441
|
if (flaggedResources.size === 0) return;
|
|
429
442
|
const adapter = this.get(FeatureFlagAdapterToken);
|
|
430
443
|
const flagResults = await this.batchEvaluateRefs(adapter, flaggedResources);
|
|
431
444
|
const filtered = resources.filter((item) => {
|
|
432
445
|
const ref = item.resource.metadata?.featureFlag;
|
|
433
|
-
if (!ref) return true;
|
|
446
|
+
if (!ref || !judged.has(item)) return true;
|
|
434
447
|
return this.isRefEnabled(ref, flagResults);
|
|
435
448
|
});
|
|
436
449
|
flowCtx.state.set("resources", filtered);
|
|
@@ -438,13 +451,14 @@ var FeatureFlagPlugin = class extends DynamicPlugin {
|
|
|
438
451
|
async filterListResourceTemplates(flowCtx) {
|
|
439
452
|
const { templates } = flowCtx.state;
|
|
440
453
|
if (!templates || templates.length === 0) return;
|
|
441
|
-
const
|
|
454
|
+
const judged = this.judgedItems(templates, (item) => ({ resource: item.template }));
|
|
455
|
+
const flaggedTemplates = this.collectFlagRefs(judged, (item) => item.template.metadata.featureFlag);
|
|
442
456
|
if (flaggedTemplates.size === 0) return;
|
|
443
457
|
const adapter = this.get(FeatureFlagAdapterToken);
|
|
444
458
|
const flagResults = await this.batchEvaluateRefs(adapter, flaggedTemplates);
|
|
445
459
|
const filtered = templates.filter((item) => {
|
|
446
460
|
const ref = item.template.metadata.featureFlag;
|
|
447
|
-
if (!ref) return true;
|
|
461
|
+
if (!ref || !judged.has(item)) return true;
|
|
448
462
|
return this.isRefEnabled(ref, flagResults);
|
|
449
463
|
});
|
|
450
464
|
flowCtx.state.set("templates", filtered);
|
|
@@ -452,13 +466,16 @@ var FeatureFlagPlugin = class extends DynamicPlugin {
|
|
|
452
466
|
async filterListPrompts(flowCtx) {
|
|
453
467
|
const { prompts } = flowCtx.state;
|
|
454
468
|
if (!prompts || prompts.length === 0) return;
|
|
455
|
-
const
|
|
469
|
+
const judged = this.judgedItems(prompts, (item) => ({
|
|
470
|
+
prompt: item.prompt
|
|
471
|
+
}));
|
|
472
|
+
const flaggedPrompts = this.collectFlagRefs(judged, (item) => item.prompt.metadata.featureFlag);
|
|
456
473
|
if (flaggedPrompts.size === 0) return;
|
|
457
474
|
const adapter = this.get(FeatureFlagAdapterToken);
|
|
458
475
|
const flagResults = await this.batchEvaluateRefs(adapter, flaggedPrompts);
|
|
459
476
|
const filtered = prompts.filter((item) => {
|
|
460
477
|
const ref = item.prompt.metadata?.featureFlag;
|
|
461
|
-
if (!ref) return true;
|
|
478
|
+
if (!ref || !judged.has(item)) return true;
|
|
462
479
|
return this.isRefEnabled(ref, flagResults);
|
|
463
480
|
});
|
|
464
481
|
flowCtx.state.set("prompts", filtered);
|
|
@@ -466,13 +483,14 @@ var FeatureFlagPlugin = class extends DynamicPlugin {
|
|
|
466
483
|
async filterSkills(flowCtx) {
|
|
467
484
|
const { skills } = flowCtx.state;
|
|
468
485
|
if (!skills || skills.length === 0) return;
|
|
469
|
-
const
|
|
486
|
+
const judged = this.judgedItems(skills, (skill) => ({ skill }));
|
|
487
|
+
const flaggedSkills = this.collectFlagRefs(judged, (skill) => skill.metadata.featureFlag);
|
|
470
488
|
if (flaggedSkills.size === 0) return;
|
|
471
489
|
const adapter = this.get(FeatureFlagAdapterToken);
|
|
472
490
|
const flagResults = await this.batchEvaluateRefs(adapter, flaggedSkills);
|
|
473
491
|
const filtered = skills.filter((skill) => {
|
|
474
492
|
const ref = skill.metadata.featureFlag;
|
|
475
|
-
if (!ref) return true;
|
|
493
|
+
if (!ref || !judged.has(skill)) return true;
|
|
476
494
|
return this.isRefEnabled(ref, flagResults);
|
|
477
495
|
});
|
|
478
496
|
flowCtx.state.set("skills", filtered);
|
|
@@ -519,6 +537,26 @@ var FeatureFlagPlugin = class extends DynamicPlugin {
|
|
|
519
537
|
// ─────────────────────────────────────────────────────────────────────────
|
|
520
538
|
// Private Helpers
|
|
521
539
|
// ─────────────────────────────────────────────────────────────────────────
|
|
540
|
+
/**
|
|
541
|
+
* The listed items this plugin instance judges: those whose gate (tools/call, resources/read,
|
|
542
|
+
* prompts/get, or `skills:filter` for skills) runs this instance's hook when the entry is served.
|
|
543
|
+
*
|
|
544
|
+
* List flows run every app's plugin over every app's entries, while a call is judged only by the
|
|
545
|
+
* plugin that covers the entry's app. With a FeatureFlagPlugin on each of two apps, filtering
|
|
546
|
+
* everything let one app's flags hide the other app's entries, which its own plugin still served
|
|
547
|
+
* by name. When the scope can't be read (a plugin used outside a server), every item is judged.
|
|
548
|
+
*/
|
|
549
|
+
judgedItems(items, entryOf) {
|
|
550
|
+
let scope;
|
|
551
|
+
try {
|
|
552
|
+
scope = this.get(ScopeEntry);
|
|
553
|
+
} catch {
|
|
554
|
+
scope = void 0;
|
|
555
|
+
}
|
|
556
|
+
if (!scope?.hooks) return new Set(items);
|
|
557
|
+
const judgingScope = scope;
|
|
558
|
+
return new Set(items.filter((item) => isEntryGatedBy(judgingScope, entryOf(item), this)));
|
|
559
|
+
}
|
|
522
560
|
/**
|
|
523
561
|
* Collect unique flag keys from items that have a featureFlag metadata.
|
|
524
562
|
*/
|
|
@@ -658,24 +696,26 @@ __decorateClass([
|
|
|
658
696
|
ListPromptsHook.Did("findPrompts", { priority: 50 })
|
|
659
697
|
], FeatureFlagPlugin.prototype, "filterListPrompts", 1);
|
|
660
698
|
__decorateClass([
|
|
661
|
-
FilterSkillsHook.Did("filterSkills", { priority: 50 })
|
|
699
|
+
FilterSkillsHook.Did("filterSkills", { priority: 50, appliesTo: "uncovered-apps" })
|
|
662
700
|
], FeatureFlagPlugin.prototype, "filterSkills", 1);
|
|
663
701
|
__decorateClass([
|
|
664
|
-
ToolHook.Will("execute", { priority: 50 })
|
|
702
|
+
ToolHook.Will("execute", { priority: 50, appliesTo: "uncovered-apps" })
|
|
665
703
|
], FeatureFlagPlugin.prototype, "gateToolExecution", 1);
|
|
666
704
|
__decorateClass([
|
|
667
|
-
ReadResourceHook.Will("execute", { priority: 50 })
|
|
705
|
+
ReadResourceHook.Will("execute", { priority: 50, appliesTo: "uncovered-apps" })
|
|
668
706
|
], FeatureFlagPlugin.prototype, "gateResourceRead", 1);
|
|
669
707
|
__decorateClass([
|
|
670
|
-
GetPromptHook.Will("execute", { priority: 50 })
|
|
708
|
+
GetPromptHook.Will("execute", { priority: 50, appliesTo: "uncovered-apps" })
|
|
671
709
|
], FeatureFlagPlugin.prototype, "gatePromptGet", 1);
|
|
672
710
|
__decorateClass([
|
|
673
|
-
CompleteHook.Will("complete", { priority: 50 })
|
|
711
|
+
CompleteHook.Will("complete", { priority: 50, appliesTo: "uncovered-apps" })
|
|
674
712
|
], FeatureFlagPlugin.prototype, "gateCompletion", 1);
|
|
675
713
|
FeatureFlagPlugin = __decorateClass([
|
|
676
714
|
Plugin({
|
|
677
715
|
name: "feature-flags",
|
|
678
716
|
description: "Feature flag-based capability filtering for MCP",
|
|
717
|
+
// A server where an entry declares `featureFlag` and none of these gates reaches it refuses to start.
|
|
718
|
+
enforcesMetadata: ["featureFlag"],
|
|
679
719
|
providers: [],
|
|
680
720
|
contextExtensions: [
|
|
681
721
|
{
|
package/esm/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@frontmcp/plugin-feature-flags",
|
|
3
|
-
"version": "1.8.
|
|
3
|
+
"version": "1.8.4",
|
|
4
4
|
"description": "Feature flag plugin for FrontMCP - dynamically gate MCP capabilities behind feature flags",
|
|
5
5
|
"author": "AgentFront <info@agentfront.dev>",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -49,7 +49,7 @@
|
|
|
49
49
|
}
|
|
50
50
|
},
|
|
51
51
|
"dependencies": {
|
|
52
|
-
"@frontmcp/sdk": "1.8.
|
|
52
|
+
"@frontmcp/sdk": "1.8.4"
|
|
53
53
|
},
|
|
54
54
|
"peerDependencies": {
|
|
55
55
|
"@launchdarkly/node-server-sdk": "^9.0.0 || ^10.0.0",
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { type FrontMcpContext } from '@frontmcp/sdk';
|
|
2
2
|
import type { FeatureFlagContext, FeatureFlagPluginOptions } from './feature-flag.types';
|
|
3
3
|
/**
|
|
4
4
|
* Build the adapter evaluation context for the current caller.
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"feature-flag.context.d.ts","sourceRoot":"","sources":["../src/feature-flag.context.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,
|
|
1
|
+
{"version":3,"file":"feature-flag.context.d.ts","sourceRoot":"","sources":["../src/feature-flag.context.ts"],"names":[],"mappings":"AAAA,OAAO,EAA4C,KAAK,eAAe,EAAE,MAAM,eAAe,CAAC;AAE/F,OAAO,KAAK,EAAE,kBAAkB,EAAE,wBAAwB,EAAE,MAAM,sBAAsB,CAAC;AAiCzF;;;;;;;GAOG;AACH,wBAAgB,uBAAuB,CACrC,GAAG,EAAE,eAAe,GAAG,SAAS,EAChC,MAAM,EAAE,IAAI,CAAC,wBAAwB,EAAE,gBAAgB,GAAG,oBAAoB,CAAC,GAC9E,kBAAkB,CAUpB"}
|
package/feature-flag.plugin.d.ts
CHANGED
|
@@ -89,6 +89,16 @@ export default class FeatureFlagPlugin extends DynamicPlugin<FeatureFlagPluginOp
|
|
|
89
89
|
* the other two did not, and three copies would drift apart the same way.
|
|
90
90
|
*/
|
|
91
91
|
private gateEntryExecution;
|
|
92
|
+
/**
|
|
93
|
+
* The listed items this plugin instance judges: those whose gate (tools/call, resources/read,
|
|
94
|
+
* prompts/get, or `skills:filter` for skills) runs this instance's hook when the entry is served.
|
|
95
|
+
*
|
|
96
|
+
* List flows run every app's plugin over every app's entries, while a call is judged only by the
|
|
97
|
+
* plugin that covers the entry's app. With a FeatureFlagPlugin on each of two apps, filtering
|
|
98
|
+
* everything let one app's flags hide the other app's entries, which its own plugin still served
|
|
99
|
+
* by name. When the scope can't be read (a plugin used outside a server), every item is judged.
|
|
100
|
+
*/
|
|
101
|
+
private judgedItems;
|
|
92
102
|
/**
|
|
93
103
|
* Collect unique flag keys from items that have a featureFlag metadata.
|
|
94
104
|
*/
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"feature-flag.plugin.d.ts","sourceRoot":"","sources":["../src/feature-flag.plugin.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,aAAa,
|
|
1
|
+
{"version":3,"file":"feature-flag.plugin.d.ts","sourceRoot":"","sources":["../src/feature-flag.plugin.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,aAAa,EAYb,KAAK,SAAS,EAGd,KAAK,YAAY,EAClB,MAAM,eAAe,CAAC;AAMvB,OAAO,KAAK,EAEV,wBAAwB,EACxB,6BAA6B,EAE9B,MAAM,sBAAsB,CAAC;AAa9B;;;;;;;;;;;;;;;;;;GAkBG;AAeH,MAAM,CAAC,OAAO,OAAO,iBAAkB,SAAQ,aAAa,CAAC,wBAAwB,EAAE,6BAA6B,CAAC;IACnH,OAAO,EAAE,wBAAwB,CAAC;gBAEtB,OAAO,EAAE,6BAA6B;IAKlD;;OAEG;IACH,OAAgB,gBAAgB,GAAI,SAAS,6BAA6B,KAAG,YAAY,EAAE,CA0FzF;IAMF;;;OAGG;IAEG,eAAe,CAAC,OAAO,EAAE,SAAS,CAAC,kBAAkB,CAAC;IAqB5D;;OAEG;IAEG,mBAAmB,CAAC,OAAO,EAAE,SAAS,CAAC,0BAA0B,CAAC;IAoBxE;;;;OAIG;IAEG,2BAA2B,CAAC,OAAO,EAAE,SAAS,CAAC,mCAAmC,CAAC;IAoBzF;;OAEG;IAEG,iBAAiB,CAAC,OAAO,EAAE,GAAG;IAsBpC;;;;;;;;OAQG;IAEG,YAAY,CAAC,OAAO,EAAE,SAAS,CAAC,eAAe,CAAC;IAsBtD;;;OAGG;IAEG,iBAAiB,CAAC,OAAO,EAAE,SAAS,CAAC,iBAAiB,CAAC;IAI7D;;;;;;OAMG;IAEG,gBAAgB,CAAC,OAAO,EAAE,GAAG;IAInC;;;;;OAKG;IAEG,aAAa,CAAC,OAAO,EAAE,GAAG;IAIhC;;;;;OAKG;IAEG,cAAc,CAAC,OAAO,EAAE,GAAG;IAMjC;;;;;OAKG;YACW,kBAAkB;IA+BhC;;;;;;;;OAQG;IACH,OAAO,CAAC,WAAW;IAYnB;;OAEG;IACH,OAAO,CAAC,eAAe;IAiBvB;;OAEG;YACW,iBAAiB;IAQ/B;;;;;;;OAOG;IACH,OAAO,CAAC,kBAAkB;IAW1B;;;;OAIG;IACH,OAAO,CAAC,YAAY;CAWrB"}
|
package/index.js
CHANGED
|
@@ -255,7 +255,7 @@ __export(index_exports, {
|
|
|
255
255
|
module.exports = __toCommonJS(index_exports);
|
|
256
256
|
|
|
257
257
|
// plugins/plugin-feature-flags/src/feature-flag.plugin.ts
|
|
258
|
-
var
|
|
258
|
+
var import_sdk3 = require("@frontmcp/sdk");
|
|
259
259
|
|
|
260
260
|
// plugins/plugin-feature-flags/src/adapters/static.adapter.ts
|
|
261
261
|
var StaticFeatureFlagAdapter = class {
|
|
@@ -299,12 +299,21 @@ var StaticFeatureFlagAdapter = class {
|
|
|
299
299
|
};
|
|
300
300
|
|
|
301
301
|
// plugins/plugin-feature-flags/src/feature-flag.context.ts
|
|
302
|
+
var import_sdk = require("@frontmcp/sdk");
|
|
303
|
+
function verifiedSessionId(ctx) {
|
|
304
|
+
const verified = ctx.authInfo?.sessionId ?? ctx.authInfo?.extra?.["sessionId"];
|
|
305
|
+
return typeof verified === "string" && verified.length > 0 && verified !== import_sdk.STATELESS_SESSION_ID ? verified : void 0;
|
|
306
|
+
}
|
|
307
|
+
function signedInUserId(ctx) {
|
|
308
|
+
const userId = ctx.authInfo?.extra?.["sub"] ?? ctx.authInfo?.extra?.["userId"] ?? ctx.authInfo?.clientId;
|
|
309
|
+
return (0, import_sdk.isAnonymousSubject)(userId) ? void 0 : userId;
|
|
310
|
+
}
|
|
302
311
|
function buildFeatureFlagContext(ctx, config) {
|
|
303
312
|
if (!ctx) return {};
|
|
304
|
-
const userId = config.userIdResolver ? config.userIdResolver(ctx) : ctx
|
|
313
|
+
const userId = config.userIdResolver ? config.userIdResolver(ctx) : signedInUserId(ctx);
|
|
305
314
|
return {
|
|
306
315
|
userId: userId ?? void 0,
|
|
307
|
-
sessionId: ctx
|
|
316
|
+
sessionId: verifiedSessionId(ctx),
|
|
308
317
|
attributes: config.attributesResolver ? config.attributesResolver(ctx) : {}
|
|
309
318
|
};
|
|
310
319
|
}
|
|
@@ -321,7 +330,7 @@ var FeatureFlagAccessorToken = /* @__PURE__ */ Symbol(
|
|
|
321
330
|
);
|
|
322
331
|
|
|
323
332
|
// plugins/plugin-feature-flags/src/providers/feature-flag-accessor.provider.ts
|
|
324
|
-
var
|
|
333
|
+
var import_sdk2 = require("@frontmcp/sdk");
|
|
325
334
|
var FeatureFlagAccessor = class {
|
|
326
335
|
adapter;
|
|
327
336
|
ctx;
|
|
@@ -387,10 +396,10 @@ var FeatureFlagAccessor = class {
|
|
|
387
396
|
}
|
|
388
397
|
};
|
|
389
398
|
FeatureFlagAccessor = __decorateClass([
|
|
390
|
-
(0,
|
|
399
|
+
(0, import_sdk2.Provider)({
|
|
391
400
|
name: "provider:feature-flags:accessor",
|
|
392
401
|
description: "Context-scoped accessor for feature flag evaluation",
|
|
393
|
-
scope:
|
|
402
|
+
scope: import_sdk2.ProviderScope.CONTEXT
|
|
394
403
|
})
|
|
395
404
|
], FeatureFlagAccessor);
|
|
396
405
|
function createFeatureFlagAccessor(adapter, ctx, config) {
|
|
@@ -398,12 +407,12 @@ function createFeatureFlagAccessor(adapter, ctx, config) {
|
|
|
398
407
|
}
|
|
399
408
|
|
|
400
409
|
// plugins/plugin-feature-flags/src/feature-flag.plugin.ts
|
|
401
|
-
var ListPromptsHook = (0,
|
|
402
|
-
var ReadResourceHook = (0,
|
|
403
|
-
var GetPromptHook = (0,
|
|
404
|
-
var CompleteHook = (0,
|
|
405
|
-
var FilterSkillsHook = (0,
|
|
406
|
-
var FeatureFlagPlugin = class extends
|
|
410
|
+
var ListPromptsHook = (0, import_sdk3.FlowHooksOf)("prompts:list-prompts");
|
|
411
|
+
var ReadResourceHook = (0, import_sdk3.FlowHooksOf)("resources:read-resource");
|
|
412
|
+
var GetPromptHook = (0, import_sdk3.FlowHooksOf)("prompts:get-prompt");
|
|
413
|
+
var CompleteHook = (0, import_sdk3.FlowHooksOf)("completion:complete");
|
|
414
|
+
var FilterSkillsHook = (0, import_sdk3.FlowHooksOf)("skills:filter");
|
|
415
|
+
var FeatureFlagPlugin = class extends import_sdk3.DynamicPlugin {
|
|
407
416
|
options;
|
|
408
417
|
constructor(options) {
|
|
409
418
|
super();
|
|
@@ -412,13 +421,14 @@ var FeatureFlagPlugin = class extends import_sdk2.DynamicPlugin {
|
|
|
412
421
|
async filterListTools(flowCtx) {
|
|
413
422
|
const { tools } = flowCtx.state;
|
|
414
423
|
if (!tools || tools.length === 0) return;
|
|
415
|
-
const
|
|
424
|
+
const judged = this.judgedItems(tools, (item) => ({ tool: item.tool }));
|
|
425
|
+
const flaggedTools = this.collectFlagRefs(judged, (item) => item.tool.metadata.featureFlag);
|
|
416
426
|
if (flaggedTools.size === 0) return;
|
|
417
427
|
const adapter = this.get(FeatureFlagAdapterToken);
|
|
418
428
|
const flagResults = await this.batchEvaluateRefs(adapter, flaggedTools);
|
|
419
429
|
const filtered = tools.filter((item) => {
|
|
420
430
|
const ref = item.tool.metadata?.featureFlag;
|
|
421
|
-
if (!ref) return true;
|
|
431
|
+
if (!ref || !judged.has(item)) return true;
|
|
422
432
|
return this.isRefEnabled(ref, flagResults);
|
|
423
433
|
});
|
|
424
434
|
flowCtx.state.set("tools", filtered);
|
|
@@ -426,13 +436,14 @@ var FeatureFlagPlugin = class extends import_sdk2.DynamicPlugin {
|
|
|
426
436
|
async filterListResources(flowCtx) {
|
|
427
437
|
const { resources } = flowCtx.state;
|
|
428
438
|
if (!resources || resources.length === 0) return;
|
|
429
|
-
const
|
|
439
|
+
const judged = this.judgedItems(resources, (item) => ({ resource: item.resource }));
|
|
440
|
+
const flaggedResources = this.collectFlagRefs(judged, (item) => item.resource.metadata.featureFlag);
|
|
430
441
|
if (flaggedResources.size === 0) return;
|
|
431
442
|
const adapter = this.get(FeatureFlagAdapterToken);
|
|
432
443
|
const flagResults = await this.batchEvaluateRefs(adapter, flaggedResources);
|
|
433
444
|
const filtered = resources.filter((item) => {
|
|
434
445
|
const ref = item.resource.metadata?.featureFlag;
|
|
435
|
-
if (!ref) return true;
|
|
446
|
+
if (!ref || !judged.has(item)) return true;
|
|
436
447
|
return this.isRefEnabled(ref, flagResults);
|
|
437
448
|
});
|
|
438
449
|
flowCtx.state.set("resources", filtered);
|
|
@@ -440,13 +451,14 @@ var FeatureFlagPlugin = class extends import_sdk2.DynamicPlugin {
|
|
|
440
451
|
async filterListResourceTemplates(flowCtx) {
|
|
441
452
|
const { templates } = flowCtx.state;
|
|
442
453
|
if (!templates || templates.length === 0) return;
|
|
443
|
-
const
|
|
454
|
+
const judged = this.judgedItems(templates, (item) => ({ resource: item.template }));
|
|
455
|
+
const flaggedTemplates = this.collectFlagRefs(judged, (item) => item.template.metadata.featureFlag);
|
|
444
456
|
if (flaggedTemplates.size === 0) return;
|
|
445
457
|
const adapter = this.get(FeatureFlagAdapterToken);
|
|
446
458
|
const flagResults = await this.batchEvaluateRefs(adapter, flaggedTemplates);
|
|
447
459
|
const filtered = templates.filter((item) => {
|
|
448
460
|
const ref = item.template.metadata.featureFlag;
|
|
449
|
-
if (!ref) return true;
|
|
461
|
+
if (!ref || !judged.has(item)) return true;
|
|
450
462
|
return this.isRefEnabled(ref, flagResults);
|
|
451
463
|
});
|
|
452
464
|
flowCtx.state.set("templates", filtered);
|
|
@@ -454,13 +466,16 @@ var FeatureFlagPlugin = class extends import_sdk2.DynamicPlugin {
|
|
|
454
466
|
async filterListPrompts(flowCtx) {
|
|
455
467
|
const { prompts } = flowCtx.state;
|
|
456
468
|
if (!prompts || prompts.length === 0) return;
|
|
457
|
-
const
|
|
469
|
+
const judged = this.judgedItems(prompts, (item) => ({
|
|
470
|
+
prompt: item.prompt
|
|
471
|
+
}));
|
|
472
|
+
const flaggedPrompts = this.collectFlagRefs(judged, (item) => item.prompt.metadata.featureFlag);
|
|
458
473
|
if (flaggedPrompts.size === 0) return;
|
|
459
474
|
const adapter = this.get(FeatureFlagAdapterToken);
|
|
460
475
|
const flagResults = await this.batchEvaluateRefs(adapter, flaggedPrompts);
|
|
461
476
|
const filtered = prompts.filter((item) => {
|
|
462
477
|
const ref = item.prompt.metadata?.featureFlag;
|
|
463
|
-
if (!ref) return true;
|
|
478
|
+
if (!ref || !judged.has(item)) return true;
|
|
464
479
|
return this.isRefEnabled(ref, flagResults);
|
|
465
480
|
});
|
|
466
481
|
flowCtx.state.set("prompts", filtered);
|
|
@@ -468,13 +483,14 @@ var FeatureFlagPlugin = class extends import_sdk2.DynamicPlugin {
|
|
|
468
483
|
async filterSkills(flowCtx) {
|
|
469
484
|
const { skills } = flowCtx.state;
|
|
470
485
|
if (!skills || skills.length === 0) return;
|
|
471
|
-
const
|
|
486
|
+
const judged = this.judgedItems(skills, (skill) => ({ skill }));
|
|
487
|
+
const flaggedSkills = this.collectFlagRefs(judged, (skill) => skill.metadata.featureFlag);
|
|
472
488
|
if (flaggedSkills.size === 0) return;
|
|
473
489
|
const adapter = this.get(FeatureFlagAdapterToken);
|
|
474
490
|
const flagResults = await this.batchEvaluateRefs(adapter, flaggedSkills);
|
|
475
491
|
const filtered = skills.filter((skill) => {
|
|
476
492
|
const ref = skill.metadata.featureFlag;
|
|
477
|
-
if (!ref) return true;
|
|
493
|
+
if (!ref || !judged.has(skill)) return true;
|
|
478
494
|
return this.isRefEnabled(ref, flagResults);
|
|
479
495
|
});
|
|
480
496
|
flowCtx.state.set("skills", filtered);
|
|
@@ -521,6 +537,26 @@ var FeatureFlagPlugin = class extends import_sdk2.DynamicPlugin {
|
|
|
521
537
|
// ─────────────────────────────────────────────────────────────────────────
|
|
522
538
|
// Private Helpers
|
|
523
539
|
// ─────────────────────────────────────────────────────────────────────────
|
|
540
|
+
/**
|
|
541
|
+
* The listed items this plugin instance judges: those whose gate (tools/call, resources/read,
|
|
542
|
+
* prompts/get, or `skills:filter` for skills) runs this instance's hook when the entry is served.
|
|
543
|
+
*
|
|
544
|
+
* List flows run every app's plugin over every app's entries, while a call is judged only by the
|
|
545
|
+
* plugin that covers the entry's app. With a FeatureFlagPlugin on each of two apps, filtering
|
|
546
|
+
* everything let one app's flags hide the other app's entries, which its own plugin still served
|
|
547
|
+
* by name. When the scope can't be read (a plugin used outside a server), every item is judged.
|
|
548
|
+
*/
|
|
549
|
+
judgedItems(items, entryOf) {
|
|
550
|
+
let scope;
|
|
551
|
+
try {
|
|
552
|
+
scope = this.get(import_sdk3.ScopeEntry);
|
|
553
|
+
} catch {
|
|
554
|
+
scope = void 0;
|
|
555
|
+
}
|
|
556
|
+
if (!scope?.hooks) return new Set(items);
|
|
557
|
+
const judgingScope = scope;
|
|
558
|
+
return new Set(items.filter((item) => (0, import_sdk3.isEntryGatedBy)(judgingScope, entryOf(item), this)));
|
|
559
|
+
}
|
|
524
560
|
/**
|
|
525
561
|
* Collect unique flag keys from items that have a featureFlag metadata.
|
|
526
562
|
*/
|
|
@@ -554,7 +590,7 @@ var FeatureFlagPlugin = class extends import_sdk2.DynamicPlugin {
|
|
|
554
590
|
*/
|
|
555
591
|
currentFlagContext() {
|
|
556
592
|
try {
|
|
557
|
-
const ctx = this.get(
|
|
593
|
+
const ctx = this.get(import_sdk3.FrontMcpContextStorage)?.getStore();
|
|
558
594
|
if (!ctx) return {};
|
|
559
595
|
return buildFeatureFlagContext(ctx, this.options);
|
|
560
596
|
} catch {
|
|
@@ -641,43 +677,45 @@ __publicField(FeatureFlagPlugin, "dynamicProviders", (options) => {
|
|
|
641
677
|
providers.push({
|
|
642
678
|
name: "feature-flags:accessor",
|
|
643
679
|
provide: FeatureFlagAccessorToken,
|
|
644
|
-
scope:
|
|
645
|
-
inject: () => [FeatureFlagAdapterToken,
|
|
680
|
+
scope: import_sdk3.ProviderScope.CONTEXT,
|
|
681
|
+
inject: () => [FeatureFlagAdapterToken, import_sdk3.FRONTMCP_CONTEXT, FeatureFlagConfigToken],
|
|
646
682
|
useFactory: (adapter, ctx, cfg) => createFeatureFlagAccessor(adapter, ctx, cfg)
|
|
647
683
|
});
|
|
648
684
|
return providers;
|
|
649
685
|
});
|
|
650
686
|
__decorateClass([
|
|
651
|
-
|
|
687
|
+
import_sdk3.ListToolsHook.Did("findTools", { priority: 50 })
|
|
652
688
|
], FeatureFlagPlugin.prototype, "filterListTools", 1);
|
|
653
689
|
__decorateClass([
|
|
654
|
-
|
|
690
|
+
import_sdk3.ListResourcesHook.Did("findResources", { priority: 50 })
|
|
655
691
|
], FeatureFlagPlugin.prototype, "filterListResources", 1);
|
|
656
692
|
__decorateClass([
|
|
657
|
-
|
|
693
|
+
import_sdk3.ListResourceTemplatesHook.Did("findTemplates", { priority: 50 })
|
|
658
694
|
], FeatureFlagPlugin.prototype, "filterListResourceTemplates", 1);
|
|
659
695
|
__decorateClass([
|
|
660
696
|
ListPromptsHook.Did("findPrompts", { priority: 50 })
|
|
661
697
|
], FeatureFlagPlugin.prototype, "filterListPrompts", 1);
|
|
662
698
|
__decorateClass([
|
|
663
|
-
FilterSkillsHook.Did("filterSkills", { priority: 50 })
|
|
699
|
+
FilterSkillsHook.Did("filterSkills", { priority: 50, appliesTo: "uncovered-apps" })
|
|
664
700
|
], FeatureFlagPlugin.prototype, "filterSkills", 1);
|
|
665
701
|
__decorateClass([
|
|
666
|
-
|
|
702
|
+
import_sdk3.ToolHook.Will("execute", { priority: 50, appliesTo: "uncovered-apps" })
|
|
667
703
|
], FeatureFlagPlugin.prototype, "gateToolExecution", 1);
|
|
668
704
|
__decorateClass([
|
|
669
|
-
ReadResourceHook.Will("execute", { priority: 50 })
|
|
705
|
+
ReadResourceHook.Will("execute", { priority: 50, appliesTo: "uncovered-apps" })
|
|
670
706
|
], FeatureFlagPlugin.prototype, "gateResourceRead", 1);
|
|
671
707
|
__decorateClass([
|
|
672
|
-
GetPromptHook.Will("execute", { priority: 50 })
|
|
708
|
+
GetPromptHook.Will("execute", { priority: 50, appliesTo: "uncovered-apps" })
|
|
673
709
|
], FeatureFlagPlugin.prototype, "gatePromptGet", 1);
|
|
674
710
|
__decorateClass([
|
|
675
|
-
CompleteHook.Will("complete", { priority: 50 })
|
|
711
|
+
CompleteHook.Will("complete", { priority: 50, appliesTo: "uncovered-apps" })
|
|
676
712
|
], FeatureFlagPlugin.prototype, "gateCompletion", 1);
|
|
677
713
|
FeatureFlagPlugin = __decorateClass([
|
|
678
|
-
(0,
|
|
714
|
+
(0, import_sdk3.Plugin)({
|
|
679
715
|
name: "feature-flags",
|
|
680
716
|
description: "Feature flag-based capability filtering for MCP",
|
|
717
|
+
// A server where an entry declares `featureFlag` and none of these gates reaches it refuses to start.
|
|
718
|
+
enforcesMetadata: ["featureFlag"],
|
|
681
719
|
providers: [],
|
|
682
720
|
contextExtensions: [
|
|
683
721
|
{
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@frontmcp/plugin-feature-flags",
|
|
3
|
-
"version": "1.8.
|
|
3
|
+
"version": "1.8.4",
|
|
4
4
|
"description": "Feature flag plugin for FrontMCP - dynamically gate MCP capabilities behind feature flags",
|
|
5
5
|
"author": "AgentFront <info@agentfront.dev>",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -49,7 +49,7 @@
|
|
|
49
49
|
}
|
|
50
50
|
},
|
|
51
51
|
"dependencies": {
|
|
52
|
-
"@frontmcp/sdk": "1.8.
|
|
52
|
+
"@frontmcp/sdk": "1.8.4"
|
|
53
53
|
},
|
|
54
54
|
"peerDependencies": {
|
|
55
55
|
"@launchdarkly/node-server-sdk": "^9.0.0 || ^10.0.0",
|