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

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.
Files changed (34) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/.turbo/turbo-test$colon$coverage.log +2579 -2669
  3. package/.turbo/turbo-test.log +1251 -1245
  4. package/coverage/coverage-final.json +4 -4
  5. package/dist/services/oauth-config.service.d.ts.map +1 -1
  6. package/dist/services/oauth-config.service.js +5 -3
  7. package/dist/services/oauth-config.service.js.map +1 -1
  8. package/dist/services/oauth-provider-registry.d.ts +11 -0
  9. package/dist/services/oauth-provider-registry.d.ts.map +1 -1
  10. package/dist/services/oauth-provider-registry.js +16 -0
  11. package/dist/services/oauth-provider-registry.js.map +1 -1
  12. package/dist/services/provider-resolver.d.ts +1 -1
  13. package/dist/services/provider-resolver.d.ts.map +1 -1
  14. package/dist/services/provider-resolver.js +14 -13
  15. package/dist/services/provider-resolver.js.map +1 -1
  16. package/dist/services/tool-protection.service.d.ts +13 -10
  17. package/dist/services/tool-protection.service.d.ts.map +1 -1
  18. package/dist/services/tool-protection.service.js +24 -113
  19. package/dist/services/tool-protection.service.js.map +1 -1
  20. package/package.json +2 -2
  21. package/src/__tests__/regression/phase2-regression.test.ts +8 -6
  22. package/src/__tests__/services/cache-no-warming.test.ts +177 -0
  23. package/src/__tests__/services/provider-resolver-edge-cases.test.ts +168 -64
  24. package/src/services/__tests__/provider-resolution.integration.test.ts +9 -5
  25. package/src/services/__tests__/provider-resolver.test.ts +22 -26
  26. package/src/services/oauth-config.service.ts +6 -3
  27. package/src/services/oauth-provider-registry.ts +18 -0
  28. package/src/services/provider-resolver.ts +15 -13
  29. package/src/services/tool-protection.service.ts +25 -136
  30. package/src/cache/oauth-config-cache.js +0 -71
  31. package/src/providers/base.js +0 -38
  32. package/src/services/oauth-config.service.js +0 -113
  33. package/src/services/oauth-provider-registry.js +0 -73
  34. package/src/services/provider-resolver.js +0 -106
@@ -0,0 +1,177 @@
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
+ });
@@ -57,12 +57,14 @@ describe("ProviderResolver - Edge Cases", () => {
57
57
  const getProviderNamesMock = vi.fn().mockReturnValue([]);
58
58
  const loadFromAgentShieldMock = vi.fn().mockResolvedValue(undefined);
59
59
  const hasProviderMock = vi.fn().mockReturnValue(false);
60
+ const getConfiguredProviderMock = vi.fn().mockReturnValue(null);
60
61
 
61
62
  mockRegistry = {
62
63
  hasProvider: hasProviderMock,
63
64
  getAllProviders: vi.fn().mockReturnValue([]),
64
65
  getProviderNames: getProviderNamesMock,
65
66
  loadFromAgentShield: loadFromAgentShieldMock,
67
+ getConfiguredProvider: getConfiguredProviderMock,
66
68
  } as any;
67
69
 
68
70
  resolver = new ProviderResolver(mockRegistry, mockConfigService);
@@ -154,19 +156,16 @@ describe("ProviderResolver - Edge Cases", () => {
154
156
  requiredScopes: [],
155
157
  };
156
158
 
157
- // Should fall through to Priority 3
158
- (mockRegistry.hasProvider as any).mockReturnValue(false);
159
- (mockRegistry.getAllProviders as any).mockReturnValue([
160
- { clientId: "github_client_id" },
161
- ]);
162
- (mockRegistry.getProviderNames as any).mockReturnValue(["github"]);
159
+ // Should fall through to Priority 3 (configuredProvider)
160
+ (mockRegistry.hasProvider as any).mockImplementation((name: string) => name === "github");
161
+ (mockRegistry.getConfiguredProvider as any).mockReturnValue("github");
163
162
 
164
163
  const provider = await resolver.resolveProvider(
165
164
  toolProtection,
166
165
  "test-project"
167
166
  );
168
167
 
169
- // Should use first configured provider (Priority 3)
168
+ // Should use configured provider (Priority 3)
170
169
  expect(provider).toBe("github");
171
170
  });
172
171
 
@@ -176,19 +175,16 @@ describe("ProviderResolver - Edge Cases", () => {
176
175
  requiredScopes: ["github:repo:read", "google:calendar:read"],
177
176
  };
178
177
 
179
- // Ambiguous scopes should fall through to Priority 3
180
- (mockRegistry.hasProvider as any).mockReturnValue(false);
181
- (mockRegistry.getAllProviders as any).mockReturnValue([
182
- { clientId: "github_client_id" },
183
- ]);
184
- (mockRegistry.getProviderNames as any).mockReturnValue(["github"]);
178
+ // Ambiguous scopes should fall through to Priority 3 (configuredProvider)
179
+ (mockRegistry.hasProvider as any).mockImplementation((name: string) => name === "github");
180
+ (mockRegistry.getConfiguredProvider as any).mockReturnValue("github");
185
181
 
186
182
  const provider = await resolver.resolveProvider(
187
183
  toolProtection,
188
184
  "test-project"
189
185
  );
190
186
 
191
- // Should use first configured provider (Priority 3)
187
+ // Should use configured provider (Priority 3)
192
188
  expect(provider).toBe("github");
193
189
  });
