@frontmcp/plugin-feature-flags 1.7.2 → 1.8.0

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.
@@ -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
- /** Batch evaluate multiple flags at once. */
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,6CAA6C;IAC7C,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
+ {"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,kCAAkC,CAAC;AAC3E,OAAO,KAAK,EAAE,kBAAkB,EAAE,kBAAkB,EAAE,MAAM,uBAAuB,CAAC;AAEpF;;;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;IAWtF,aAAa,CAAC,QAAQ,EAAE,MAAM,EAAE,EAAE,OAAO,EAAE,kBAAkB,GAAG,OAAO,CAAC,GAAG,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAQ7F,OAAO,IAAI,OAAO,CAAC,IAAI,CAAC;CAG/B"}
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,14 @@ import {
246
246
  DynamicPlugin,
247
247
  FlowHooksOf,
248
248
  FRONTMCP_CONTEXT,
249
- ListToolsHook,
249
+ FrontMcpContextStorage,
250
250
  ListResourcesHook,
251
+ ListToolsHook,
251
252
  Plugin,
252
253
  ProviderScope as ProviderScope2,
253
254
  ToolHook
254
255
  } from "@frontmcp/sdk";
255
256
 
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
257
  // plugins/plugin-feature-flags/src/adapters/static.adapter.ts
268
258
  var StaticFeatureFlagAdapter = class {
269
259
  flags;
@@ -288,9 +278,15 @@ var StaticFeatureFlagAdapter = class {
288
278
  }
289
279
  return { ...flag };
290
280
  }
281
+ /**
282
+ * A key this adapter has never been configured with is OMITTED rather than reported as
283
+ * `false`. Only then can the caller tell "the operator disabled it" from "nobody configured
284
+ * it" and apply the ref's `defaultValue` to the second case only.
285
+ */
291
286
  async evaluateFlags(flagKeys, context) {
292
287
  const results = /* @__PURE__ */ new Map();
293
288
  for (const key of flagKeys) {
289
+ if (this.flags[key] === void 0) continue;
294
290
  results.set(key, await this.isEnabled(key, context));
295
291
  }
296
292
  return results;
@@ -299,6 +295,28 @@ var StaticFeatureFlagAdapter = class {
299
295
  }
300
296
  };
301
297
 
298
+ // plugins/plugin-feature-flags/src/feature-flag.context.ts
299
+ function buildFeatureFlagContext(ctx, config) {
300
+ if (!ctx) return {};
301
+ const userId = config.userIdResolver ? config.userIdResolver(ctx) : ctx.authInfo?.extra?.["sub"] ?? ctx.authInfo?.extra?.["userId"] ?? ctx.authInfo?.clientId;
302
+ return {
303
+ userId: userId ?? void 0,
304
+ sessionId: ctx.sessionId,
305
+ attributes: config.attributesResolver ? config.attributesResolver(ctx) : {}
306
+ };
307
+ }
308
+
309
+ // plugins/plugin-feature-flags/src/feature-flag.symbols.ts
310
+ var FeatureFlagAdapterToken = /* @__PURE__ */ Symbol(
311
+ "plugin:feature-flags:adapter"
312
+ );
313
+ var FeatureFlagConfigToken = /* @__PURE__ */ Symbol(
314
+ "plugin:feature-flags:config"
315
+ );
316
+ var FeatureFlagAccessorToken = /* @__PURE__ */ Symbol(
317
+ "plugin:feature-flags:accessor"
318
+ );
319
+
302
320
  // plugins/plugin-feature-flags/src/providers/feature-flag-accessor.provider.ts
303
321
  import { Provider, ProviderScope } from "@frontmcp/sdk";
304
322
  var FeatureFlagAccessor = class {
@@ -362,13 +380,7 @@ var FeatureFlagAccessor = class {
362
380
  * Build the FeatureFlagContext from the current FrontMcpContext.
363
381
  */
364
382
  buildContext() {
365
- const userId = this.config.userIdResolver ? this.config.userIdResolver(this.ctx) : this.ctx.authInfo?.extra?.["sub"] ?? this.ctx.authInfo?.extra?.["userId"] ?? this.ctx.authInfo?.clientId;
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
- };
383
+ return buildFeatureFlagContext(this.ctx, this.config);
372
384
  }
373
385
  };
