@frontmcp/plugin-feature-flags 1.7.2 → 1.8.1
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/adapters/feature-flag-adapter.interface.d.ts +11 -1
- package/adapters/feature-flag-adapter.interface.d.ts.map +1 -1
- package/adapters/static.adapter.d.ts +6 -1
- package/adapters/static.adapter.d.ts.map +1 -1
- package/esm/index.mjs +117 -38
- package/esm/package.json +2 -2
- package/feature-flag.context.d.ts +12 -0
- package/feature-flag.context.d.ts.map +1 -0
- package/feature-flag.plugin.d.ts +53 -4
- package/feature-flag.plugin.d.ts.map +1 -1
- package/feature-flag.types.d.ts +3 -0
- package/feature-flag.types.d.ts.map +1 -1
- package/index.js +114 -37
- package/package.json +2 -2
- package/providers/feature-flag-accessor.provider.d.ts +1 -1
- package/providers/feature-flag-accessor.provider.d.ts.map +1 -1
|
@@ -10,7 +10,17 @@ export interface FeatureFlagAdapter {
|
|
|
10
10
|
isEnabled(flagKey: string, context: FeatureFlagContext): Promise<boolean>;
|
|
11
11
|
/** Get the variant for a flag (for multi-variate flags). */
|
|
12
12
|
getVariant(flagKey: string, context: FeatureFlagContext): Promise<FeatureFlagVariant>;
|
|
13
|
-
/**
|
|
13
|
+
/**
|
|
14
|
+
* Batch evaluate multiple flags at once.
|
|
15
|
+
*
|
|
16
|
+
* An adapter MAY omit a key it has no opinion on — a flag it has never heard of. An absent
|
|
17
|
+
* key means "unknown", and the caller applies the ref's `defaultValue`; a key present with
|
|
18
|
+
* `false` means the flag is genuinely disabled and `defaultValue` does NOT apply. Conflating
|
|
19
|
+
* the two makes `defaultValue` dead code and makes listing disagree with the execution gate.
|
|
20
|
+
*
|
|
21
|
+
* Adapters backed by a remote service (Split.io, LaunchDarkly, Unleash) always have an
|
|
22
|
+
* opinion, because the service applies its own default — they answer for every key.
|
|
23
|
+
*/
|
|
14
24
|
evaluateFlags(flagKeys: string[], context: FeatureFlagContext): Promise<Map<string, boolean>>;
|
|
15
25
|
/** Destroy the adapter (disconnect, cleanup). */
|
|
16
26
|
destroy(): Promise<void>;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"feature-flag-adapter.interface.d.ts","sourceRoot":"","sources":["../../src/adapters/feature-flag-adapter.interface.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,kBAAkB,EAAE,kBAAkB,EAAE,MAAM,uBAAuB,CAAC;AAEpF;;;GAGG;AACH,MAAM,WAAW,kBAAkB;IACjC,yDAAyD;IACzD,UAAU,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;IAE5B,wDAAwD;IACxD,SAAS,CAAC,OAAO,EAAE,MAAM,EAAE,OAAO,EAAE,kBAAkB,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;IAE1E,4DAA4D;IAC5D,UAAU,CAAC,OAAO,EAAE,MAAM,EAAE,OAAO,EAAE,kBAAkB,GAAG,OAAO,CAAC,kBAAkB,CAAC,CAAC;IAEtF
|
|
1
|
+
{"version":3,"file":"feature-flag-adapter.interface.d.ts","sourceRoot":"","sources":["../../src/adapters/feature-flag-adapter.interface.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,kBAAkB,EAAE,kBAAkB,EAAE,MAAM,uBAAuB,CAAC;AAEpF;;;GAGG;AACH,MAAM,WAAW,kBAAkB;IACjC,yDAAyD;IACzD,UAAU,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;IAE5B,wDAAwD;IACxD,SAAS,CAAC,OAAO,EAAE,MAAM,EAAE,OAAO,EAAE,kBAAkB,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;IAE1E,4DAA4D;IAC5D,UAAU,CAAC,OAAO,EAAE,MAAM,EAAE,OAAO,EAAE,kBAAkB,GAAG,OAAO,CAAC,kBAAkB,CAAC,CAAC;IAEtF;;;;;;;;;;OAUG;IACH,aAAa,CAAC,QAAQ,EAAE,MAAM,EAAE,EAAE,OAAO,EAAE,kBAAkB,GAAG,OAAO,CAAC,GAAG,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAAC;IAE9F,iDAAiD;IACjD,OAAO,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;CAC1B"}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import type { FeatureFlagAdapter } from './feature-flag-adapter.interface';
|
|
2
1
|
import type { FeatureFlagContext, FeatureFlagVariant } from '../feature-flag.types';
|
|
2
|
+
import type { FeatureFlagAdapter } from './feature-flag-adapter.interface';
|
|
3
3
|
/**
|
|
4
4
|
* Static/in-memory feature flag adapter.
|
|
5
5
|
* Flags are defined at construction time and don't change.
|
|
@@ -10,6 +10,11 @@ export declare class StaticFeatureFlagAdapter implements FeatureFlagAdapter {
|
|
|
10
10
|
initialize(): Promise<void>;
|
|
11
11
|
isEnabled(flagKey: string, _context: FeatureFlagContext): Promise<boolean>;
|
|
12
12
|
getVariant(flagKey: string, _context: FeatureFlagContext): Promise<FeatureFlagVariant>;
|
|
13
|
+
/**
|
|
14
|
+
* A key this adapter has never been configured with is OMITTED rather than reported as
|
|
15
|
+
* `false`. Only then can the caller tell "the operator disabled it" from "nobody configured
|
|
16
|
+
* it" and apply the ref's `defaultValue` to the second case only.
|
|
17
|
+
*/
|
|
13
18
|
evaluateFlags(flagKeys: string[], context: FeatureFlagContext): Promise<Map<string, boolean>>;
|
|
14
19
|
destroy(): Promise<void>;
|
|
15
20
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"static.adapter.d.ts","sourceRoot":"","sources":["../../src/adapters/static.adapter.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,
|
|
1
|
+
{"version":3,"file":"static.adapter.d.ts","sourceRoot":"","sources":["../../src/adapters/static.adapter.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,kBAAkB,EAAE,kBAAkB,EAAE,MAAM,uBAAuB,CAAC;AACpF,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,kCAAkC,CAAC;AAE3E;;;GAGG;AACH,qBAAa,wBAAyB,YAAW,kBAAkB;IACjE,OAAO,CAAC,QAAQ,CAAC,KAAK,CAA+C;gBAEzD,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,GAAG,kBAAkB,CAAC;IAIzD,UAAU,IAAI,OAAO,CAAC,IAAI,CAAC;IAI3B,SAAS,CAAC,OAAO,EAAE,MAAM,EAAE,QAAQ,EAAE,kBAAkB,GAAG,OAAO,CAAC,OAAO,CAAC;IAO1E,UAAU,CAAC,OAAO,EAAE,MAAM,EAAE,QAAQ,EAAE,kBAAkB,GAAG,OAAO,CAAC,kBAAkB,CAAC;IAW5F;;;;OAIG;IACG,aAAa,CAAC,QAAQ,EAAE,MAAM,EAAE,EAAE,OAAO,EAAE,kBAAkB,GAAG,OAAO,CAAC,GAAG,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAS7F,OAAO,IAAI,OAAO,CAAC,IAAI,CAAC;CAG/B"}
|
package/esm/index.mjs
CHANGED
|
@@ -246,24 +246,15 @@ import {
|
|
|
246
246
|
DynamicPlugin,
|
|
247
247
|
FlowHooksOf,
|
|
248
248
|
FRONTMCP_CONTEXT,
|
|
249
|
-
|
|
249
|
+
FrontMcpContextStorage,
|
|
250
250
|
ListResourcesHook,
|
|
251
|
+
ListResourceTemplatesHook,
|
|
252
|
+
ListToolsHook,
|
|
251
253
|
Plugin,
|
|
252
254
|
ProviderScope as ProviderScope2,
|
|
253
255
|
ToolHook
|
|
254
256
|
} from "@frontmcp/sdk";
|
|
255
257
|
|
|
256
|
-
// plugins/plugin-feature-flags/src/feature-flag.symbols.ts
|
|
257
|
-
var FeatureFlagAdapterToken = /* @__PURE__ */ Symbol(
|
|
258
|
-
"plugin:feature-flags:adapter"
|
|
259
|
-
);
|
|
260
|
-
var FeatureFlagConfigToken = /* @__PURE__ */ Symbol(
|
|
261
|
-
"plugin:feature-flags:config"
|
|
262
|
-
);
|
|
263
|
-
var FeatureFlagAccessorToken = /* @__PURE__ */ Symbol(
|
|
264
|
-
"plugin:feature-flags:accessor"
|
|
265
|
-
);
|
|
266
|
-
|
|
267
258
|
// plugins/plugin-feature-flags/src/adapters/static.adapter.ts
|
|
268
259
|
var StaticFeatureFlagAdapter = class {
|
|
269
260
|
flags;
|
|
@@ -288,9 +279,15 @@ var StaticFeatureFlagAdapter = class {
|
|
|
288
279
|
}
|
|
289
280
|
return { ...flag };
|
|
290
281
|
}
|
|
282
|
+
/**
|
|
283
|
+
* A key this adapter has never been configured with is OMITTED rather than reported as
|
|
284
|
+
* `false`. Only then can the caller tell "the operator disabled it" from "nobody configured
|
|
285
|
+
* it" and apply the ref's `defaultValue` to the second case only.
|
|
286
|
+
*/
|
|
291
287
|
async evaluateFlags(flagKeys, context) {
|
|
292
288
|
const results = /* @__PURE__ */ new Map();
|
|
293
289
|
for (const key of flagKeys) {
|
|
290
|
+
if (this.flags[key] === void 0) continue;
|
|
294
291
|
results.set(key, await this.isEnabled(key, context));
|
|
295
292
|
}
|
|
296
293
|
return results;
|
|
@@ -299,6 +296,28 @@ var StaticFeatureFlagAdapter = class {
|
|
|
299
296
|
}
|
|
300
297
|
};
|
|
301
298
|
|
|
299
|
+
// plugins/plugin-feature-flags/src/feature-flag.context.ts
|
|
300
|
+
function buildFeatureFlagContext(ctx, config) {
|
|
301
|
+
if (!ctx) return {};
|
|
302
|
+
const userId = config.userIdResolver ? config.userIdResolver(ctx) : ctx.authInfo?.extra?.["sub"] ?? ctx.authInfo?.extra?.["userId"] ?? ctx.authInfo?.clientId;
|
|
303
|
+
return {
|
|
304
|
+
userId: userId ?? void 0,
|
|
305
|
+
sessionId: ctx.sessionId,
|
|
306
|
+
attributes: config.attributesResolver ? config.attributesResolver(ctx) : {}
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// plugins/plugin-feature-flags/src/feature-flag.symbols.ts
|
|
311
|
+
var FeatureFlagAdapterToken = /* @__PURE__ */ Symbol(
|
|
312
|
+
"plugin:feature-flags:adapter"
|
|
313
|
+
);
|
|
314
|
+
var FeatureFlagConfigToken = /* @__PURE__ */ Symbol(
|
|
315
|
+
"plugin:feature-flags:config"
|
|
316
|
+
);
|
|
317
|
+
var FeatureFlagAccessorToken = /* @__PURE__ */ Symbol(
|
|
318
|
+
"plugin:feature-flags:accessor"
|
|
319
|
+
);
|
|
320
|
+
|
|
302
321
|
// plugins/plugin-feature-flags/src/providers/feature-flag-accessor.provider.ts
|
|
303
322
|
import { Provider, ProviderScope } from "@frontmcp/sdk";
|
|
304
323
|
var FeatureFlagAccessor = class {
|
|
@@ -362,13 +381,7 @@ var FeatureFlagAccessor = class {
|
|
|
362
381
|
* Build the FeatureFlagContext from the current FrontMcpContext.
|
|
363
382
|
*/
|
|
364
383
|
buildContext() {
|
|
365
|
-
|
|
366
|
-
const attributes = this.config.attributesResolver ? this.config.attributesResolver(this.ctx) : {};
|
|
367
|
-
return {
|
|
368
|
-
userId: userId ?? void 0,
|
|
369
|
-
sessionId: this.ctx.sessionId,
|
|
370
|
-
attributes
|
|
371
|
-
};
|
|
384
|
+
return buildFeatureFlagContext(this.ctx, this.config);
|
|
372
385
|
}
|
|
373
386
|
};
|
|
374
387
|
FeatureFlagAccessor = __decorateClass([
|
|
@@ -384,7 +397,10 @@ function createFeatureFlagAccessor(adapter, ctx, config) {
|
|
|
384
397
|
|
|
385
398
|
// plugins/plugin-feature-flags/src/feature-flag.plugin.ts
|
|
386
399
|
var ListPromptsHook = FlowHooksOf("prompts:list-prompts");
|
|
387
|
-
var
|
|
400
|
+
var ReadResourceHook = FlowHooksOf("resources:read-resource");
|
|
401
|
+
var GetPromptHook = FlowHooksOf("prompts:get-prompt");
|
|
402
|
+
var CompleteHook = FlowHooksOf("completion:complete");
|
|
403
|
+
var FilterSkillsHook = FlowHooksOf("skills:filter");
|
|
388
404
|
var FeatureFlagPlugin = class extends DynamicPlugin {
|
|
389
405
|
options;
|
|
390
406
|
constructor(options) {
|
|
@@ -419,6 +435,20 @@ var FeatureFlagPlugin = class extends DynamicPlugin {
|
|
|
419
435
|
});
|
|
420
436
|
flowCtx.state.set("resources", filtered);
|
|
421
437
|
}
|
|
438
|
+
async filterListResourceTemplates(flowCtx) {
|
|
439
|
+
const { templates } = flowCtx.state;
|
|
440
|
+
if (!templates || templates.length === 0) return;
|
|
441
|
+
const flaggedTemplates = this.collectFlagRefs(templates, (item) => item.template.metadata.featureFlag);
|
|
442
|
+
if (flaggedTemplates.size === 0) return;
|
|
443
|
+
const adapter = this.get(FeatureFlagAdapterToken);
|
|
444
|
+
const flagResults = await this.batchEvaluateRefs(adapter, flaggedTemplates);
|
|
445
|
+
const filtered = templates.filter((item) => {
|
|
446
|
+
const ref = item.template.metadata.featureFlag;
|
|
447
|
+
if (!ref) return true;
|
|
448
|
+
return this.isRefEnabled(ref, flagResults);
|
|
449
|
+
});
|
|
450
|
+
flowCtx.state.set("templates", filtered);
|
|
451
|
+
}
|
|
422
452
|
async filterListPrompts(flowCtx) {
|
|
423
453
|
const { prompts } = flowCtx.state;
|
|
424
454
|
if (!prompts || prompts.length === 0) return;
|
|
@@ -433,36 +463,57 @@ var FeatureFlagPlugin = class extends DynamicPlugin {
|
|
|
433
463
|
});
|
|
434
464
|
flowCtx.state.set("prompts", filtered);
|
|
435
465
|
}
|
|
436
|
-
async
|
|
437
|
-
const {
|
|
438
|
-
if (!
|
|
439
|
-
const flaggedSkills = this.collectFlagRefs(
|
|
466
|
+
async filterSkills(flowCtx) {
|
|
467
|
+
const { skills } = flowCtx.state;
|
|
468
|
+
if (!skills || skills.length === 0) return;
|
|
469
|
+
const flaggedSkills = this.collectFlagRefs(skills, (skill) => skill.metadata.featureFlag);
|
|
440
470
|
if (flaggedSkills.size === 0) return;
|
|
441
471
|
const adapter = this.get(FeatureFlagAdapterToken);
|
|
442
472
|
const flagResults = await this.batchEvaluateRefs(adapter, flaggedSkills);
|
|
443
|
-
const filtered =
|
|
444
|
-
const ref =
|
|
473
|
+
const filtered = skills.filter((skill) => {
|
|
474
|
+
const ref = skill.metadata.featureFlag;
|
|
445
475
|
if (!ref) return true;
|
|
446
476
|
return this.isRefEnabled(ref, flagResults);
|
|
447
477
|
});
|
|
448
|
-
flowCtx.state.set("
|
|
478
|
+
flowCtx.state.set("skills", filtered);
|
|
449
479
|
}
|
|
450
480
|
async gateToolExecution(flowCtx) {
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
481
|
+
await this.gateEntryExecution("Tool", flowCtx.state.tool);
|
|
482
|
+
}
|
|
483
|
+
async gateResourceRead(flowCtx) {
|
|
484
|
+
await this.gateEntryExecution("Resource", flowCtx.state.resource);
|
|
485
|
+
}
|
|
486
|
+
async gatePromptGet(flowCtx) {
|
|
487
|
+
await this.gateEntryExecution("Prompt", flowCtx.state.prompt);
|
|
488
|
+
}
|
|
489
|
+
async gateCompletion(flowCtx) {
|
|
490
|
+
const { prompt, resource } = flowCtx.state;
|
|
491
|
+
if (prompt) await this.gateEntryExecution("Prompt", prompt);
|
|
492
|
+
if (resource) await this.gateEntryExecution("Resource", resource);
|
|
493
|
+
}
|
|
494
|
+
/**
|
|
495
|
+
* Shared execution gate for tools, resources and prompts.
|
|
496
|
+
*
|
|
497
|
+
* One implementation on purpose: the advisory existed because the tool path had a gate and
|
|
498
|
+
* the other two did not, and three copies would drift apart the same way.
|
|
499
|
+
*/
|
|
500
|
+
async gateEntryExecution(kind, entry) {
|
|
501
|
+
if (!entry) return;
|
|
502
|
+
const metadata = entry.metadata;
|
|
503
|
+
const ref = metadata?.featureFlag;
|
|
454
504
|
if (!ref) return;
|
|
455
|
-
const adapter = this.get(FeatureFlagAdapterToken);
|
|
456
505
|
const key = typeof ref === "string" ? ref : ref.key;
|
|
457
506
|
const defaultValue = typeof ref === "object" ? ref.defaultValue ?? false : false;
|
|
458
507
|
let enabled;
|
|
459
508
|
try {
|
|
460
|
-
|
|
509
|
+
const adapter = this.get(FeatureFlagAdapterToken);
|
|
510
|
+
const results = await adapter.evaluateFlags([key], this.currentFlagContext());
|
|
511
|
+
enabled = this.isRefEnabled(ref, results);
|
|
461
512
|
} catch {
|
|
462
513
|
enabled = defaultValue;
|
|
463
514
|
}
|
|
464
515
|
if (!enabled) {
|
|
465
|
-
throw new Error(
|
|
516
|
+
throw new Error(`${kind} "${metadata?.name}" is disabled by feature flag "${key}"`);
|
|
466
517
|
}
|
|
467
518
|
}
|
|
468
519
|
// ─────────────────────────────────────────────────────────────────────────
|
|
@@ -489,7 +540,24 @@ var FeatureFlagPlugin = class extends DynamicPlugin {
|
|
|
489
540
|
*/
|
|
490
541
|
async batchEvaluateRefs(adapter, refs) {
|
|
491
542
|
const keys = Array.from(refs.keys());
|
|
492
|
-
return adapter.evaluateFlags(keys,
|
|
543
|
+
return adapter.evaluateFlags(keys, this.currentFlagContext());
|
|
544
|
+
}
|
|
545
|
+
/**
|
|
546
|
+
* The caller's evaluation context.
|
|
547
|
+
*
|
|
548
|
+
* Passing `{}` here asked the adapter "is this flag on for nobody in particular", which a
|
|
549
|
+
* targeted adapter can answer differently from "is it on for THIS caller" — enabling access
|
|
550
|
+
* the caller should not have. The context comes from the same `FrontMcpContext` the
|
|
551
|
+
* context-scoped accessor reads, so hooks and `this.featureFlags` agree.
|
|
552
|
+
*/
|
|
553
|
+
currentFlagContext() {
|
|
554
|
+
try {
|
|
555
|
+
const ctx = this.get(FrontMcpContextStorage)?.getStore();
|
|
556
|
+
if (!ctx) return {};
|
|
557
|
+
return buildFeatureFlagContext(ctx, this.options);
|
|
558
|
+
} catch {
|
|
559
|
+
return {};
|
|
560
|
+
}
|
|
493
561
|
}
|
|
494
562
|
/**
|
|
495
563
|
* Determine if a feature flag ref is enabled given adapter results.
|
|
@@ -498,9 +566,8 @@ var FeatureFlagPlugin = class extends DynamicPlugin {
|
|
|
498
566
|
*/
|
|
499
567
|
isRefEnabled(ref, flagResults) {
|
|
500
568
|
const key = typeof ref === "string" ? ref : ref.key;
|
|
501
|
-
const adapterResult = flagResults.get(key);
|
|
502
569
|
const defaultValue = typeof ref === "object" ? ref.defaultValue ?? false : false;
|
|
503
|
-
if (
|
|
570
|
+
if (flagResults.has(key)) return flagResults.get(key) === true;
|
|
504
571
|
return defaultValue;
|
|
505
572
|
}
|
|
506
573
|
};
|
|
@@ -584,15 +651,27 @@ __decorateClass([
|
|
|
584
651
|
__decorateClass([
|
|
585
652
|
ListResourcesHook.Did("findResources", { priority: 50 })
|
|
586
653
|
], FeatureFlagPlugin.prototype, "filterListResources", 1);
|
|
654
|
+
__decorateClass([
|
|
655
|
+
ListResourceTemplatesHook.Did("findTemplates", { priority: 50 })
|
|
656
|
+
], FeatureFlagPlugin.prototype, "filterListResourceTemplates", 1);
|
|
587
657
|
__decorateClass([
|
|
588
658
|
ListPromptsHook.Did("findPrompts", { priority: 50 })
|
|
589
659
|
], FeatureFlagPlugin.prototype, "filterListPrompts", 1);
|
|
590
660
|
__decorateClass([
|
|
591
|
-
|
|
592
|
-
], FeatureFlagPlugin.prototype, "
|
|
661
|
+
FilterSkillsHook.Did("filterSkills", { priority: 50 })
|
|
662
|
+
], FeatureFlagPlugin.prototype, "filterSkills", 1);
|
|
593
663
|
__decorateClass([
|
|
594
664
|
ToolHook.Will("execute", { priority: 50 })
|
|
595
665
|
], FeatureFlagPlugin.prototype, "gateToolExecution", 1);
|
|
666
|
+
__decorateClass([
|
|
667
|
+
ReadResourceHook.Will("execute", { priority: 50 })
|
|
668
|
+
], FeatureFlagPlugin.prototype, "gateResourceRead", 1);
|
|
669
|
+
__decorateClass([
|
|
670
|
+
GetPromptHook.Will("execute", { priority: 50 })
|
|
671
|
+
], FeatureFlagPlugin.prototype, "gatePromptGet", 1);
|
|
672
|
+
__decorateClass([
|
|
673
|
+
CompleteHook.Will("complete", { priority: 50 })
|
|
674
|
+
], FeatureFlagPlugin.prototype, "gateCompletion", 1);
|
|
596
675
|
FeatureFlagPlugin = __decorateClass([
|
|
597
676
|
Plugin({
|
|
598
677
|
name: "feature-flags",
|
package/esm/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@frontmcp/plugin-feature-flags",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.8.1",
|
|
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.
|
|
52
|
+
"@frontmcp/sdk": "1.8.1"
|
|
53
53
|
},
|
|
54
54
|
"peerDependencies": {
|
|
55
55
|
"@launchdarkly/node-server-sdk": "^9.0.0 || ^10.0.0",
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { FrontMcpContext } from '@frontmcp/sdk';
|
|
2
|
+
import type { FeatureFlagContext, FeatureFlagPluginOptions } from './feature-flag.types';
|
|
3
|
+
/**
|
|
4
|
+
* Build the adapter evaluation context for the current caller.
|
|
5
|
+
*
|
|
6
|
+
* Shared by the context-scoped accessor and by the plugin's list/gate hooks. Those hooks used
|
|
7
|
+
* to pass `{}`, which asks a targeted adapter "is this flag on for nobody in particular" — a
|
|
8
|
+
* question it can answer differently from "is it on for THIS caller", letting a gate allow
|
|
9
|
+
* access the caller should not have.
|
|
10
|
+
*/
|
|
11
|
+
export declare function buildFeatureFlagContext(ctx: FrontMcpContext | undefined, config: Pick<FeatureFlagPluginOptions, 'userIdResolver' | 'attributesResolver'>): FeatureFlagContext;
|
|
12
|
+
//# sourceMappingURL=feature-flag.context.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"feature-flag.context.d.ts","sourceRoot":"","sources":["../src/feature-flag.context.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,eAAe,CAAC;AAErD,OAAO,KAAK,EAAE,kBAAkB,EAAE,wBAAwB,EAAE,MAAM,sBAAsB,CAAC;AAEzF;;;;;;;GAOG;AACH,wBAAgB,uBAAuB,CACrC,GAAG,EAAE,eAAe,GAAG,SAAS,EAChC,MAAM,EAAE,IAAI,CAAC,wBAAwB,EAAE,gBAAgB,GAAG,oBAAoB,CAAC,GAC9E,kBAAkB,CAcpB"}
|
package/feature-flag.plugin.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { DynamicPlugin, FlowCtxOf, ProviderType } from '@frontmcp/sdk';
|
|
1
|
+
import { DynamicPlugin, type FlowCtxOf, type ProviderType } from '@frontmcp/sdk';
|
|
2
2
|
import type { FeatureFlagPluginOptions, FeatureFlagPluginOptionsInput } from './feature-flag.types';
|
|
3
3
|
/**
|
|
4
4
|
* FeatureFlagPlugin - Dynamic capability gating for FrontMCP.
|
|
@@ -35,20 +35,60 @@ export default class FeatureFlagPlugin extends DynamicPlugin<FeatureFlagPluginOp
|
|
|
35
35
|
* Filter resources from list_resources based on feature flags.
|
|
36
36
|
*/
|
|
37
37
|
filterListResources(flowCtx: FlowCtxOf<'resources:list-resources'>): Promise<void>;
|
|
38
|
+
/**
|
|
39
|
+
* Filter resource templates from resources/templates/list based on feature flags.
|
|
40
|
+
*
|
|
41
|
+
* GHSA-gf7p-j3hr-h5h4: templates were listed even when their flag was off.
|
|
42
|
+
*/
|
|
43
|
+
filterListResourceTemplates(flowCtx: FlowCtxOf<'resources:list-resource-templates'>): Promise<void>;
|
|
38
44
|
/**
|
|
39
45
|
* Filter prompts from list_prompts based on feature flags.
|
|
40
46
|
*/
|
|
41
47
|
filterListPrompts(flowCtx: any): Promise<void>;
|
|
42
48
|
/**
|
|
43
|
-
* Filter skills
|
|
44
|
-
*
|
|
49
|
+
* Filter skills in the `skills:filter` flow, which every skill surface runs: MCP
|
|
50
|
+
* `skills/search`, `skills/list` and `skills/load`, SEP-2640 `skill://` resources, and the HTTP
|
|
51
|
+
* `/skills`, `/llm.txt` and `/llm_full.txt` endpoints. A skill removed here is absent from
|
|
52
|
+
* listings and not found when named.
|
|
53
|
+
*
|
|
54
|
+
* GHSA-gf7p-j3hr-h5h4: the only skill hook sat on `skills:search`, which the `skills/search`
|
|
55
|
+
* handler never ran and whose results carry no `featureFlag`, so no skill surface was gated.
|
|
45
56
|
*/
|
|
46
|
-
|
|
57
|
+
filterSkills(flowCtx: FlowCtxOf<'skills:filter'>): Promise<void>;
|
|
47
58
|
/**
|
|
48
59
|
* Execution gate: block direct tool/call when the tool's feature flag is off.
|
|
49
60
|
* This prevents bypassing the list filter via direct tool invocation.
|
|
50
61
|
*/
|
|
51
62
|
gateToolExecution(flowCtx: FlowCtxOf<'tools:call-tool'>): Promise<void>;
|
|
63
|
+
/**
|
|
64
|
+
* Execution gate: block resources/read when the resource's feature flag is off.
|
|
65
|
+
*
|
|
66
|
+
* GHSA-gf7p-j3hr-h5h4: only tools had this gate, so a flagged resource was merely absent
|
|
67
|
+
* from resources/list and still readable by URI. Hiding a capability from a listing is not
|
|
68
|
+
* the same as withholding it — clients cache listings and hold URIs from earlier sessions.
|
|
69
|
+
*/
|
|
70
|
+
gateResourceRead(flowCtx: any): Promise<void>;
|
|
71
|
+
/**
|
|
72
|
+
* Execution gate: block prompts/get when the prompt's feature flag is off.
|
|
73
|
+
*
|
|
74
|
+
* The same gap as resources (GHSA-gf7p-j3hr-h5h4): filtering prompts/list left the prompt
|
|
75
|
+
* retrievable by name.
|
|
76
|
+
*/
|
|
77
|
+
gatePromptGet(flowCtx: any): Promise<void>;
|
|
78
|
+
/**
|
|
79
|
+
* Execution gate: block completion/complete for a prompt or resource whose feature flag is off.
|
|
80
|
+
*
|
|
81
|
+
* GHSA-gf7p-j3hr-h5h4: completion looked the entry up and ran its argument completers without
|
|
82
|
+
* a gate, so a disabled resource template still suggested its values.
|
|
83
|
+
*/
|
|
84
|
+
gateCompletion(flowCtx: any): Promise<void>;
|
|
85
|
+
/**
|
|
86
|
+
* Shared execution gate for tools, resources and prompts.
|
|
87
|
+
*
|
|
88
|
+
* One implementation on purpose: the advisory existed because the tool path had a gate and
|
|
89
|
+
* the other two did not, and three copies would drift apart the same way.
|
|
90
|
+
*/
|
|
91
|
+
private gateEntryExecution;
|
|
52
92
|
/**
|
|
53
93
|
* Collect unique flag keys from items that have a featureFlag metadata.
|
|
54
94
|
*/
|
|
@@ -57,6 +97,15 @@ export default class FeatureFlagPlugin extends DynamicPlugin<FeatureFlagPluginOp
|
|
|
57
97
|
* Batch evaluate all collected flag refs via the adapter.
|
|
58
98
|
*/
|
|
59
99
|
private batchEvaluateRefs;
|
|
100
|
+
/**
|
|
101
|
+
* The caller's evaluation context.
|
|
102
|
+
*
|
|
103
|
+
* Passing `{}` here asked the adapter "is this flag on for nobody in particular", which a
|
|
104
|
+
* targeted adapter can answer differently from "is it on for THIS caller" — enabling access
|
|
105
|
+
* the caller should not have. The context comes from the same `FrontMcpContext` the
|
|
106
|
+
* context-scoped accessor reads, so hooks and `this.featureFlags` agree.
|
|
107
|
+
*/
|
|
108
|
+
private currentFlagContext;
|
|
60
109
|
/**
|
|
61
110
|
* Determine if a feature flag ref is enabled given adapter results.
|
|
62
111
|
* For object-style refs, `defaultValue` acts as a fallback when the adapter
|
|
@@ -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,EAUb,KAAK,SAAS,EACd,KAAK,YAAY,EAClB,MAAM,eAAe,CAAC;AAMvB,OAAO,KAAK,EAEV,wBAAwB,EACxB,6BAA6B,EAE9B,MAAM,sBAAsB,CAAC;AAa9B;;;;;;;;;;;;;;;;;;GAkBG;AAaH,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;IAmB5D;;OAEG;IAEG,mBAAmB,CAAC,OAAO,EAAE,SAAS,CAAC,0BAA0B,CAAC;IAmBxE;;;;OAIG;IAEG,2BAA2B,CAAC,OAAO,EAAE,SAAS,CAAC,mCAAmC,CAAC;IAmBzF;;OAEG;IAEG,iBAAiB,CAAC,OAAO,EAAE,GAAG;IAmBpC;;;;;;;;OAQG;IAEG,YAAY,CAAC,OAAO,EAAE,SAAS,CAAC,eAAe,CAAC;IAmBtD;;;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;;OAEG;IACH,OAAO,CAAC,eAAe;IAcvB;;OAEG;YACW,iBAAiB;IAQ/B;;;;;;;OAOG;IACH,OAAO,CAAC,kBAAkB;IAW1B;;;;OAIG;IACH,OAAO,CAAC,YAAY;CAWrB"}
|
package/feature-flag.types.d.ts
CHANGED
|
@@ -77,6 +77,9 @@ declare global {
|
|
|
77
77
|
interface ExtendFrontMcpResourceMetadata {
|
|
78
78
|
featureFlag?: FeatureFlagRef;
|
|
79
79
|
}
|
|
80
|
+
interface ExtendFrontMcpResourceTemplateMetadata {
|
|
81
|
+
featureFlag?: FeatureFlagRef;
|
|
82
|
+
}
|
|
80
83
|
interface ExtendFrontMcpPromptMetadata {
|
|
81
84
|
featureFlag?: FeatureFlagRef;
|
|
82
85
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"feature-flag.types.d.ts","sourceRoot":"","sources":["../src/feature-flag.types.ts"],"names":[],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"feature-flag.types.d.ts","sourceRoot":"","sources":["../src/feature-flag.types.ts"],"names":[],"mappings":"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"}
|
package/index.js
CHANGED
|
@@ -257,17 +257,6 @@ module.exports = __toCommonJS(index_exports);
|
|
|
257
257
|
// plugins/plugin-feature-flags/src/feature-flag.plugin.ts
|
|
258
258
|
var import_sdk2 = require("@frontmcp/sdk");
|
|
259
259
|
|
|
260
|
-
// plugins/plugin-feature-flags/src/feature-flag.symbols.ts
|
|
261
|
-
var FeatureFlagAdapterToken = /* @__PURE__ */ Symbol(
|
|
262
|
-
"plugin:feature-flags:adapter"
|
|
263
|
-
);
|
|
264
|
-
var FeatureFlagConfigToken = /* @__PURE__ */ Symbol(
|
|
265
|
-
"plugin:feature-flags:config"
|
|
266
|
-
);
|
|
267
|
-
var FeatureFlagAccessorToken = /* @__PURE__ */ Symbol(
|
|
268
|
-
"plugin:feature-flags:accessor"
|
|
269
|
-
);
|
|
270
|
-
|
|
271
260
|
// plugins/plugin-feature-flags/src/adapters/static.adapter.ts
|
|
272
261
|
var StaticFeatureFlagAdapter = class {
|
|
273
262
|
flags;
|
|
@@ -292,9 +281,15 @@ var StaticFeatureFlagAdapter = class {
|
|
|
292
281
|
}
|
|
293
282
|
return { ...flag };
|
|
294
283
|
}
|
|
284
|
+
/**
|
|
285
|
+
* A key this adapter has never been configured with is OMITTED rather than reported as
|
|
286
|
+
* `false`. Only then can the caller tell "the operator disabled it" from "nobody configured
|
|
287
|
+
* it" and apply the ref's `defaultValue` to the second case only.
|
|
288
|
+
*/
|
|
295
289
|
async evaluateFlags(flagKeys, context) {
|
|
296
290
|
const results = /* @__PURE__ */ new Map();
|
|
297
291
|
for (const key of flagKeys) {
|
|
292
|
+
if (this.flags[key] === void 0) continue;
|
|
298
293
|
results.set(key, await this.isEnabled(key, context));
|
|
299
294
|
}
|
|
300
295
|
return results;
|
|
@@ -303,6 +298,28 @@ var StaticFeatureFlagAdapter = class {
|
|
|
303
298
|
}
|
|
304
299
|
};
|
|
305
300
|
|
|
301
|
+
// plugins/plugin-feature-flags/src/feature-flag.context.ts
|
|
302
|
+
function buildFeatureFlagContext(ctx, config) {
|
|
303
|
+
if (!ctx) return {};
|
|
304
|
+
const userId = config.userIdResolver ? config.userIdResolver(ctx) : ctx.authInfo?.extra?.["sub"] ?? ctx.authInfo?.extra?.["userId"] ?? ctx.authInfo?.clientId;
|
|
305
|
+
return {
|
|
306
|
+
userId: userId ?? void 0,
|
|
307
|
+
sessionId: ctx.sessionId,
|
|
308
|
+
attributes: config.attributesResolver ? config.attributesResolver(ctx) : {}
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// plugins/plugin-feature-flags/src/feature-flag.symbols.ts
|
|
313
|
+
var FeatureFlagAdapterToken = /* @__PURE__ */ Symbol(
|
|
314
|
+
"plugin:feature-flags:adapter"
|
|
315
|
+
);
|
|
316
|
+
var FeatureFlagConfigToken = /* @__PURE__ */ Symbol(
|
|
317
|
+
"plugin:feature-flags:config"
|
|
318
|
+
);
|
|
319
|
+
var FeatureFlagAccessorToken = /* @__PURE__ */ Symbol(
|
|
320
|
+
"plugin:feature-flags:accessor"
|
|
321
|
+
);
|
|
322
|
+
|
|
306
323
|
// plugins/plugin-feature-flags/src/providers/feature-flag-accessor.provider.ts
|
|
307
324
|
var import_sdk = require("@frontmcp/sdk");
|
|
308
325
|
var FeatureFlagAccessor = class {
|
|
@@ -366,13 +383,7 @@ var FeatureFlagAccessor = class {
|
|
|
366
383
|
* Build the FeatureFlagContext from the current FrontMcpContext.
|
|
367
384
|
*/
|
|
368
385
|
buildContext() {
|
|
369
|
-
|
|
370
|
-
const attributes = this.config.attributesResolver ? this.config.attributesResolver(this.ctx) : {};
|
|
371
|
-
return {
|
|
372
|
-
userId: userId ?? void 0,
|
|
373
|
-
sessionId: this.ctx.sessionId,
|
|
374
|
-
attributes
|
|
375
|
-
};
|
|
386
|
+
return buildFeatureFlagContext(this.ctx, this.config);
|
|
376
387
|
}
|
|
377
388
|
};
|
|
378
389
|
FeatureFlagAccessor = __decorateClass([
|
|
@@ -388,7 +399,10 @@ function createFeatureFlagAccessor(adapter, ctx, config) {
|
|
|
388
399
|
|
|
389
400
|
// plugins/plugin-feature-flags/src/feature-flag.plugin.ts
|
|
390
401
|
var ListPromptsHook = (0, import_sdk2.FlowHooksOf)("prompts:list-prompts");
|
|
391
|
-
var
|
|
402
|
+
var ReadResourceHook = (0, import_sdk2.FlowHooksOf)("resources:read-resource");
|
|
403
|
+
var GetPromptHook = (0, import_sdk2.FlowHooksOf)("prompts:get-prompt");
|
|
404
|
+
var CompleteHook = (0, import_sdk2.FlowHooksOf)("completion:complete");
|
|
405
|
+
var FilterSkillsHook = (0, import_sdk2.FlowHooksOf)("skills:filter");
|
|
392
406
|
var FeatureFlagPlugin = class extends import_sdk2.DynamicPlugin {
|
|
393
407
|
options;
|
|
394
408
|
constructor(options) {
|
|
@@ -423,6 +437,20 @@ var FeatureFlagPlugin = class extends import_sdk2.DynamicPlugin {
|
|
|
423
437
|
});
|
|
424
438
|
flowCtx.state.set("resources", filtered);
|
|
425
439
|
}
|
|
440
|
+
async filterListResourceTemplates(flowCtx) {
|
|
441
|
+
const { templates } = flowCtx.state;
|
|
442
|
+
if (!templates || templates.length === 0) return;
|
|
443
|
+
const flaggedTemplates = this.collectFlagRefs(templates, (item) => item.template.metadata.featureFlag);
|
|
444
|
+
if (flaggedTemplates.size === 0) return;
|
|
445
|
+
const adapter = this.get(FeatureFlagAdapterToken);
|
|
446
|
+
const flagResults = await this.batchEvaluateRefs(adapter, flaggedTemplates);
|
|
447
|
+
const filtered = templates.filter((item) => {
|
|
448
|
+
const ref = item.template.metadata.featureFlag;
|
|
449
|
+
if (!ref) return true;
|
|
450
|
+
return this.isRefEnabled(ref, flagResults);
|
|
451
|
+
});
|
|
452
|
+
flowCtx.state.set("templates", filtered);
|
|
453
|
+
}
|
|
426
454
|
async filterListPrompts(flowCtx) {
|
|
427
455
|
const { prompts } = flowCtx.state;
|
|
428
456
|
if (!prompts || prompts.length === 0) return;
|
|
@@ -437,36 +465,57 @@ var FeatureFlagPlugin = class extends import_sdk2.DynamicPlugin {
|
|
|
437
465
|
});
|
|
438
466
|
flowCtx.state.set("prompts", filtered);
|
|
439
467
|
}
|
|
440
|
-
async
|
|
441
|
-
const {
|
|
442
|
-
if (!
|
|
443
|
-
const flaggedSkills = this.collectFlagRefs(
|
|
468
|
+
async filterSkills(flowCtx) {
|
|
469
|
+
const { skills } = flowCtx.state;
|
|
470
|
+
if (!skills || skills.length === 0) return;
|
|
471
|
+
const flaggedSkills = this.collectFlagRefs(skills, (skill) => skill.metadata.featureFlag);
|
|
444
472
|
if (flaggedSkills.size === 0) return;
|
|
445
473
|
const adapter = this.get(FeatureFlagAdapterToken);
|
|
446
474
|
const flagResults = await this.batchEvaluateRefs(adapter, flaggedSkills);
|
|
447
|
-
const filtered =
|
|
448
|
-
const ref =
|
|
475
|
+
const filtered = skills.filter((skill) => {
|
|
476
|
+
const ref = skill.metadata.featureFlag;
|
|
449
477
|
if (!ref) return true;
|
|
450
478
|
return this.isRefEnabled(ref, flagResults);
|
|
451
479
|
});
|
|
452
|
-
flowCtx.state.set("
|
|
480
|
+
flowCtx.state.set("skills", filtered);
|
|
453
481
|
}
|
|
454
482
|
async gateToolExecution(flowCtx) {
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
483
|
+
await this.gateEntryExecution("Tool", flowCtx.state.tool);
|
|
484
|
+
}
|
|
485
|
+
async gateResourceRead(flowCtx) {
|
|
486
|
+
await this.gateEntryExecution("Resource", flowCtx.state.resource);
|
|
487
|
+
}
|
|
488
|
+
async gatePromptGet(flowCtx) {
|
|
489
|
+
await this.gateEntryExecution("Prompt", flowCtx.state.prompt);
|
|
490
|
+
}
|
|
491
|
+
async gateCompletion(flowCtx) {
|
|
492
|
+
const { prompt, resource } = flowCtx.state;
|
|
493
|
+
if (prompt) await this.gateEntryExecution("Prompt", prompt);
|
|
494
|
+
if (resource) await this.gateEntryExecution("Resource", resource);
|
|
495
|
+
}
|
|
496
|
+
/**
|
|
497
|
+
* Shared execution gate for tools, resources and prompts.
|
|
498
|
+
*
|
|
499
|
+
* One implementation on purpose: the advisory existed because the tool path had a gate and
|
|
500
|
+
* the other two did not, and three copies would drift apart the same way.
|
|
501
|
+
*/
|
|
502
|
+
async gateEntryExecution(kind, entry) {
|
|
503
|
+
if (!entry) return;
|
|
504
|
+
const metadata = entry.metadata;
|
|
505
|
+
const ref = metadata?.featureFlag;
|
|
458
506
|
if (!ref) return;
|
|
459
|
-
const adapter = this.get(FeatureFlagAdapterToken);
|
|
460
507
|
const key = typeof ref === "string" ? ref : ref.key;
|
|
461
508
|
const defaultValue = typeof ref === "object" ? ref.defaultValue ?? false : false;
|
|
462
509
|
let enabled;
|
|
463
510
|
try {
|
|
464
|
-
|
|
511
|
+
const adapter = this.get(FeatureFlagAdapterToken);
|
|
512
|
+
const results = await adapter.evaluateFlags([key], this.currentFlagContext());
|
|
513
|
+
enabled = this.isRefEnabled(ref, results);
|
|
465
514
|
} catch {
|
|
466
515
|
enabled = defaultValue;
|
|
467
516
|
}
|
|
468
517
|
if (!enabled) {
|
|
469
|
-
throw new Error(
|
|
518
|
+
throw new Error(`${kind} "${metadata?.name}" is disabled by feature flag "${key}"`);
|
|
470
519
|
}
|
|
471
520
|
}
|
|
472
521
|
// ─────────────────────────────────────────────────────────────────────────
|
|
@@ -493,7 +542,24 @@ var FeatureFlagPlugin = class extends import_sdk2.DynamicPlugin {
|
|
|
493
542
|
*/
|
|
494
543
|
async batchEvaluateRefs(adapter, refs) {
|
|
495
544
|
const keys = Array.from(refs.keys());
|
|
496
|
-
return adapter.evaluateFlags(keys,
|
|
545
|
+
return adapter.evaluateFlags(keys, this.currentFlagContext());
|
|
546
|
+
}
|
|
547
|
+
/**
|
|
548
|
+
* The caller's evaluation context.
|
|
549
|
+
*
|
|
550
|
+
* Passing `{}` here asked the adapter "is this flag on for nobody in particular", which a
|
|
551
|
+
* targeted adapter can answer differently from "is it on for THIS caller" — enabling access
|
|
552
|
+
* the caller should not have. The context comes from the same `FrontMcpContext` the
|
|
553
|
+
* context-scoped accessor reads, so hooks and `this.featureFlags` agree.
|
|
554
|
+
*/
|
|
555
|
+
currentFlagContext() {
|
|
556
|
+
try {
|
|
557
|
+
const ctx = this.get(import_sdk2.FrontMcpContextStorage)?.getStore();
|
|
558
|
+
if (!ctx) return {};
|
|
559
|
+
return buildFeatureFlagContext(ctx, this.options);
|
|
560
|
+
} catch {
|
|
561
|
+
return {};
|
|
562
|
+
}
|
|
497
563
|
}
|
|
498
564
|
/**
|
|
499
565
|
* Determine if a feature flag ref is enabled given adapter results.
|
|
@@ -502,9 +568,8 @@ var FeatureFlagPlugin = class extends import_sdk2.DynamicPlugin {
|
|
|
502
568
|
*/
|
|
503
569
|
isRefEnabled(ref, flagResults) {
|
|
504
570
|
const key = typeof ref === "string" ? ref : ref.key;
|
|
505
|
-
const adapterResult = flagResults.get(key);
|
|
506
571
|
const defaultValue = typeof ref === "object" ? ref.defaultValue ?? false : false;
|
|
507
|
-
if (
|
|
572
|
+
if (flagResults.has(key)) return flagResults.get(key) === true;
|
|
508
573
|
return defaultValue;
|
|
509
574
|
}
|
|
510
575
|
};
|
|
@@ -588,15 +653,27 @@ __decorateClass([
|
|
|
588
653
|
__decorateClass([
|
|
589
654
|
import_sdk2.ListResourcesHook.Did("findResources", { priority: 50 })
|
|
590
655
|
], FeatureFlagPlugin.prototype, "filterListResources", 1);
|
|
656
|
+
__decorateClass([
|
|
657
|
+
import_sdk2.ListResourceTemplatesHook.Did("findTemplates", { priority: 50 })
|
|
658
|
+
], FeatureFlagPlugin.prototype, "filterListResourceTemplates", 1);
|
|
591
659
|
__decorateClass([
|
|
592
660
|
ListPromptsHook.Did("findPrompts", { priority: 50 })
|
|
593
661
|
], FeatureFlagPlugin.prototype, "filterListPrompts", 1);
|
|
594
662
|
__decorateClass([
|
|
595
|
-
|
|
596
|
-
], FeatureFlagPlugin.prototype, "
|
|
663
|
+
FilterSkillsHook.Did("filterSkills", { priority: 50 })
|
|
664
|
+
], FeatureFlagPlugin.prototype, "filterSkills", 1);
|
|
597
665
|
__decorateClass([
|
|
598
666
|
import_sdk2.ToolHook.Will("execute", { priority: 50 })
|
|
599
667
|
], FeatureFlagPlugin.prototype, "gateToolExecution", 1);
|
|
668
|
+
__decorateClass([
|
|
669
|
+
ReadResourceHook.Will("execute", { priority: 50 })
|
|
670
|
+
], FeatureFlagPlugin.prototype, "gateResourceRead", 1);
|
|
671
|
+
__decorateClass([
|
|
672
|
+
GetPromptHook.Will("execute", { priority: 50 })
|
|
673
|
+
], FeatureFlagPlugin.prototype, "gatePromptGet", 1);
|
|
674
|
+
__decorateClass([
|
|
675
|
+
CompleteHook.Will("complete", { priority: 50 })
|
|
676
|
+
], FeatureFlagPlugin.prototype, "gateCompletion", 1);
|
|
600
677
|
FeatureFlagPlugin = __decorateClass([
|
|
601
678
|
(0, import_sdk2.Plugin)({
|
|
602
679
|
name: "feature-flags",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@frontmcp/plugin-feature-flags",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.8.1",
|
|
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.
|
|
52
|
+
"@frontmcp/sdk": "1.8.1"
|
|
53
53
|
},
|
|
54
54
|
"peerDependencies": {
|
|
55
55
|
"@launchdarkly/node-server-sdk": "^9.0.0 || ^10.0.0",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { FrontMcpContext } from '@frontmcp/sdk';
|
|
2
2
|
import type { FeatureFlagAdapter } from '../adapters/feature-flag-adapter.interface';
|
|
3
|
-
import type {
|
|
3
|
+
import type { FeatureFlagPluginOptions, FeatureFlagRef, FeatureFlagVariant } from '../feature-flag.types';
|
|
4
4
|
/**
|
|
5
5
|
* Context-scoped accessor for feature flag evaluation.
|
|
6
6
|
* Provides caching and context resolution from FrontMcpContext.
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"feature-flag-accessor.provider.d.ts","sourceRoot":"","sources":["../../src/providers/feature-flag-accessor.provider.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,eAAe,EAA2B,MAAM,eAAe,CAAC;
|
|
1
|
+
{"version":3,"file":"feature-flag-accessor.provider.d.ts","sourceRoot":"","sources":["../../src/providers/feature-flag-accessor.provider.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,eAAe,EAA2B,MAAM,eAAe,CAAC;AAEzE,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,4CAA4C,CAAC;AAErF,OAAO,KAAK,EAEV,wBAAwB,EACxB,cAAc,EACd,kBAAkB,EACnB,MAAM,uBAAuB,CAAC;AAE/B;;;GAGG;AACH,qBAKa,mBAAmB;IAC9B,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAqB;IAC7C,OAAO,CAAC,QAAQ,CAAC,GAAG,CAAkB;IACtC,OAAO,CAAC,QAAQ,CAAC,MAAM,CAA2B;IAClD,OAAO,CAAC,QAAQ,CAAC,KAAK,CAA4D;gBAEtE,OAAO,EAAE,kBAAkB,EAAE,GAAG,EAAE,eAAe,EAAE,MAAM,EAAE,wBAAwB;IAM/F;;OAEG;IACG,SAAS,CAAC,OAAO,EAAE,MAAM,EAAE,YAAY,CAAC,EAAE,OAAO,GAAG,OAAO,CAAC,OAAO,CAAC;IA4B1E;;OAEG;IACG,UAAU,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,kBAAkB,CAAC;IAK9D;;OAEG;IACG,aAAa,CAAC,QAAQ,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,GAAG,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAKtE;;OAEG;IACG,UAAU,CAAC,GAAG,EAAE,cAAc,GAAG,OAAO,CAAC,OAAO,CAAC;IAOvD;;OAEG;IACH,OAAO,CAAC,YAAY;CAGrB;AAED;;GAEG;AACH,wBAAgB,yBAAyB,CACvC,OAAO,EAAE,kBAAkB,EAC3B,GAAG,EAAE,eAAe,EACpB,MAAM,EAAE,wBAAwB,GAC/B,mBAAmB,CAErB"}
|