@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.
- package/.turbo/turbo-build.log +1 -1
- package/.turbo/turbo-test$colon$coverage.log +2530 -2522
- package/.turbo/turbo-test.log +1635 -1635
- package/coverage/coverage-final.json +1 -1
- package/dist/services/oauth-config.service.d.ts.map +1 -1
- package/dist/services/oauth-config.service.js +27 -5
- package/dist/services/oauth-config.service.js.map +1 -1
- package/dist/services/tool-protection.service.d.ts +16 -12
- package/dist/services/tool-protection.service.d.ts.map +1 -1
- package/dist/services/tool-protection.service.js +134 -24
- package/dist/services/tool-protection.service.js.map +1 -1
- package/package.json +1 -1
- package/src/__tests__/services/cache-busting.test.ts +125 -0
- package/src/services/oauth-config.service.ts +28 -5
- package/src/services/tool-protection.service.ts +160 -25
- package/src/__tests__/services/cache-no-warming.test.ts +0 -177
|
@@ -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
|
|
818
|
+
* Clear cache and immediately fetch fresh config from API
|
|
801
819
|
*
|
|
802
|
-
*
|
|
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
|
-
*
|
|
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
|
|
811
|
-
*
|
|
812
|
-
*
|
|
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
|
|
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: '
|
|
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]
|
|
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
|
|
852
|
+
// 1. Delete the cache entry
|
|
833
853
|
await this.cache.delete(cacheKey);
|
|
834
854
|
|
|
835
|
-
console.log("[ToolProtectionService] Cache entry deleted
|
|
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
|
-
//
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
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
|
-
});
|