374
386
  FeatureFlagAccessor = __decorateClass([
@@ -385,6 +397,8 @@ function createFeatureFlagAccessor(adapter, ctx, config) {
385
397
  // plugins/plugin-feature-flags/src/feature-flag.plugin.ts
386
398
  var ListPromptsHook = FlowHooksOf("prompts:list-prompts");
387
399
  var SearchSkillsHook = FlowHooksOf("skills:search");
400
+ var ReadResourceHook = FlowHooksOf("resources:read-resource");
401
+ var GetPromptHook = FlowHooksOf("prompts:get-prompt");
388
402
  var FeatureFlagPlugin = class extends DynamicPlugin {
389
403
  options;
390
404
  constructor(options) {
@@ -448,21 +462,37 @@ var FeatureFlagPlugin = class extends DynamicPlugin {
448
462
  flowCtx.state.set("results", filtered);
449
463
  }
450
464
  async gateToolExecution(flowCtx) {
451
- const { tool } = flowCtx.state;
452
- if (!tool) return;
453
- const ref = tool.metadata?.featureFlag;
465
+ await this.gateEntryExecution("Tool", flowCtx.state.tool);
466
+ }
467
+ async gateResourceRead(flowCtx) {
468
+ await this.gateEntryExecution("Resource", flowCtx.state.resource);
469
+ }
470
+ async gatePromptGet(flowCtx) {
471
+ await this.gateEntryExecution("Prompt", flowCtx.state.prompt);
472
+ }
473
+ /**
474
+ * Shared execution gate for tools, resources and prompts.
475
+ *
476
+ * One implementation on purpose: the advisory existed because the tool path had a gate and
477
+ * the other two did not, and three copies would drift apart the same way.
478
+ */
479
+ async gateEntryExecution(kind, entry) {
480
+ if (!entry) return;
481
+ const metadata = entry.metadata;
482
+ const ref = metadata?.featureFlag;
454
483
  if (!ref) return;
455
- const adapter = this.get(FeatureFlagAdapterToken);
456
484
  const key = typeof ref === "string" ? ref : ref.key;
457
485
  const defaultValue = typeof ref === "object" ? ref.defaultValue ?? false : false;
458
486
  let enabled;
459
487
  try {
460
- enabled = await adapter.isEnabled(key, {});
488
+ const adapter = this.get(FeatureFlagAdapterToken);
489
+ const results = await adapter.evaluateFlags([key], this.currentFlagContext());
490
+ enabled = this.isRefEnabled(ref, results);
461
491
  } catch {
462
492
  enabled = defaultValue;
463
493
  }
464
494
  if (!enabled) {
465
- throw new Error(`Tool "${tool.metadata.name}" is disabled by feature flag "${key}"`);
495
+ throw new Error(`${kind} "${metadata?.name}" is disabled by feature flag "${key}"`);
466
496
  }
467
497
  }
468
498
  // ─────────────────────────────────────────────────────────────────────────
@@ -489,7 +519,24 @@ var FeatureFlagPlugin = class extends DynamicPlugin {
489
519
  */
490
520
  async batchEvaluateRefs(adapter, refs) {
491
521
  const keys = Array.from(refs.keys());
492
- return adapter.evaluateFlags(keys, {});
522
+ return adapter.evaluateFlags(keys, this.currentFlagContext());
523
+ }
524
+ /**
525
+ * The caller's evaluation context.
526
+ *
527
+ * Passing `{}` here asked the adapter "is this flag on for nobody in particular", which a
528
+ * targeted adapter can answer differently from "is it on for THIS caller" — enabling access
529
+ * the caller should not have. The context comes from the same `FrontMcpContext` the
530
+ * context-scoped accessor reads, so hooks and `this.featureFlags` agree.
531
+ */
532
+ currentFlagContext() {
533
+ try {
534
+ const ctx = this.get(FrontMcpContextStorage)?.getStore();
535
+ if (!ctx) return {};
536
+ return buildFeatureFlagContext(ctx, this.options);
537
+ } catch {
538
+ return {};
539
+ }
493
540
  }
494
541
  /**
495
542
  * Determine if a feature flag ref is enabled given adapter results.
@@ -498,9 +545,8 @@ var FeatureFlagPlugin = class extends DynamicPlugin {
498
545
  */
499
546
  isRefEnabled(ref, flagResults) {
500
547
  const key = typeof ref === "string" ? ref : ref.key;
501
- const adapterResult = flagResults.get(key);
502
548
  const defaultValue = typeof ref === "object" ? ref.defaultValue ?? false : false;
503
- if (adapterResult === true) return true;
549
+ if (flagResults.has(key)) return flagResults.get(key) === true;
504
550
  return defaultValue;
505
551
  }
506
552
  };
@@ -593,6 +639,12 @@ __decorateClass([
593
639
  __decorateClass([
594
640
  ToolHook.Will("execute", { priority: 50 })
595
641
  ], FeatureFlagPlugin.prototype, "gateToolExecution", 1);
642
+ __decorateClass([
643
+ ReadResourceHook.Will("execute", { priority: 50 })
644
+ ], FeatureFlagPlugin.prototype, "gateResourceRead", 1);
645
+ __decorateClass([
646
+ GetPromptHook.Will("execute", { priority: 50 })
647
+ ], FeatureFlagPlugin.prototype, "gatePromptGet", 1);
596
648
  FeatureFlagPlugin = __decorateClass([
597
649
  Plugin({
598
650
  name: "feature-flags",
package/esm/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@frontmcp/plugin-feature-flags",
3
- "version": "1.7.2",
3
+ "version": "1.8.0",
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.7.2"
52
+ "@frontmcp/sdk": "1.8.0"
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"}
@@ -49,6 +49,28 @@ export default class FeatureFlagPlugin extends DynamicPlugin<FeatureFlagPluginOp
49
49
  * This prevents bypassing the list filter via direct tool invocation.
50
50
  */
51
51
  gateToolExecution(flowCtx: FlowCtxOf<'tools:call-tool'>): Promise<void>;
52
+ /**
53
+ * Execution gate: block resources/read when the resource's feature flag is off.
54
+ *
55
+ * GHSA-gf7p-j3hr-h5h4: only tools had this gate, so a flagged resource was merely absent
56
+ * from resources/list and still readable by URI. Hiding a capability from a listing is not
57
+ * the same as withholding it — clients cache listings and hold URIs from earlier sessions.
58
+ */
59
+ gateResourceRead(flowCtx: any): Promise<void>;
60
+ /**
61
+ * Execution gate: block prompts/get when the prompt's feature flag is off.
62
+ *
63
+ * The same gap as resources (GHSA-gf7p-j3hr-h5h4): filtering prompts/list left the prompt
64
+ * retrievable by name.
65
+ */
66
+ gatePromptGet(flowCtx: any): Promise<void>;
67
+ /**
68
+ * Shared execution gate for tools, resources and prompts.
69
+ *
70
+ * One implementation on purpose: the advisory existed because the tool path had a gate and
71
+ * the other two did not, and three copies would drift apart the same way.
72
+ */
73
+ private gateEntryExecution;
52
74
  /**
53
75
  * Collect unique flag keys from items that have a featureFlag metadata.
54
76
  */
@@ -57,6 +79,15 @@ export default class FeatureFlagPlugin extends DynamicPlugin<FeatureFlagPluginOp
57
79
  * Batch evaluate all collected flag refs via the adapter.
58
80
  */
59
81
  private batchEvaluateRefs;
82
+ /**
83
+ * The caller's evaluation context.
84
+ *
85
+ * Passing `{}` here asked the adapter "is this flag on for nobody in particular", which a
86
+ * targeted adapter can answer differently from "is it on for THIS caller" — enabling access
87
+ * the caller should not have. The context comes from the same `FrontMcpContext` the
88
+ * context-scoped accessor reads, so hooks and `this.featureFlags` agree.
89
+ */
90
+ private currentFlagContext;
60
91
  /**
61
92
  * Determine if a feature flag ref is enabled given adapter results.
62
93
  * 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,EACb,SAAS,EAOT,YAAY,EAEb,MAAM,eAAe,CAAC;AAEvB,OAAO,KAAK,EAAE,wBAAwB,EAAE,6BAA6B,EAAkB,MAAM,sBAAsB,CAAC;AAYpH;;;;;;;;;;;;;;;;;;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;;OAEG;IAEG,iBAAiB,CAAC,OAAO,EAAE,GAAG;IAmBpC;;;OAGG;IAEG,kBAAkB,CAAC,OAAO,EAAE,GAAG;IAmBrC;;;OAGG;IAEG,iBAAiB,CAAC,OAAO,EAAE,SAAS,CAAC,iBAAiB,CAAC;IA2B7D;;OAEG;IACH,OAAO,CAAC,eAAe;IAcvB;;OAEG;YACW,iBAAiB;IAQ/B;;;;OAIG;IACH,OAAO,CAAC,YAAY;CAOrB"}
1
+ {"version":3,"file":"feature-flag.plugin.d.ts","sourceRoot":"","sources":["../src/feature-flag.plugin.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,aAAa,EACb,SAAS,EAQT,YAAY,EAEb,MAAM,eAAe,CAAC;AAMvB,OAAO,KAAK,EAEV,wBAAwB,EACxB,6BAA6B,EAE9B,MAAM,sBAAsB,CAAC;AAW9B;;;;;;;;;;;;;;;;;;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;;OAEG;IAEG,iBAAiB,CAAC,OAAO,EAAE,GAAG;IAmBpC;;;OAGG;IAEG,kBAAkB,CAAC,OAAO,EAAE,GAAG;IAmBrC;;;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;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/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
- const userId = this.config.userIdResolver ? this.config.userIdResolver(this.ctx) : this.ctx.authInfo?.extra?.["sub"] ?? this.ctx.authInfo?.extra?.["userId"] ?? this.ctx.authInfo?.clientId;
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([
@@ -389,6 +400,8 @@ function createFeatureFlagAccessor(adapter, ctx, config) {
389
400
  // plugins/plugin-feature-flags/src/feature-flag.plugin.ts
390
401
  var ListPromptsHook = (0, import_sdk2.FlowHooksOf)("prompts:list-prompts");
391
402
  var SearchSkillsHook = (0, import_sdk2.FlowHooksOf)("skills:search");
403
+ var ReadResourceHook = (0, import_sdk2.FlowHooksOf)("resources:read-resource");
404
+ var GetPromptHook = (0, import_sdk2.FlowHooksOf)("prompts:get-prompt");
392
405
  var FeatureFlagPlugin = class extends import_sdk2.DynamicPlugin {
393
406
  options;
394
407
  constructor(options) {
@@ -452,21 +465,37 @@ var FeatureFlagPlugin = class extends import_sdk2.DynamicPlugin {
452
465
  flowCtx.state.set("results", filtered);
453
466
  }
454
467
  async gateToolExecution(flowCtx) {
455
- const { tool } = flowCtx.state;
456
- if (!tool) return;
457
- const ref = tool.metadata?.featureFlag;
468
+ await this.gateEntryExecution("Tool", flowCtx.state.tool);
469
+ }
470
+ async gateResourceRead(flowCtx) {
471
+ await this.gateEntryExecution("Resource", flowCtx.state.resource);
472
+ }
473
+ async gatePromptGet(flowCtx) {
474
+ await this.gateEntryExecution("Prompt", flowCtx.state.prompt);
475
+ }
476
+ /**
477
+ * Shared execution gate for tools, resources and prompts.
478
+ *
479
+ * One implementation on purpose: the advisory existed because the tool path had a gate and
480
+ * the other two did not, and three copies would drift apart the same way.
481
+ */
482
+ async gateEntryExecution(kind, entry) {
483
+ if (!entry) return;
484
+ const metadata = entry.metadata;
485
+ const ref = metadata?.featureFlag;
458
486
  if (!ref) return;
459
- const adapter = this.get(FeatureFlagAdapterToken);
460
487
  const key = typeof ref === "string" ? ref : ref.key;
461
488
  const defaultValue = typeof ref === "object" ? ref.defaultValue ?? false : false;
462
489
  let enabled;
463
490
  try {
464
- enabled = await adapter.isEnabled(key, {});
491
+ const adapter = this.get(FeatureFlagAdapterToken);
492
+ const results = await adapter.evaluateFlags([key], this.currentFlagContext());
493
+ enabled = this.isRefEnabled(ref, results);
465
494
  } catch {
466
495
  enabled = defaultValue;
467
496
  }
468
497
  if (!enabled) {
469
- throw new Error(`Tool "${tool.metadata.name}" is disabled by feature flag "${key}"`);
498
+ throw new Error(`${kind} "${metadata?.name}" is disabled by feature flag "${key}"`);
470
499
  }
471
500
  }
472
501
  // ─────────────────────────────────────────────────────────────────────────
@@ -493,7 +522,24 @@ var FeatureFlagPlugin = class extends import_sdk2.DynamicPlugin {
493
522
  */
494
523
  async batchEvaluateRefs(adapter, refs) {
495
524
  const keys = Array.from(refs.keys());
496
- return adapter.evaluateFlags(keys, {});
525
+ return adapter.evaluateFlags(keys, this.currentFlagContext());
526
+ }
527
+ /**
528
+ * The caller's evaluation context.
529
+ *
530
+ * Passing `{}` here asked the adapter "is this flag on for nobody in particular", which a
531
+ * targeted adapter can answer differently from "is it on for THIS caller" — enabling access
532
+ * the caller should not have. The context comes from the same `FrontMcpContext` the
533
+ * context-scoped accessor reads, so hooks and `this.featureFlags` agree.
534
+ */
535
+ currentFlagContext() {
536
+ try {
537
+ const ctx = this.get(import_sdk2.FrontMcpContextStorage)?.getStore();
538
+ if (!ctx) return {};
539
+ return buildFeatureFlagContext(ctx, this.options);
540
+ } catch {
541
+ return {};
542
+ }
497
543
  }
498
544
  /**
499
545
  * Determine if a feature flag ref is enabled given adapter results.
@@ -502,9 +548,8 @@ var FeatureFlagPlugin = class extends import_sdk2.DynamicPlugin {
502
548
  */
503
549
  isRefEnabled(ref, flagResults) {
504
550
  const key = typeof ref === "string" ? ref : ref.key;
505
- const adapterResult = flagResults.get(key);
506
551
  const defaultValue = typeof ref === "object" ? ref.defaultValue ?? false : false;
507
- if (adapterResult === true) return true;
552
+ if (flagResults.has(key)) return flagResults.get(key) === true;
508
553
  return defaultValue;
509
554
  }
510
555
  };
@@ -597,6 +642,12 @@ __decorateClass([
597
642
  __decorateClass([
598
643
  import_sdk2.ToolHook.Will("execute", { priority: 50 })
599
644
  ], FeatureFlagPlugin.prototype, "gateToolExecution", 1);
645
+ __decorateClass([
646
+ ReadResourceHook.Will("execute", { priority: 50 })
647
+ ], FeatureFlagPlugin.prototype, "gateResourceRead", 1);
648
+ __decorateClass([
649
+ GetPromptHook.Will("execute", { priority: 50 })
650
+ ], FeatureFlagPlugin.prototype, "gatePromptGet", 1);
600
651
  FeatureFlagPlugin = __decorateClass([
601
652
  (0, import_sdk2.Plugin)({
602
653
  name: "feature-flags",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@frontmcp/plugin-feature-flags",
3
- "version": "1.7.2",
3
+ "version": "1.8.0",
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.7.2"
52
+ "@frontmcp/sdk": "1.8.0"
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 { FeatureFlagVariant, FeatureFlagPluginOptions, FeatureFlagRef } from '../feature-flag.types';
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;AACzE,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,4CAA4C,CAAC;AACrF,OAAO,KAAK,EAEV,kBAAkB,EAClB,wBAAwB,EACxB,cAAc,EACf,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;CAerB;AAED;;GAEG;AACH,wBAAgB,yBAAyB,CACvC,OAAO,EAAE,kBAAkB,EAC3B,GAAG,EAAE,eAAe,EACpB,MAAM,EAAE,wBAAwB,GAC/B,mBAAmB,CAErB"}
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"}