194
190
 
@@ -198,19 +194,16 @@ describe("ProviderResolver - Edge Cases", () => {
198
194
  requiredScopes: ["custom:unknown:scope"],
199
195
  };
200
196
 
201
- // Unknown prefix should fall through to Priority 3
202
- (mockRegistry.hasProvider as any).mockReturnValue(false);
203
- (mockRegistry.getAllProviders as any).mockReturnValue([
204
- { clientId: "github_client_id" },
205
- ]);
206
- (mockRegistry.getProviderNames as any).mockReturnValue(["github"]);
197
+ // Unknown prefix should fall through to Priority 3 (configuredProvider)
198
+ (mockRegistry.hasProvider as any).mockImplementation((name: string) => name === "github");
199
+ (mockRegistry.getConfiguredProvider as any).mockReturnValue("github");
207
200
 
208
201
  const provider = await resolver.resolveProvider(
209
202
  toolProtection,
210
203
  "test-project"
211
204
  );
212
205
 
213
- // Should use first configured provider (Priority 3)
206
+ // Should use configured provider (Priority 3)
214
207
  expect(provider).toBe("github");
215
208
  });
216
209
 
@@ -277,19 +270,16 @@ describe("ProviderResolver - Edge Cases", () => {
277
270
  requiredScopes: ["read", "write"], // No colons
278
271
  };
279
272
 
280
- // Should fall through to Priority 3
281
- (mockRegistry.hasProvider as any).mockReturnValue(false);
282
- (mockRegistry.getAllProviders as any).mockReturnValue([
283
- { clientId: "github_client_id" },
284
- ]);
285
- (mockRegistry.getProviderNames as any).mockReturnValue(["github"]);
273
+ // Should fall through to Priority 3 (configuredProvider)
274
+ (mockRegistry.hasProvider as any).mockImplementation((name: string) => name === "github");
275
+ (mockRegistry.getConfiguredProvider as any).mockReturnValue("github");
286
276
 
287
277
  const provider = await resolver.resolveProvider(
288
278
  toolProtection,
289
279
  "test-project"
290
280
  );
291
281
 
292
- // Should use first configured provider (Priority 3)
282
+ // Should use configured provider (Priority 3)
293
283
  expect(provider).toBe("github");
294
284
  });
295
285
 
@@ -299,90 +289,102 @@ describe("ProviderResolver - Edge Cases", () => {
299
289
  requiredScopes: ["github:repo:read"],
300
290
  };
301
291
 
302
- // Inferred provider exists but not in registry
303
- (mockRegistry.getProviderNames as any).mockReturnValue(["google"]); // Only google configured
304
- (mockRegistry.hasProvider as any).mockReturnValue(false); // github not in registry
305
-
306
- // Should fall through to Priority 3
307
- (mockRegistry.getAllProviders as any).mockReturnValue([
308
- { clientId: "google_client_id" },
309
- ]);
292
+ // Inferred provider (github) exists but not in registry
293
+ // configuredProvider is google
310
294
  (mockRegistry.getProviderNames as any).mockReturnValue(["google"]);
295
+ (mockRegistry.hasProvider as any).mockImplementation((name: string) => name === "google");
296
+ (mockRegistry.getConfiguredProvider as any).mockReturnValue("google");
311
297
 
312
298
  const provider = await resolver.resolveProvider(
313
299
  toolProtection,
314
300
  "test-project"
315
301
  );
316
302
 
317
- // Should use first configured provider (Priority 3)
303
+ // Should use configured provider (Priority 3)
318
304
  expect(provider).toBe("google");
319
305
  });
