@frontmcp/plugin-approval 1.8.0 → 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/index.js CHANGED
@@ -76,11 +76,37 @@ __export(index_exports, {
76
76
  module.exports = __toCommonJS(index_exports);
77
77
 
78
78
  // plugins/plugin-approval/src/approval.plugin.ts
79
- var import_sdk5 = require("@frontmcp/sdk");
79
+ var import_sdk6 = require("@frontmcp/sdk");
80
80
 
81
- // plugins/plugin-approval/src/stores/approval-storage.store.ts
81
+ // plugins/plugin-approval/src/approval.identity.ts
82
82
  var import_sdk = require("@frontmcp/sdk");
83
83
  var import_utils = require("@frontmcp/utils");
84
+ function nonEmptyString(value) {
85
+ return typeof value === "string" && value.length > 0 ? value : void 0;
86
+ }
87
+ function resolveApprovalIdentity(ctx) {
88
+ const extra = ctx?.authInfo?.extra;
89
+ const userId = nonEmptyString(extra?.["userId"]) ?? nonEmptyString(extra?.["sub"]) ?? nonEmptyString(ctx?.authInfo?.clientId);
90
+ const sessionId = nonEmptyString(ctx?.sessionId);
91
+ if (sessionId && sessionId !== import_sdk.STATELESS_SESSION_ID) {
92
+ return { sessionId, userId };
93
+ }
94
+ return { sessionId: userId ? `stateless-user:${userId}` : `unidentified:${(0, import_utils.randomUUID)()}`, userId };
95
+ }
96
+
97
+ // plugins/plugin-approval/src/approval.symbols.ts
98
+ var ApprovalStoreToken = /* @__PURE__ */ Symbol.for(
99
+ "plugin:approval:store"
100
+ );
101
+ var ApprovalServiceToken = /* @__PURE__ */ Symbol.for(
102
+ "plugin:approval:service"
103
+ );
104
+ var ChallengeServiceToken = /* @__PURE__ */ Symbol.for(
105
+ "plugin:approval:challenge-service"
106
+ );
107
+
108
+ // plugins/plugin-approval/src/hooks/approval-check.hook.ts
109
+ var import_sdk2 = require("@frontmcp/sdk");
84
110
 
85
111
  // plugins/plugin-approval/src/approval/types.ts
86
112
  var ApprovalScope = /* @__PURE__ */ ((ApprovalScope2) => {
@@ -445,269 +471,125 @@ var ChallengeValidationError = class extends ApprovalError {
445
471
  }
446
472
  };
447
473
 
448
- // plugins/plugin-approval/src/stores/approval-storage.store.ts
449
- function escapePattern(str) {
450
- return str.replace(/[*?[\]\\]/g, "\\$&");
451
- }
452
- var ApprovalStorageStore = class {
453
- storage;
454
- options;
455
- cleanupInterval;
456
- initialized = false;
457
- ownedStorage = false;
458
- constructor(options = {}) {
459
- this.options = {
460
- storage: options.storage ?? { type: "auto" },
461
- storageInstance: options.storageInstance,
462
- namespace: options.namespace ?? "approval",
463
- cleanupIntervalSeconds: options.cleanupIntervalSeconds ?? 60
464
- };
465
- }
466
- /**
467
- * Initialize the storage connection.
468
- */
469
- async initialize() {
470
- if (this.initialized) {
474
+ // plugins/plugin-approval/src/hooks/approval-check.hook.ts
475
+ var passedApprovalStores = /* @__PURE__ */ new WeakMap();
476
+ var ApprovalCheckPlugin = class extends import_sdk2.DynamicPlugin {
477
+ async checkApproval(flowCtx) {
478
+ const { tool, toolContext } = flowCtx.state;
479
+ if (!tool || !toolContext) return;
480
+ const metadata = tool.metadata;
481
+ const approvalConfig = this.resolveApprovalConfig(
482
+ metadata["approval"]
483
+ );
484
+ if (!approvalConfig.required) {
471
485
  return;
472
486
  }
473
- if (this.options.storageInstance) {
474
- this.storage = this.options.storageInstance.namespace(this.options.namespace);
475
- this.ownedStorage = false;
476
- } else {
477
- const rootStorage = await (0, import_utils.createStorage)(this.options.storage);
478
- this.storage = rootStorage.namespace(this.options.namespace);
479
- this.ownedStorage = true;
480
- }
481
- if (this.options.cleanupIntervalSeconds > 0) {
482
- this.cleanupInterval = setInterval(() => {
483
- void this.clearExpiredApprovals();
484
- }, this.options.cleanupIntervalSeconds * 1e3);
485
- this.cleanupInterval.unref?.();
487
+ if (approvalConfig.skipApproval) {
488
+ return;
486
489
  }
487
- this.initialized = true;
490
+ const approvalStore = this.get(ApprovalStoreToken);
491
+ const passedStores = passedApprovalStores.get(toolContext) ?? /* @__PURE__ */ new WeakSet();
492
+ if (passedStores.has(approvalStore)) return;
493
+ await this.enforceApproval(flowCtx, tool, toolContext, approvalConfig, approvalStore);
494
+ passedStores.add(approvalStore);
495
+ passedApprovalStores.set(toolContext, passedStores);
488
496
  }
489
- ensureInitialized() {
490
- if (!this.initialized) {
491
- throw new Error("ApprovalStorageStore not initialized. Call initialize() first.");
497
+ async enforceApproval(flowCtx, tool, toolContext, approvalConfig, approvalStore) {
498
+ const { sessionId, userId } = resolveApprovalIdentity(toolContext.tryGetContext?.());
499
+ const approval = await approvalStore.getApproval(tool.fullName, sessionId, userId);
500
+ if (approval?.state === "denied" /* DENIED */) {
501
+ throw new ApprovalRequiredError({
502
+ toolId: tool.fullName,
503
+ state: "denied",
504
+ message: `Tool "${tool.fullName}" execution denied.`
505
+ });
492
506
  }
493
- }
494
- buildKey(toolId, sessionId, userId, context) {
495
- const parts = [toolId];
496
- if (sessionId) parts.push(`session:${sessionId}`);
497
- if (userId) parts.push(`user:${userId}`);
498
- if (context) parts.push(`ctx:${context.type}:${context.identifier}`);
499
- return parts.join(":");
500
- }
501
- parseRecord(value) {
502
- if (!value) return void 0;
503
- try {
504
- const parsed = JSON.parse(value);
505
- const result = approvalRecordSchema.safeParse(parsed);
506
- if (!result.success) {
507
- return void 0;
508
- }
509
- return result.data;
510
- } catch {
511
- return void 0;
507
+ if (this.isPreApprovedContext(approvalConfig, this.getCurrentContext(flowCtx))) {
508
+ return;
512
509
  }
513
- }
514
- isExpired(approval) {
515
- return approval.expiresAt !== void 0 && Date.now() > approval.expiresAt;
516
- }
517
- async getApproval(toolId, sessionId, userId) {
518
- this.ensureInitialized();
519
- const sessionKey = this.buildKey(toolId, sessionId);
520
- const sessionValue = await this.storage.get(sessionKey);
521
- const sessionApproval = this.parseRecord(sessionValue);
522
- if (sessionApproval && !this.isExpired(sessionApproval)) {
523
- return sessionApproval;
510
+ if (approvalConfig.alwaysPrompt) {
511
+ await this.handleApprovalRequired(flowCtx, approvalConfig, approval);
512
+ return;
524
513
  }
525
- if (userId) {
526
- const userKey = this.buildKey(toolId, void 0, userId);
527
- const userValue = await this.storage.get(userKey);
528
- const userApproval = this.parseRecord(userValue);
529
- if (userApproval && !this.isExpired(userApproval)) {
530
- return userApproval;
514
+ if (approval?.state === "approved" /* APPROVED */) {
515
+ if (!this.isExpired(approval)) {
516
+ return;
531
517
  }
532
518
  }
533
- return void 0;
519
+ await this.handleApprovalRequired(flowCtx, approvalConfig, approval);
534
520
  }
535
- async queryApprovals(query) {
536
- this.ensureInitialized();
537
- const results = [];
538
- const pattern = query.toolId ? `${query.toolId}:*` : "*";
539
- const keys = await this.storage.keys(pattern);
540
- const values = await this.storage.mget(keys);
541
- for (const value of values) {
542
- const approval = this.parseRecord(value);
543
- if (!approval) continue;
544
- if (!query.includeExpired && this.isExpired(approval)) {
545
- continue;
546
- }
547
- if (query.toolId && approval.toolId !== query.toolId) continue;
548
- if (query.toolIds && !query.toolIds.includes(approval.toolId)) continue;
549
- if (query.scope && approval.scope !== query.scope) continue;
550
- if (query.scopes && !query.scopes.includes(approval.scope)) continue;
551
- if (query.state && approval.state !== query.state) continue;
552
- if (query.states && !query.states.includes(approval.state)) continue;
553
- if (query.sessionId && approval.sessionId !== query.sessionId) continue;
554
- if (query.userId && approval.userId !== query.userId) continue;
555
- if (query.context) {
556
- if (!approval.context || approval.context.type !== query.context.type || approval.context.identifier !== query.context.identifier) {
557
- continue;
558
- }
559
- }
560
- results.push(approval);
521
+ resolveApprovalConfig(config) {
522
+ if (config === true) {
523
+ return { required: true, defaultScope: "session" /* SESSION */ };
561
524
  }
562
- return results;
563
- }
564
- async grantApproval(options) {
565
- this.ensureInitialized();
566
- const now = Date.now();
567
- const expiresAt = options.ttlMs ? now + options.ttlMs : void 0;
568
- const grantedBy = normalizeGrantor(options.grantedBy);
569
- const record = {
570
- toolId: options.toolId,
571
- state: "approved" /* APPROVED */,
572
- scope: options.scope,
573
- grantedAt: now,
574
- expiresAt,
575
- ttlMs: options.ttlMs,
576
- sessionId: options.sessionId,
577
- userId: options.userId,
578
- context: options.context,
579
- grantedBy,
580
- reason: options.reason,
581
- metadata: options.metadata
525
+ if (config === false || config === void 0) {
526
+ return { required: false };
527
+ }
528
+ return {
529
+ ...config,
530
+ required: config.required ?? true,
531
+ defaultScope: config.defaultScope ?? "session" /* SESSION */
582
532
  };
583
- const key = this.buildKey(options.toolId, options.sessionId, options.userId, options.context);
584
- const ttlSeconds = options.ttlMs ? Math.ceil(options.ttlMs / 1e3) : void 0;
585
- await this.storage.set(key, JSON.stringify(record), { ttlSeconds });
586
- return record;
587
533
  }
588
- async revokeApproval(options) {
589
- this.ensureInitialized();
590
- const key = this.buildKey(options.toolId, options.sessionId, options.userId, options.context);
591
- const exists = await this.storage.exists(key);
592
- if (exists) {
593
- await this.storage.delete(key);
594
- return true;
595
- }
596
- return false;
534
+ isExpired(approval) {
535
+ if (!approval.expiresAt) return false;
536
+ return Date.now() > approval.expiresAt;
597
537
  }
598
- async isApproved(toolId, sessionId, userId, context) {
599
- this.ensureInitialized();
600
- if (context) {
601
- const contextKey = this.buildKey(toolId, sessionId, userId, context);
602
- const contextValue = await this.storage.get(contextKey);
603
- const contextApproval = this.parseRecord(contextValue);
604
- if (contextApproval && contextApproval.state === "approved" /* APPROVED */ && !this.isExpired(contextApproval)) {
605
- return true;
606
- }
607
- }
608
- const sessionKey = this.buildKey(toolId, sessionId);
609
- const sessionValue = await this.storage.get(sessionKey);
610
- const sessionApproval = this.parseRecord(sessionValue);
611
- if (sessionApproval && sessionApproval.state === "approved" /* APPROVED */ && !this.isExpired(sessionApproval)) {
612
- return true;
613
- }
614
- if (userId) {
615
- const userKey = this.buildKey(toolId, void 0, userId);
616
- const userValue = await this.storage.get(userKey);
617
- const userApproval = this.parseRecord(userValue);
618
- if (userApproval && userApproval.state === "approved" /* APPROVED */ && !this.isExpired(userApproval)) {
619
- return true;
620
- }
621
- }
622
- return false;
538
+ /**
539
+ * The context this call is running in, as established by the SESSION (GHSA-r848-p7wf-96rc).
540
+ *
541
+ * Deliberately ignores `toolContext.input.context`. The only consumer of this value is
542
+ * `isPreApprovedContext`, which skips the approval prompt outright, so reading it from the
543
+ * arguments of the call being gated let a caller name its own pre-approved context and walk
544
+ * straight past the gate. Only `authInfo.extra.approvalContext`, which the server sets
545
+ * while authenticating, can say what context a call belongs to.
546
+ */
547
+ getCurrentContext(flowCtx) {
548
+ const ctx = flowCtx.state.toolContext?.tryGetContext?.();
549
+ const sessionContext = ctx?.authInfo?.extra?.["approvalContext"];
550
+ return this.isApprovalContext(sessionContext) ? sessionContext : void 0;
623
551
  }
624
- async clearSessionApprovals(sessionId) {
625
- this.ensureInitialized();
626
- const escapedSessionId = escapePattern(sessionId);
627
- const pattern = `*:session:${escapedSessionId}*`;
628
- const keys = await this.storage.keys(pattern);
629
- if (keys.length === 0) {
630
- return 0;
631
- }
632
- return await this.storage.mdelete(keys);
552
+ isApprovalContext(value) {
553
+ return typeof value === "object" && value !== null && "type" in value && "identifier" in value && typeof value.type === "string" && typeof value.identifier === "string";
633
554
  }
634
- async clearExpiredApprovals() {
635
- this.ensureInitialized();
636
- const now = Date.now();
637
- const keys = await this.storage.keys("*");
638
- const values = await this.storage.mget(keys);
639
- const keysToDelete = [];
640
- for (let i = 0; i < keys.length; i++) {
641
- const approval = this.parseRecord(values[i]);
642
- if (approval && approval.expiresAt && approval.expiresAt <= now) {
643
- keysToDelete.push(keys[i]);
644
- }
645
- }
646
- if (keysToDelete.length > 0) {
647
- return await this.storage.mdelete(keysToDelete);
555
+ isPreApprovedContext(config, currentContext) {
556
+ if (!currentContext || !config.preApprovedContexts?.length) {
557
+ return false;
648
558
  }
649
- return 0;
559
+ return config.preApprovedContexts.some(
560
+ (preApproved) => preApproved.type === currentContext.type && preApproved.identifier === currentContext.identifier
561
+ );
650
562
  }
651
- async getStats() {
652
- this.ensureInitialized();
653
- const byScope = {
654
- ["session" /* SESSION */]: 0,
655
- ["user" /* USER */]: 0,
656
- ["time_limited" /* TIME_LIMITED */]: 0,
657
- ["tool_specific" /* TOOL_SPECIFIC */]: 0,
658
- ["context_specific" /* CONTEXT_SPECIFIC */]: 0
659
- };
660
- const byState = {
661
- ["pending" /* PENDING */]: 0,
662
- ["approved" /* APPROVED */]: 0,
663
- ["denied" /* DENIED */]: 0,
664
- ["expired" /* EXPIRED */]: 0
665
- };
666
- const keys = await this.storage.keys("*");
667
- const values = await this.storage.mget(keys);
668
- let total = 0;
669
- for (const value of values) {
670
- const approval = this.parseRecord(value);
671
- if (approval) {
672
- total++;
673
- byScope[approval.scope]++;
674
- byState[approval.state]++;
563
+ async handleApprovalRequired(flowCtx, config, existingApproval) {
564
+ const { tool } = flowCtx.state;
565
+ const message = config.approvalMessage ?? `Tool "${tool?.fullName}" requires approval to execute. Allow?`;
566
+ const isExpiredApproval = existingApproval ? this.isExpired(existingApproval) : false;
567
+ throw new ApprovalRequiredError({
568
+ toolId: tool?.fullName ?? "unknown",
569
+ state: isExpiredApproval ? "expired" : "pending",
570
+ message,
571
+ approvalOptions: {
572
+ allowedScopes: config.allowedScopes,
573
+ defaultScope: config.defaultScope,
574
+ maxTtlMs: config.maxTtlMs,
575
+ category: config.category,
576
+ riskLevel: config.riskLevel
675
577
  }
676
- }
677
- return {
678
- totalApprovals: total,
679
- byScope,
680
- byState
681
- };
682
- }
683
- async close() {
684
- if (this.cleanupInterval) {
685
- clearInterval(this.cleanupInterval);
686
- this.cleanupInterval = void 0;
687
- }
688
- if (this.ownedStorage && this.storage) {
689
- await this.storage.root.disconnect();
690
- }
691
- this.initialized = false;
578
+ });
692
579
  }
693
580
  };
694
- ApprovalStorageStore = __decorateClass([
695
- (0, import_sdk.Provider)({
696
- name: "provider:approval:store:storage",
697
- description: "Storage-backed approval store (supports Memory, Redis, Vercel KV, Upstash)",
698
- scope: import_sdk.ProviderScope.GLOBAL
581
+ __decorateClass([
582
+ import_sdk2.ToolHook.Will("execute", { priority: 100 })
583
+ ], ApprovalCheckPlugin.prototype, "checkApproval", 1);
584
+ ApprovalCheckPlugin = __decorateClass([
585
+ (0, import_sdk2.Plugin)({
586
+ name: "approval:check",
587
+ description: "Checks tool approval state before execution"
699
588
  })
700
- ], ApprovalStorageStore);
701
- function createApprovalMemoryStore(options = {}) {
702
- const memoryStorage = (0, import_utils.createMemoryStorage)();
703
- return new ApprovalStorageStore({
704
- ...options,
705
- storageInstance: memoryStorage
706
- });
707
- }
589
+ ], ApprovalCheckPlugin);
708
590
 
709
591
  // plugins/plugin-approval/src/services/approval.service.ts
710
- var import_sdk2 = require("@frontmcp/sdk");
592
+ var import_sdk3 = require("@frontmcp/sdk");
711
593
  var ApprovalService = class {
712
594
  constructor(store, sessionId, userId) {
713
595
  this.store = store;
@@ -849,10 +731,10 @@ var ApprovalService = class {
849
731
  }
850
732
  };
851
733
  ApprovalService = __decorateClass([
852
- (0, import_sdk2.Provider)({
734
+ (0, import_sdk3.Provider)({
853
735
  name: "provider:approval:service",
854
736
  description: "Service for managing tool approvals",
855
- scope: import_sdk2.ProviderScope.CONTEXT
737
+ scope: import_sdk3.ProviderScope.CONTEXT
856
738
  })
857
739
  ], ApprovalService);
858
740
  function createApprovalService(store, sessionId, userId) {
@@ -860,7 +742,7 @@ function createApprovalService(store, sessionId, userId) {
860
742
  }
861
743
 
862
744
  // plugins/plugin-approval/src/services/challenge.service.ts
863
- var import_sdk3 = require("@frontmcp/sdk");
745
+ var import_sdk4 = require("@frontmcp/sdk");
864
746
  var import_utils2 = require("@frontmcp/utils");
865
747
  var ChallengeService = class {
866
748
  storage;
@@ -994,10 +876,10 @@ var ChallengeService = class {
994
876
  }
995
877
  };
996
878
  ChallengeService = __decorateClass([
997
- (0, import_sdk3.Provider)({
879
+ (0, import_sdk4.Provider)({
998
880
  name: "provider:approval:challenge-service",
999
881
  description: "PKCE challenge service for webhook approval flows",
1000
- scope: import_sdk3.ProviderScope.GLOBAL
882
+ scope: import_sdk4.ProviderScope.GLOBAL
1001
883
  })
1002
884
  ], ChallengeService);
1003
885
  function generatePkcePairFromVerifier(codeVerifier) {
@@ -1014,137 +896,272 @@ function createMemoryChallengeService(options = {}) {
1014
896
  });
1015
897
  }
1016
898
 
1017
- // plugins/plugin-approval/src/approval.symbols.ts
1018
- var ApprovalStoreToken = /* @__PURE__ */ Symbol.for(
1019
- "plugin:approval:store"
1020
- );
1021
- var ApprovalServiceToken = /* @__PURE__ */ Symbol.for(
1022
- "plugin:approval:service"
1023
- );
1024
- var ChallengeServiceToken = /* @__PURE__ */ Symbol.for(
1025
- "plugin:approval:challenge-service"
1026
- );
1027
-
1028
- // plugins/plugin-approval/src/hooks/approval-check.hook.ts
1029
- var import_sdk4 = require("@frontmcp/sdk");
1030
- var ApprovalCheckPlugin = class extends import_sdk4.DynamicPlugin {
1031
- async checkApproval(flowCtx) {
1032
- const { tool, toolContext } = flowCtx.state;
1033
- if (!tool || !toolContext) return;
1034
- const metadata = tool.metadata;
1035
- const approvalConfig = this.resolveApprovalConfig(
1036
- metadata["approval"]
1037
- );
1038
- if (!approvalConfig.required) {
1039
- return;
1040
- }
1041
- if (approvalConfig.skipApproval) {
1042
- return;
1043
- }
1044
- const ctx = toolContext.tryGetContext?.();
1045
- const sessionId = ctx?.sessionId ?? "unknown";
1046
- const userId = this.getStringExtra(ctx?.authInfo?.extra, "userId") ?? this.getStringExtra(ctx?.authInfo?.extra, "sub") ?? ctx?.authInfo?.clientId;
1047
- const currentContext = this.getCurrentContext(flowCtx);
1048
- if (this.isPreApprovedContext(approvalConfig, currentContext)) {
1049
- return;
1050
- }
1051
- const approvalStore = this.get(ApprovalStoreToken);
1052
- const approval = await approvalStore.getApproval(tool.fullName, sessionId, userId);
1053
- if (approvalConfig.alwaysPrompt) {
1054
- await this.handleApprovalRequired(flowCtx, approvalConfig, approval);
899
+ // plugins/plugin-approval/src/stores/approval-storage.store.ts
900
+ var import_sdk5 = require("@frontmcp/sdk");
901
+ var import_utils3 = require("@frontmcp/utils");
902
+ function escapePattern(str) {
903
+ return str.replace(/[*?[\]\\]/g, "\\$&");
904
+ }
905
+ var ApprovalStorageStore = class {
906
+ storage;
907
+ options;
908
+ cleanupInterval;
909
+ initialized = false;
910
+ ownedStorage = false;
911
+ constructor(options = {}) {
912
+ this.options = {
913
+ storage: options.storage ?? { type: "auto" },
914
+ storageInstance: options.storageInstance,
915
+ namespace: options.namespace ?? "approval",
916
+ cleanupIntervalSeconds: options.cleanupIntervalSeconds ?? 60
917
+ };
918
+ }
919
+ /**
920
+ * Initialize the storage connection.
921
+ */
922
+ async initialize() {
923
+ if (this.initialized) {
1055
924
  return;
1056
925
  }
1057
- if (approval?.state === "approved" /* APPROVED */) {
1058
- if (!this.isExpired(approval)) {
1059
- return;
1060
- }
926
+ if (this.options.storageInstance) {
927
+ this.storage = this.options.storageInstance.namespace(this.options.namespace);
928
+ this.ownedStorage = false;
929
+ } else {
930
+ const rootStorage = await (0, import_utils3.createStorage)(this.options.storage);
931
+ this.storage = rootStorage.namespace(this.options.namespace);
932
+ this.ownedStorage = true;
1061
933
  }
1062
- if (approval?.state === "denied" /* DENIED */) {
1063
- throw new ApprovalRequiredError({
1064
- toolId: tool.fullName,
1065
- state: "denied",
1066
- message: `Tool "${tool.fullName}" execution denied.`
1067
- });
934
+ if (this.options.cleanupIntervalSeconds > 0) {
935
+ this.cleanupInterval = setInterval(() => {
936
+ void this.clearExpiredApprovals();
937
+ }, this.options.cleanupIntervalSeconds * 1e3);
938
+ this.cleanupInterval.unref?.();
1068
939
  }
1069
- await this.handleApprovalRequired(flowCtx, approvalConfig, approval);
940
+ this.initialized = true;
1070
941
  }
1071
- resolveApprovalConfig(config) {
1072
- if (config === true) {
1073
- return { required: true, defaultScope: "session" /* SESSION */ };
942
+ ensureInitialized() {
943
+ if (!this.initialized) {
944
+ throw new Error("ApprovalStorageStore not initialized. Call initialize() first.");
1074
945
  }
1075
- if (config === false || config === void 0) {
1076
- return { required: false };
946
+ }
947
+ buildKey(toolId, sessionId, userId, context) {
948
+ const parts = [toolId];
949
+ if (sessionId) parts.push(`session:${sessionId}`);
950
+ if (userId) parts.push(`user:${userId}`);
951
+ if (context) parts.push(`ctx:${context.type}:${context.identifier}`);
952
+ return parts.join(":");
953
+ }
954
+ parseRecord(value) {
955
+ if (!value) return void 0;
956
+ try {
957
+ const parsed = JSON.parse(value);
958
+ const result = approvalRecordSchema.safeParse(parsed);
959
+ if (!result.success) {
960
+ return void 0;
961
+ }
962
+ return result.data;
963
+ } catch {
964
+ return void 0;
1077
965
  }
1078
- return {
1079
- ...config,
1080
- required: config.required ?? true,
1081
- defaultScope: config.defaultScope ?? "session" /* SESSION */
1082
- };
1083
966
  }
1084
967
  isExpired(approval) {
1085
- if (!approval.expiresAt) return false;
1086
- return Date.now() > approval.expiresAt;
968
+ return approval.expiresAt !== void 0 && Date.now() > approval.expiresAt;
1087
969
  }
1088
970
  /**
1089
- * The context this call is running in, as established by the SESSION (GHSA-r848-p7wf-96rc).
1090
- *
1091
- * Deliberately ignores `toolContext.input.context`. The only consumer of this value is
1092
- * `isPreApprovedContext`, which skips the approval prompt outright, so reading it from the
1093
- * arguments of the call being gated let a caller name its own pre-approved context and walk
1094
- * straight past the gate. Only `authInfo.extra.approvalContext`, which the server sets
1095
- * while authenticating, can say what context a call belongs to.
971
+ * The session record, else the user record, except that a denial in either scope wins,
972
+ * so a session approval cannot mask a user-level denial.
1096
973
  */
1097
- getCurrentContext(flowCtx) {
1098
- const ctx = flowCtx.state.toolContext?.tryGetContext?.();
1099
- const sessionContext = ctx?.authInfo?.extra?.["approvalContext"];
1100
- return this.isApprovalContext(sessionContext) ? sessionContext : void 0;
974
+ async getApproval(toolId, sessionId, userId) {
975
+ this.ensureInitialized();
976
+ const keys = [this.buildKey(toolId, sessionId)];
977
+ if (userId) {
978
+ keys.push(this.buildKey(toolId, void 0, userId));
979
+ }
980
+ const activeRecords = [];
981
+ for (const key of keys) {
982
+ const record = this.parseRecord(await this.storage.get(key));
983
+ if (record && !this.isExpired(record)) {
984
+ activeRecords.push(record);
985
+ }
986
+ }
987
+ return activeRecords.find((record) => record.state === "denied" /* DENIED */) ?? activeRecords[0];
1101
988
  }
1102
- isApprovalContext(value) {
1103
- return typeof value === "object" && value !== null && "type" in value && "identifier" in value && typeof value.type === "string" && typeof value.identifier === "string";
989
+ async queryApprovals(query) {
990
+ this.ensureInitialized();
991
+ const results = [];
992
+ const pattern = query.toolId ? `${query.toolId}:*` : "*";
993
+ const keys = await this.storage.keys(pattern);
994
+ const values = await this.storage.mget(keys);
995
+ for (const value of values) {
996
+ const approval = this.parseRecord(value);
997
+ if (!approval) continue;
998
+ if (!query.includeExpired && this.isExpired(approval)) {
999
+ continue;
1000
+ }
1001
+ if (query.toolId && approval.toolId !== query.toolId) continue;
1002
+ if (query.toolIds && !query.toolIds.includes(approval.toolId)) continue;
1003
+ if (query.scope && approval.scope !== query.scope) continue;
1004
+ if (query.scopes && !query.scopes.includes(approval.scope)) continue;
1005
+ if (query.state && approval.state !== query.state) continue;
1006
+ if (query.states && !query.states.includes(approval.state)) continue;
1007
+ if (query.sessionId && approval.sessionId !== query.sessionId) continue;
1008
+ if (query.userId && approval.userId !== query.userId) continue;
1009
+ if (query.context) {
1010
+ if (!approval.context || approval.context.type !== query.context.type || approval.context.identifier !== query.context.identifier) {
1011
+ continue;
1012
+ }
1013
+ }
1014
+ results.push(approval);
1015
+ }
1016
+ return results;
1104
1017
  }
1105
- getStringExtra(extra, key) {
1106
- if (!extra) return void 0;
1107
- const value = extra[key];
1108
- return typeof value === "string" ? value : void 0;
1018
+ async grantApproval(options) {
1019
+ this.ensureInitialized();
1020
+ const now = Date.now();
1021
+ const expiresAt = options.ttlMs ? now + options.ttlMs : void 0;
1022
+ const grantedBy = normalizeGrantor(options.grantedBy);
1023
+ const record = {
1024
+ toolId: options.toolId,
1025
+ state: "approved" /* APPROVED */,
1026
+ scope: options.scope,
1027
+ grantedAt: now,
1028
+ expiresAt,
1029
+ ttlMs: options.ttlMs,
1030
+ sessionId: options.sessionId,
1031
+ userId: options.userId,
1032
+ context: options.context,
1033
+ grantedBy,
1034
+ reason: options.reason,
1035
+ metadata: options.metadata
1036
+ };
1037
+ const key = this.buildKey(options.toolId, options.sessionId, options.userId, options.context);
1038
+ const ttlSeconds = options.ttlMs ? Math.ceil(options.ttlMs / 1e3) : void 0;
1039
+ await this.storage.set(key, JSON.stringify(record), { ttlSeconds });
1040
+ return record;
1109
1041
  }
1110
- isPreApprovedContext(config, currentContext) {
1111
- if (!currentContext || !config.preApprovedContexts?.length) {
1112
- return false;
1042
+ async revokeApproval(options) {
1043
+ this.ensureInitialized();
1044
+ const key = this.buildKey(options.toolId, options.sessionId, options.userId, options.context);
1045
+ const exists = await this.storage.exists(key);
1046
+ if (exists) {
1047
+ await this.storage.delete(key);
1048
+ return true;
1113
1049
  }
1114
- return config.preApprovedContexts.some(
1115
- (preApproved) => preApproved.type === currentContext.type && preApproved.identifier === currentContext.identifier
1116
- );
1050
+ return false;
1117
1051
  }
1118
- async handleApprovalRequired(flowCtx, config, existingApproval) {
1119
- const { tool } = flowCtx.state;
1120
- const message = config.approvalMessage ?? `Tool "${tool?.fullName}" requires approval to execute. Allow?`;
1121
- const isExpiredApproval = existingApproval ? this.isExpired(existingApproval) : false;
1122
- throw new ApprovalRequiredError({
1123
- toolId: tool?.fullName ?? "unknown",
1124
- state: isExpiredApproval ? "expired" : "pending",
1125
- message,
1126
- approvalOptions: {
1127
- allowedScopes: config.allowedScopes,
1128
- defaultScope: config.defaultScope,
1129
- maxTtlMs: config.maxTtlMs,
1130
- category: config.category,
1131
- riskLevel: config.riskLevel
1052
+ async isApproved(toolId, sessionId, userId, context) {
1053
+ this.ensureInitialized();
1054
+ if (context) {
1055
+ const contextKey = this.buildKey(toolId, sessionId, userId, context);
1056
+ const contextValue = await this.storage.get(contextKey);
1057
+ const contextApproval = this.parseRecord(contextValue);
1058
+ if (contextApproval && contextApproval.state === "approved" /* APPROVED */ && !this.isExpired(contextApproval)) {
1059
+ return true;
1132
1060
  }
1133
- });
1061
+ }
1062
+ const sessionKey = this.buildKey(toolId, sessionId);
1063
+ const sessionValue = await this.storage.get(sessionKey);
1064
+ const sessionApproval = this.parseRecord(sessionValue);
1065
+ if (sessionApproval && sessionApproval.state === "approved" /* APPROVED */ && !this.isExpired(sessionApproval)) {
1066
+ return true;
1067
+ }
1068
+ if (userId) {
1069
+ const userKey = this.buildKey(toolId, void 0, userId);
1070
+ const userValue = await this.storage.get(userKey);
1071
+ const userApproval = this.parseRecord(userValue);
1072
+ if (userApproval && userApproval.state === "approved" /* APPROVED */ && !this.isExpired(userApproval)) {
1073
+ return true;
1074
+ }
1075
+ }
1076
+ return false;
1077
+ }
1078
+ async clearSessionApprovals(sessionId) {
1079
+ this.ensureInitialized();
1080
+ const escapedSessionId = escapePattern(sessionId);
1081
+ const pattern = `*:session:${escapedSessionId}*`;
1082
+ const keys = await this.storage.keys(pattern);
1083
+ if (keys.length === 0) {
1084
+ return 0;
1085
+ }
1086
+ return await this.storage.mdelete(keys);
1087
+ }
1088
+ async clearExpiredApprovals() {
1089
+ this.ensureInitialized();
1090
+ const now = Date.now();
1091
+ const keys = await this.storage.keys("*");
1092
+ const values = await this.storage.mget(keys);
1093
+ const keysToDelete = [];
1094
+ for (let i = 0; i < keys.length; i++) {
1095
+ const approval = this.parseRecord(values[i]);
1096
+ if (approval && approval.expiresAt && approval.expiresAt <= now) {
1097
+ keysToDelete.push(keys[i]);
1098
+ }
1099
+ }
1100
+ if (keysToDelete.length > 0) {
1101
+ return await this.storage.mdelete(keysToDelete);
1102
+ }
1103
+ return 0;
1104
+ }
1105
+ async getStats() {
1106
+ this.ensureInitialized();
1107
+ const byScope = {
1108
+ ["session" /* SESSION */]: 0,
1109
+ ["user" /* USER */]: 0,
1110
+ ["time_limited" /* TIME_LIMITED */]: 0,
1111
+ ["tool_specific" /* TOOL_SPECIFIC */]: 0,
1112
+ ["context_specific" /* CONTEXT_SPECIFIC */]: 0
1113
+ };
1114
+ const byState = {
1115
+ ["pending" /* PENDING */]: 0,
1116
+ ["approved" /* APPROVED */]: 0,
1117
+ ["denied" /* DENIED */]: 0,
1118
+ ["expired" /* EXPIRED */]: 0
1119
+ };
1120
+ const keys = await this.storage.keys("*");
1121
+ const values = await this.storage.mget(keys);
1122
+ let total = 0;
1123
+ for (const value of values) {
1124
+ const approval = this.parseRecord(value);
1125
+ if (approval) {
1126
+ total++;
1127
+ byScope[approval.scope]++;
1128
+ byState[approval.state]++;
1129
+ }
1130
+ }
1131
+ return {
1132
+ totalApprovals: total,
1133
+ byScope,
1134
+ byState
1135
+ };
1136
+ }
1137
+ async close() {
1138
+ if (this.cleanupInterval) {
1139
+ clearInterval(this.cleanupInterval);
1140
+ this.cleanupInterval = void 0;
1141
+ }
1142
+ if (this.ownedStorage && this.storage) {
1143
+ await this.storage.root.disconnect();
1144
+ }
1145
+ this.initialized = false;
1134
1146
  }
1135
1147
  };
1136
- __decorateClass([
1137
- import_sdk4.ToolHook.Will("execute", { priority: 100 })
1138
- ], ApprovalCheckPlugin.prototype, "checkApproval", 1);
1139
- ApprovalCheckPlugin = __decorateClass([
1140
- (0, import_sdk4.Plugin)({
1141
- name: "approval:check",
1142
- description: "Checks tool approval state before execution"
1148
+ ApprovalStorageStore = __decorateClass([
1149
+ (0, import_sdk5.Provider)({
1150
+ name: "provider:approval:store:storage",
1151
+ description: "Storage-backed approval store (supports Memory, Redis, Vercel KV, Upstash)",
1152
+ scope: import_sdk5.ProviderScope.GLOBAL
1143
1153
  })
1144
- ], ApprovalCheckPlugin);
1154
+ ], ApprovalStorageStore);
1155
+ function createApprovalMemoryStore(options = {}) {
1156
+ const memoryStorage = (0, import_utils3.createMemoryStorage)();
1157
+ return new ApprovalStorageStore({
1158
+ ...options,
1159
+ storageInstance: memoryStorage
1160
+ });
1161
+ }
1145
1162
 
1146
1163
  // plugins/plugin-approval/src/approval.plugin.ts
1147
- var ApprovalPlugin = class extends import_sdk5.DynamicPlugin {
1164
+ var ApprovalPlugin = class extends import_sdk6.DynamicPlugin {
1148
1165
  options;
1149
1166
  constructor(options = {}) {
1150
1167
  super();
@@ -1153,12 +1170,6 @@ var ApprovalPlugin = class extends import_sdk5.DynamicPlugin {
1153
1170
  ...options
1154
1171
  };
1155
1172
  }
1156
- /**
1157
- * Get plugin metadata including nested plugins.
1158
- */
1159
- static getPluginMetadata(_options) {
1160
- return { plugins: [ApprovalCheckPlugin] };
1161
- }
1162
1173
  };
1163
1174
  __publicField(ApprovalPlugin, "defaultOptions", {
1164
1175
  namespace: "approval",
@@ -1211,19 +1222,20 @@ __publicField(ApprovalPlugin, "dynamicProviders", (options) => {
1211
1222
  providers.push({
1212
1223
  name: "approval:service",
1213
1224
  provide: ApprovalServiceToken,
1214
- scope: import_sdk5.ProviderScope.CONTEXT,
1215
- inject: () => [ApprovalStoreToken, import_sdk5.FRONTMCP_CONTEXT],
1225
+ scope: import_sdk6.ProviderScope.CONTEXT,
1226
+ inject: () => [ApprovalStoreToken, import_sdk6.FRONTMCP_CONTEXT],
1216
1227
  useFactory: (store, ctx) => {
1217
- const userId = ctx.authInfo?.extra?.["userId"] ?? ctx.authInfo?.extra?.["sub"] ?? ctx.authInfo?.clientId;
1218
- return createApprovalService(store, ctx.sessionId, userId);
1228
+ const { sessionId, userId } = resolveApprovalIdentity(ctx);
1229
+ return createApprovalService(store, sessionId, userId);
1219
1230
  }
1220
1231
  });
1221
1232
  return providers;
1222
1233
  });
1223
1234
  ApprovalPlugin = __decorateClass([
1224
- (0, import_sdk5.Plugin)({
1235
+ (0, import_sdk6.Plugin)({
1225
1236
  name: "approval",
1226
1237
  description: "Tool authorization workflow with PKCE webhook security",
1238
+ plugins: [ApprovalCheckPlugin],
1227
1239
  contextExtensions: [
1228
1240
  {
1229
1241
  property: "approval",