@kya-os/mcp-i-core 1.3.2 → 1.3.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -668,9 +668,12 @@ export class ToolProtectionService {
668
668
  * Uses projectId endpoint if available (preferred, project-scoped), otherwise falls back to agent_did query param
669
669
  *
670
670
  * @param agentDid DID of the agent to fetch config for
671
+ * @param options Optional fetch options
672
+ * @param options.bypassCDNCache When true, adds cache-busting to bypass CDN caches (used by clearAndRefresh)
671
673
  */
672
674
  private async fetchFromApi(
673
- agentDid: string
675
+ agentDid: string,
676
+ options?: { bypassCDNCache?: boolean }
674
677
  ): Promise<BouncerConfigApiResponse> {
675
678
  // Prefer new project-scoped endpoint: /api/v1/bouncer/projects/{projectId}/tool-protections
676
679
  // Falls back to old endpoint: /api/v1/bouncer/config?agent_did={did} for backward compatibility
@@ -686,6 +689,14 @@ export class ToolProtectionService {
686
689
  url = `${this.config.apiUrl}/api/v1/bouncer/config?agent_did=${encodeURIComponent(agentDid)}`;
687
690
  }
688
691
 
692
+ // Add cache-busting query param when bypassing CDN cache
693
+ // This is used during cache invalidation (clearAndRefresh) to ensure we get fresh data
694
+ // from the origin server, not stale CDN-cached data
695
+ if (options?.bypassCDNCache) {
696
+ const separator = url.includes("?") ? "&" : "?";
697
+ url = `${url}${separator}_t=${Date.now()}`;
698
+ }
699
+
689
700
  // Debug: Log API key status (masked for security)
690
701
  // Format: sk_live... (prefix up to first underscore after type, then ...)
691
702
  const apiKeyMasked = this.config.apiKey
@@ -741,6 +752,13 @@ export class ToolProtectionService {
741
752
  headers["Authorization"] = `Bearer ${this.config.apiKey}`;
742
753
  }
743
754
 
755
+ // Add cache-control header when bypassing CDN cache
756
+ // This tells any intermediate caches (CDN, proxies) not to serve cached content
757
+ if (options?.bypassCDNCache) {
758
+ headers["Cache-Control"] = "no-cache, no-store, must-revalidate";
759
+ headers["Pragma"] = "no-cache"; // HTTP/1.0 backward compatibility
760
+ }
761
+
744
762
  const response = await fetch(url, {
745
763
  method: "GET",
746
764
  headers,
@@ -797,51 +815,168 @@ export class ToolProtectionService {
797
815
  }
798
816
 
799
817
  /**
800
- * Clear cache WITHOUT warming
818
+ * Clear cache and immediately fetch fresh config from API
801
819
  *
802
- * IMPORTANT: This method intentionally does NOT fetch fresh data after clearing.
820
+ * This method is designed for Cloudflare Workers where KV has edge caching.
821
+ * After clearing the KV entry, it fetches fresh data from the API and writes
822
+ * it back to KV. This ensures:
823
+ * 1. The global KV entry is deleted
824
+ * 2. Fresh data is fetched from API (with CDN cache bypass!)
825
+ * 3. New data is written to KV (updating edge cache)
803
826
  *
804
- * Why no cache warming?
805
- * - Upstream APIs (AgentShield) may have their own CDN/cache layers
806
- * - Immediately fetching after clear would get stale CDN data
807
- * - Storing stale data defeats the purpose of cache invalidation
808
- * - The next actual tool call will fetch fresh data (with natural delay)
827
+ * The next request from the same edge location will get the fresh data.
809
828
  *
810
- * This was the behavior in mcp-i-cloudflare@1.6.3 that worked perfectly.
811
- * Cache warming was added as an "optimization" that backfired when upstream
812
- * APIs have their own caching.
829
+ * IMPORTANT: This method uses bypassCDNCache to ensure we get fresh data
830
+ * from AgentShield's origin server, not stale CDN-cached data. This is
831
+ * critical for instant cache invalidation when tool protection settings
832
+ * are changed in the AgentShield dashboard.
813
833
  *
814
834
  * @param agentDid DID of the agent (used for cache key)
815
- * @returns Cache key that was cleared
835
+ * @returns The fresh tool protection config from API
816
836
  */
817
837
  async clearAndRefresh(agentDid: string): Promise<{
818
838
  config: ToolProtectionConfig;
819
839
  cacheKey: string;
820
- source: 'cleared';
840
+ source: 'api' | 'fallback';
821
841
  }> {
822
842
  const cacheKey = this.config.projectId
823
843
  ? `config:tool-protections:${this.config.projectId}`
824
844
  : `agent:${agentDid}`;
825
845
 
826
- console.log("[ToolProtectionService] clearCache (no warming)", {
846
+ console.log("[ToolProtectionService] clearAndRefresh starting", {
827
847
  cacheKey,
828
848
  projectId: this.config.projectId || "none",
829
849
  agentDid: agentDid.slice(0, 20) + "...",
830
850
  });
831
851
 
832
- // Delete the cache entry - that's it, no warming
852
+ // 1. Delete the cache entry
833
853
  await this.cache.delete(cacheKey);
834
854
 
835
- console.log("[ToolProtectionService] Cache entry deleted (next call will fetch fresh)", {
836
- cacheKey,
837
- note: "No cache warming - upstream APIs may have CDN caching",
838
- });
855
+ console.log("[ToolProtectionService] Cache entry deleted", { cacheKey });
839
856
 
840
- // Return empty config - next tool call will fetch fresh from API
841
- return {
842
- config: { toolProtections: {} },
843
- cacheKey,
844
- source: 'cleared'
845
- };
857
+ // 2. Fetch fresh config from API with CDN cache bypass
858
+ // This ensures we get fresh data from origin, not stale CDN data
859
+ try {
860
+ const response = await this.fetchFromApi(agentDid, { bypassCDNCache: true });
861
+
862
+ // Transform API response to internal format (same logic as getToolProtectionConfig)
863
+ const toolProtections: Record<string, ToolProtection> = {};
864
+
865
+ if (response.data.toolProtections) {
866
+ for (const [toolName, toolConfig] of Object.entries(
867
+ response.data.toolProtections
868
+ )) {
869
+ const requiresDelegation =
870
+ (toolConfig as any).requiresDelegation ??
871
+ (toolConfig as any).requires_delegation ??
872
+ false;
873
+ const requiredScopes =
874
+ (toolConfig as any).requiredScopes ??
875
+ (toolConfig as any).required_scopes ??
876
+ (toolConfig as any).scopes ??
877
+ [];
878
+ const oauthProvider =
879
+ (toolConfig as any).oauthProvider ??
880
+ (toolConfig as any).oauth_provider ??
881
+ undefined;
882
+ const riskLevel =
883
+ (toolConfig as any).riskLevel ??
884
+ (toolConfig as any).risk_level ??
885
+ undefined;
886
+
887
+ toolProtections[toolName] = {
888
+ requiresDelegation,
889
+ requiredScopes,
890
+ ...(oauthProvider && { oauthProvider }),
891
+ ...(riskLevel && { riskLevel }),
892
+ };
893
+ }
894
+ } else if (response.data.tools) {
895
+ if (Array.isArray(response.data.tools)) {
896
+ for (const tool of response.data.tools) {
897
+ const toolName = (tool as any).name;
898
+ if (!toolName) continue;
899
+ const requiresDelegation =
900
+ (tool as any).requiresDelegation ?? (tool as any).requires_delegation ?? false;
901
+ const requiredScopes =
902
+ (tool as any).requiredScopes ??
903
+ (tool as any).required_scopes ??
904
+ (tool as any).scopes ??
905
+ [];
906
+ const oauthProvider =
907
+ (tool as any).oauthProvider ?? (tool as any).oauth_provider ?? undefined;
908
+ const riskLevel = (tool as any).riskLevel ?? (tool as any).risk_level ?? undefined;
909
+
910
+ toolProtections[toolName] = {
911
+ requiresDelegation,
912
+ requiredScopes,
913
+ ...(oauthProvider && { oauthProvider }),
914
+ ...(riskLevel && { riskLevel }),
915
+ };
916
+ }
917
+ } else {
918
+ for (const [toolName, toolConfig] of Object.entries(
919
+ response.data.tools
920
+ )) {
921
+ const requiresDelegation =
922
+ (toolConfig as any).requiresDelegation ??
923
+ (toolConfig as any).requires_delegation ??
924
+ false;
925
+ const requiredScopes =
926
+ (toolConfig as any).requiredScopes ??
927
+ (toolConfig as any).required_scopes ??
928
+ (toolConfig as any).scopes ??
929
+ [];
930
+ const oauthProvider =
931
+ (toolConfig as any).oauthProvider ??
932
+ (toolConfig as any).oauth_provider ??
933
+ undefined;
934
+ const riskLevel =
935
+ (toolConfig as any).riskLevel ??
936
+ (toolConfig as any).risk_level ??
937
+ undefined;
938
+
939
+ toolProtections[toolName] = {
940
+ requiresDelegation,
941
+ requiredScopes,
942
+ ...(oauthProvider && { oauthProvider }),
943
+ ...(riskLevel && { riskLevel }),
944
+ };
945
+ }
946
+ }
947
+ }
948
+
949
+ // Construct fresh config (ToolProtectionConfig type)
950
+ const freshConfig: ToolProtectionConfig = {
951
+ toolProtections,
952
+ };
953
+
954
+ // 3. Write fresh config to cache
955
+ const ttl = this.config.cacheTtl ?? 300000;
956
+ await this.cache.set(cacheKey, freshConfig, ttl);
957
+
958
+ console.log("[ToolProtectionService] Fresh config fetched and cached", {
959
+ cacheKey,
960
+ toolCount: Object.keys(toolProtections).length,
961
+ protectedTools: Object.entries(toolProtections)
962
+ .filter(([_, cfg]) => cfg.requiresDelegation)
963
+ .map(([name]) => name),
964
+ source: "api",
965
+ });
966
+
967
+ return { config: freshConfig, cacheKey, source: 'api' };
968
+ } catch (error) {
969
+ console.warn("[ToolProtectionService] API fetch failed during refresh, using fallback", {
970
+ error: error instanceof Error ? error.message : String(error),
971
+ cacheKey,
972
+ });
973
+
974
+ // Use fallback config if API fails
975
+ const fallbackConfig: ToolProtectionConfig = this.config.fallbackConfig || {
976
+ toolProtections: {},
977
+ };
978
+
979
+ return { config: fallbackConfig, cacheKey, source: 'fallback' };
980
+ }
846
981
  }
847
982
  }
@@ -1,177 +0,0 @@
1
- /**
2
- * Cache Invalidation: No Warming Test
3
- *
4
- * CRITICAL TEST: Proves that clearAndRefresh does NOT fetch immediately after clearing.
5
- *
6
- * Background:
7
- * - In mcp-i-cloudflare@1.6.4+, cache warming was added as an "optimization"
8
- * - The idea was to fetch fresh data immediately after clearing
9
- * - BUT this backfired when upstream APIs (AgentShield) have CDN caching
10
- * - The immediate fetch would get stale CDN data and store it in our cache
11
- * - This defeated the purpose of cache invalidation
12
- *
13
- * Fix:
14
- * - clearAndRefresh now just DELETES the cache entry
15
- * - It does NOT fetch or warm the cache
16
- * - The next actual tool call will fetch fresh data (with natural delay)
17
- *
18
- * This was the behavior in mcp-i-cloudflare@1.6.3 that worked perfectly.
19
- */
20
-
21
- import { describe, it, expect, vi, beforeEach } from "vitest";
22
- import { ToolProtectionService } from "../../services/tool-protection.service.js";
23
- import { InMemoryToolProtectionCache } from "../../cache/tool-protection-cache.js";
24
-
25
- describe("Cache Invalidation - No Warming", () => {
26
- let cache: InMemoryToolProtectionCache;
27
- let service: ToolProtectionService;
28
- let fetchSpy: ReturnType<typeof vi.fn>;
29
-
30
- beforeEach(() => {
31
- cache = new InMemoryToolProtectionCache();
32
- fetchSpy = vi.fn();
33
-
34
- service = new ToolProtectionService(
35
- {
36
- apiUrl: "https://test.agentshield.com",
37
- apiKey: "test-key",
38
- projectId: "test-project",
39
- cacheTtl: 300000,
40
- debug: true,
41
- },
42
- cache
43
- );
44
-
45
- // Spy on the internal fetch method
46
- (service as any).fetchFromApi = fetchSpy;
47
- });
48
-
49
- it("clearAndRefresh should DELETE cache without fetching", async () => {
50
- // Seed the cache with old data
51
- const oldConfig = {
52
- toolProtections: {
53
- greet: { requiresDelegation: false, requiredScopes: ["greet:execute"] },
54
- },
55
- };
56
- await cache.set("config:tool-protections:test-project", oldConfig, 300000);
57
-
58
- // Clear the cache
59
- const result = await service.clearAndRefresh("did:key:test-agent");
60
-
61
- // CRITICAL: fetchFromApi should NOT have been called
62
- expect(fetchSpy).not.toHaveBeenCalled();
63
-
64
- // Result should indicate cache was cleared, not refreshed
65
- expect(result.source).toBe("cleared");
66
- expect(result.cacheKey).toBe("config:tool-protections:test-project");
67
- expect(result.config.toolProtections).toEqual({});
68
- });
69
-
70
- it("cache should be empty after clearAndRefresh (no warming)", async () => {
71
- // Seed the cache
72
- const oldConfig = {
73
- toolProtections: {
74
- greet: { requiresDelegation: true, requiredScopes: ["greet:execute"] },
75
- },
76
- };
77
- await cache.set("config:tool-protections:test-project", oldConfig, 300000);
78
-
79
- // Verify it's cached
80
- const beforeClear = await cache.get("config:tool-protections:test-project");
81
- expect(beforeClear).not.toBeNull();
82
- expect(beforeClear?.toolProtections.greet.requiresDelegation).toBe(true);
83
-
84
- // Clear the cache
85
- await service.clearAndRefresh("did:key:test-agent");
86
-
87
- // Cache should be EMPTY (not warmed with new data)
88
- const afterClear = await cache.get("config:tool-protections:test-project");
89
- expect(afterClear).toBeNull();
90
- });
91
-
92
- it("next getToolProtectionConfig call should fetch fresh from API", async () => {
93
- // Setup: Mock API to return DIFFERENT data than what was cached
94
- const staleConfig = {
95
- toolProtections: {
96
- greet: { requiresDelegation: false, requiredScopes: [] },
97
- },
98
- };
99
-
100
- // Seed cache with stale data
101
- await cache.set(
102
- "config:tool-protections:test-project",
103
- staleConfig,
104
- 300000
105
- );
106
-
107
- // Mock API to return fresh data (requiresDelegation: TRUE)
108
- fetchSpy.mockResolvedValueOnce({
109
- data: {
110
- toolProtections: {
111
- greet: {
112
- requiresDelegation: true,
113
- requiredScopes: ["greet:execute"],
114
- },
115
- },
116
- },
117
- });
118
-
119
- // Clear cache (should NOT fetch)
120
- await service.clearAndRefresh("did:key:test-agent");
121
- expect(fetchSpy).not.toHaveBeenCalled();
122
-
123
- // Now get config - this SHOULD fetch from API
124
- const result = await service.getToolProtectionConfig("did:key:test-agent");
125
-
126
- // API should have been called now (cache was cleared, so it fetched)
127
- expect(fetchSpy).toHaveBeenCalledTimes(1);
128
-
129
- // Result should have fresh data from API (not stale cached data)
130
- expect(result.toolProtections.greet.requiresDelegation).toBe(true);
131
- });
132
-
133
- it("proves stale CDN data issue is avoided", async () => {
134
- /**
135
- * This test proves why NO warming is better than warming.
136
- *
137
- * Scenario:
138
- * 1. User toggles delegation ON in dashboard
139
- * 2. AgentShield DB updates, but CDN still has old data (5 min TTL)
140
- * 3. User triggers cache clear
141
- *
142
- * WITH warming (broken):
143
- * - clearAndRefresh immediately fetches from API
144
- * - API returns stale CDN data (requiresDelegation: false)
145
- * - We store stale data in our cache
146
- * - Tool call uses stale data = BUG
147
- *
148
- * WITHOUT warming (fixed):
149
- * - clearAndRefresh just deletes cache
150
- * - Next tool call fetches from API (some time later)
151
- * - Better chance CDN has refreshed
152
- * - Even if CDN still stale, user can retry after CDN TTL
153
- */
154
-
155
- // Simulate stale CDN response (old config before user toggled delegation)
156
- const staleCdnResponse = {
157
- data: {
158
- toolProtections: {
159
- greet: { requiresDelegation: false, requiredScopes: [] },
160
- },
161
- },
162
- };
163
-
164
- fetchSpy.mockResolvedValue(staleCdnResponse);
165
-
166
- // Clear cache
167
- await service.clearAndRefresh("did:key:test-agent");
168
-
169
- // CRITICAL: We did NOT store the stale CDN data
170
- // fetchSpy was NOT called during clearAndRefresh
171
- expect(fetchSpy).not.toHaveBeenCalled();
172
-
173
- // Cache is empty, not poisoned with stale data
174
- const cached = await cache.get("config:tool-protections:test-project");
175
- expect(cached).toBeNull();
176
- });
177
- });