320
306
  });
321
307
 
322
- describe("Fallback behavior (Priority 3)", () => {
323
- it("should log deprecation warning for Phase 1 fallback", async () => {
308
+ describe("Fallback behavior (Priority 3 - configuredProvider)", () => {
309
+ it("should log warning when using configuredProvider fallback", async () => {
324
310
  const toolProtection: ToolProtection = {
325
311
  requiresDelegation: true,
326
312
  requiredScopes: ["custom:scope"],
327
313
  };
328
314
 
329
- (mockRegistry.hasProvider as any).mockReturnValue(false);
330
- (mockRegistry.getAllProviders as any).mockReturnValue([
331
- { clientId: "github_client_id" },
332
- ]);
333
- (mockRegistry.getProviderNames as any).mockReturnValue(["github"]);
315
+ (mockRegistry.hasProvider as any).mockImplementation((name: string) => name === "github");
316
+ (mockRegistry.getConfiguredProvider as any).mockReturnValue("github");
334
317
 
335
318
  const consoleSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
336
319
 
337
320
  await resolver.resolveProvider(toolProtection, "test-project");
338
321
 
339
322
  expect(consoleSpy).toHaveBeenCalledWith(
340
- expect.stringContaining("deprecated")
323
+ expect.stringContaining("project-configured provider")
341
324
  );
342
325
  expect(consoleSpy).toHaveBeenCalledWith(
343
- expect.stringContaining("first configured provider")
326
+ expect.stringContaining("github")
344
327
  );
345
328
 
346
329
  consoleSpy.mockRestore();
347
330
  });
348
331
 
349
- it("should use first configured provider when oauthProvider not specified", async () => {
332
+ it("should use configuredProvider when oauthProvider not specified", async () => {
350
333
  const toolProtection: ToolProtection = {
351
334
  requiresDelegation: true,
352
335
  requiredScopes: ["custom:scope"],
353
336
  };
354
337
 
338
+ // Multiple providers available, but configuredProvider is google
339
+ (mockRegistry.hasProvider as any).mockImplementation((name: string) =>
340
+ name === "github" || name === "google"
341
+ );
342
+ (mockRegistry.getConfiguredProvider as any).mockReturnValue("google");
343
+
344
+ const provider = await resolver.resolveProvider(
345
+ toolProtection,
346
+ "test-project"
347
+ );
348
+
349
+ // Should use configuredProvider, not first alphabetically
350
+ expect(provider).toBe("google");
351
+ });
352
+
353
+ it("should NOT fall back to first provider alphabetically", async () => {
354
+ const toolProtection: ToolProtection = {
355
+ requiresDelegation: true,
356
+ requiredScopes: ["custom:scope"],
357
+ };
358
+
359
+ // Multiple providers in registry, but NO configuredProvider
355
360
  (mockRegistry.hasProvider as any).mockReturnValue(false);
361
+ (mockRegistry.getConfiguredProvider as any).mockReturnValue(null);
356
362
  (mockRegistry.getAllProviders as any).mockReturnValue([
357
363
  { clientId: "github_client_id" },
358
364
  { clientId: "google_client_id" },
359
365
  ]);
360
366
  (mockRegistry.getProviderNames as any).mockReturnValue(["github", "google"]);
361
367
 
362
- const provider = await resolver.resolveProvider(
363
- toolProtection,
364
- "test-project"
365
- );
366
-
367
- // Should use first provider
368
- expect(provider).toBe("github");
368
+ // Should throw error, NOT pick first provider
369
+ await expect(
370
+ resolver.resolveProvider(toolProtection, "test-project")
371
+ ).rejects.toThrow(/no provider is configured/);
369
372
  });
370
373
  });
371
374
 
372
375
  describe("Error scenarios (Priority 4)", () => {
373
- it("should throw error if no providers configured", async () => {
376
+ it("should throw error if no provider is configured", async () => {
374
377
  const toolProtection: ToolProtection = {
375
378
  requiresDelegation: true,
376
379
  requiredScopes: [],
377
380
  };
378
381
 
379
382
  (mockRegistry.hasProvider as any).mockReturnValue(false);
380
- (mockRegistry.getAllProviders as any).mockReturnValue([]);
381
- (mockRegistry.getProviderNames as any).mockReturnValue([]);
383
+ (mockRegistry.getConfiguredProvider as any).mockReturnValue(null);
382
384
 
383
385
  await expect(
384
386
  resolver.resolveProvider(toolProtection, "test-project")
385
- ).rejects.toThrow(/no provider could be resolved/);
387
+ ).rejects.toThrow(/no provider is configured/);
386
388
  });
387
389
 
388
390
  it("should include project ID in error message", async () => {
@@ -392,8 +394,7 @@ describe("ProviderResolver - Edge Cases", () => {
392
394
  };
393
395
 
394
396
  (mockRegistry.hasProvider as any).mockReturnValue(false);
395
- (mockRegistry.getAllProviders as any).mockReturnValue([]);
396
- (mockRegistry.getProviderNames as any).mockReturnValue([]);
397
+ (mockRegistry.getConfiguredProvider as any).mockReturnValue(null);
397
398
 
398
399
  try {
399
400
  await resolver.resolveProvider(toolProtection, "test-project-123");
@@ -409,15 +410,14 @@ describe("ProviderResolver - Edge Cases", () => {
409
410
  };
410
411
 
411
412
  (mockRegistry.hasProvider as any).mockReturnValue(false);
412
- (mockRegistry.getAllProviders as any).mockReturnValue([]);
413
- (mockRegistry.getProviderNames as any).mockReturnValue([]);
413
+ (mockRegistry.getConfiguredProvider as any).mockReturnValue(null);
414
414
 
415
415
  try {
416
416
  await resolver.resolveProvider(toolProtection, "test-project");
417
417
  } catch (error) {
418
418
  const message = (error as Error).message;
419
- expect(message).toContain("oauthProvider");
420
- expect(message).toContain("configure");
419
+ expect(message).toContain("AgentShield dashboard");
420
+ expect(message).toContain("Configure");
421
421
  }
422
422
  });
423
423
  });
@@ -483,5 +483,109 @@ describe("ProviderResolver - Edge Cases", () => {
483
483
  expect(provider).toBe("github");
484
484
  });
485
485
  });
486
+
487
+ describe("configuredProvider behavior (Priority 3)", () => {
488
+ it("should use configuredProvider when tool has no oauthProvider", async () => {
489
+ const toolProtection: ToolProtection = {
490
+ requiresDelegation: true,
491
+ requiredScopes: ["greet:execute"], // No scope inference match
492
+ };
493
+
494
+ // configuredProvider is github
495
+ (mockRegistry.hasProvider as any).mockImplementation((name: string) => name === "github");
496
+ (mockRegistry.getConfiguredProvider as any).mockReturnValue("github");
497
+
498
+ const provider = await resolver.resolveProvider(
499
+ toolProtection,
500
+ "test-project"
501
+ );
502
+
503
+ expect(provider).toBe("github");
504
+ expect(mockRegistry.getConfiguredProvider).toHaveBeenCalled();
505
+ });
506
+
507
+ it("should throw when configuredProvider is null and tool needs OAuth", async () => {
508
+ const toolProtection: ToolProtection = {
509
+ requiresDelegation: true,
510
+ requiredScopes: ["greet:execute"],
511
+ };
512
+
513
+ // No configuredProvider set
514
+ (mockRegistry.hasProvider as any).mockReturnValue(false);
515
+ (mockRegistry.getConfiguredProvider as any).mockReturnValue(null);
516
+
517
+ await expect(
518
+ resolver.resolveProvider(toolProtection, "test-project")
519
+ ).rejects.toThrow(/no provider is configured/);
520
+ });
521
+
522
+ it("should NOT use unconfigured providers as fallback", async () => {
523
+ const toolProtection: ToolProtection = {
524
+ requiresDelegation: true,
525
+ requiredScopes: ["greet:execute"],
526
+ };
527
+
528
+ // Registry has providers but configuredProvider is null
529
+ // This simulates AgentShield returning all providers but none configured
530
+ (mockRegistry.hasProvider as any).mockReturnValue(false);
531
+ (mockRegistry.getConfiguredProvider as any).mockReturnValue(null);
532
+ (mockRegistry.getAllProviders as any).mockReturnValue([
533
+ { clientId: "github_client_id" },
534
+ { clientId: "google_client_id" },
535
+ { clientId: "microsoft_client_id" },
536
+ ]);
537
+ (mockRegistry.getProviderNames as any).mockReturnValue(["github", "google", "microsoft"]);
538
+
539
+ // Should NOT pick "github" alphabetically - should throw
540
+ await expect(
541
+ resolver.resolveProvider(toolProtection, "test-project")
542
+ ).rejects.toThrow(/no provider is configured/);
543
+ });
544
+
545
+ it("should prefer tool-specific oauthProvider over configuredProvider", async () => {
546
+ const toolProtection: ToolProtection = {
547
+ requiresDelegation: true,
548
+ requiredScopes: [],
549
+ oauthProvider: "google", // Tool specifies google
550
+ };
551
+
552
+ // configuredProvider is github, but tool wants google
553
+ (mockRegistry.hasProvider as any).mockImplementation((name: string) =>
554
+ name === "github" || name === "google"
555
+ );
556
+ (mockRegistry.getConfiguredProvider as any).mockReturnValue("github");
557
+ (mockRegistry.getProviderNames as any).mockReturnValue(["github", "google"]);
558
+
559
+ const provider = await resolver.resolveProvider(
560
+ toolProtection,
561
+ "test-project"
562
+ );
563
+
564
+ // Priority 1 (tool-specific) should win over Priority 3 (configuredProvider)
565
+ expect(provider).toBe("google");
566
+ });
567
+
568
+ it("should prefer scope-inferred provider over configuredProvider", async () => {
569
+ const toolProtection: ToolProtection = {
570
+ requiresDelegation: true,
571
+ requiredScopes: ["google:calendar:read"], // Infers google
572
+ };
573
+
574
+ // configuredProvider is github, but scopes infer google
575
+ (mockRegistry.hasProvider as any).mockImplementation((name: string) =>
576
+ name === "github" || name === "google"
577
+ );
578
+ (mockRegistry.getConfiguredProvider as any).mockReturnValue("github");
579
+ (mockRegistry.getProviderNames as any).mockReturnValue(["github", "google"]);
580
+
581
+ const provider = await resolver.resolveProvider(
582
+ toolProtection,
583
+ "test-project"
584
+ );
585
+
586
+ // Priority 2 (scope inference) should win over Priority 3 (configuredProvider)
587
+ expect(provider).toBe("google");
588
+ });
589
+ });
486
590
  });
487
591
 
@@ -37,6 +37,8 @@ describe("Provider Resolution Integration", () => {
37
37
  requiresClientSecret: false,
38
38
  },
39
39
  },
40
+ // Explicitly configured provider (Priority 3 fallback)
41
+ configuredProvider: "github",
40
42
  };
41
43
 
42
44
  beforeEach(() => {
@@ -95,7 +97,7 @@ describe("Provider Resolution Integration", () => {
95
97
  consoleSpy.mockRestore();
96
98
  });
97
99
 
98
- it("should fall back to first configured provider for Phase 1 compatibility", async () => {
100
+ it("should fall back to configuredProvider for Phase 1 compatibility", async () => {
99
101
  await providerRegistry.loadFromAgentShield("test-project");
100
102
 
101
103
  const toolProtection: ToolProtection = {
@@ -111,10 +113,10 @@ describe("Provider Resolution Integration", () => {
111
113
  "test-project"
112
114
  );
113
115
 
114
- // Should use first configured provider (github)
116
+ // Should use configuredProvider (github)
115
117
  expect(provider).toBe("github");
116
118
  expect(consoleSpy).toHaveBeenCalledWith(
117
- expect.stringContaining("deprecated")
119
+ expect.stringContaining("project-configured provider")
118
120
  );
119
121
 
120
122
  consoleSpy.mockRestore();
@@ -157,9 +159,11 @@ describe("Provider Resolution Integration", () => {
157
159
  "test-project"
158
160
  );
159
161
 
160
- // Should fall back to first provider
162
+ // Should fall back to configuredProvider
161
163
  expect(provider).toBe("github");
162
- expect(consoleSpy).toHaveBeenCalled();
164
+ expect(consoleSpy).toHaveBeenCalledWith(
165
+ expect.stringContaining("project-configured provider")
166
+ );
163
167
 
164
168
  consoleSpy.mockRestore();
165
169
  });