@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.
- 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 +80 -28
- 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 +31 -0
- package/feature-flag.plugin.d.ts.map +1 -1
- package/index.js +78 -27
- 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,14 @@ import {
|
|
|
246
246
|
DynamicPlugin,
|
|
247
247
|
FlowHooksOf,
|
|
248
248
|
FRONTMCP_CONTEXT,
|
|
249
|
-
|
|
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
|
-
|
|
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
|
-
|
|
452
|
-
|
|
453
|
-
|
|
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
|
-
|
|
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(
|
|
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 (
|
|
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.
|
|
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.
|
|
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"}
|
package/feature-flag.plugin.d.ts
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
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
|
-
|
|
456
|
-
|
|
457
|
-
|
|
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
|
-
|
|
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(
|
|
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 (
|
|
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.
|
|
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.
|
|
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 {
|
|
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"}
|