kiln-ai 0.19.0__py3-none-any.whl → 0.20.1__py3-none-any.whl

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.

Potentially problematic release.


This version of kiln-ai might be problematic. Click here for more details.

Files changed (70) hide show
  1. kiln_ai/adapters/__init__.py +2 -2
  2. kiln_ai/adapters/adapter_registry.py +19 -1
  3. kiln_ai/adapters/chat/chat_formatter.py +8 -12
  4. kiln_ai/adapters/chat/test_chat_formatter.py +6 -2
  5. kiln_ai/adapters/docker_model_runner_tools.py +119 -0
  6. kiln_ai/adapters/eval/base_eval.py +2 -2
  7. kiln_ai/adapters/eval/eval_runner.py +3 -1
  8. kiln_ai/adapters/eval/g_eval.py +2 -2
  9. kiln_ai/adapters/eval/test_base_eval.py +1 -1
  10. kiln_ai/adapters/eval/test_g_eval.py +3 -4
  11. kiln_ai/adapters/fine_tune/__init__.py +1 -1
  12. kiln_ai/adapters/fine_tune/openai_finetune.py +14 -4
  13. kiln_ai/adapters/fine_tune/test_openai_finetune.py +108 -111
  14. kiln_ai/adapters/ml_model_list.py +380 -34
  15. kiln_ai/adapters/model_adapters/base_adapter.py +51 -21
  16. kiln_ai/adapters/model_adapters/litellm_adapter.py +383 -79
  17. kiln_ai/adapters/model_adapters/test_base_adapter.py +193 -17
  18. kiln_ai/adapters/model_adapters/test_litellm_adapter.py +406 -1
  19. kiln_ai/adapters/model_adapters/test_litellm_adapter_tools.py +1103 -0
  20. kiln_ai/adapters/model_adapters/test_saving_adapter_results.py +5 -5
  21. kiln_ai/adapters/model_adapters/test_structured_output.py +110 -4
  22. kiln_ai/adapters/parsers/__init__.py +1 -1
  23. kiln_ai/adapters/provider_tools.py +15 -1
  24. kiln_ai/adapters/repair/test_repair_task.py +12 -9
  25. kiln_ai/adapters/run_output.py +3 -0
  26. kiln_ai/adapters/test_adapter_registry.py +80 -1
  27. kiln_ai/adapters/test_docker_model_runner_tools.py +305 -0
  28. kiln_ai/adapters/test_ml_model_list.py +39 -1
  29. kiln_ai/adapters/test_prompt_adaptors.py +13 -6
  30. kiln_ai/adapters/test_provider_tools.py +55 -0
  31. kiln_ai/adapters/test_remote_config.py +98 -0
  32. kiln_ai/datamodel/__init__.py +23 -21
  33. kiln_ai/datamodel/datamodel_enums.py +1 -0
  34. kiln_ai/datamodel/eval.py +1 -1
  35. kiln_ai/datamodel/external_tool_server.py +298 -0
  36. kiln_ai/datamodel/json_schema.py +25 -10
  37. kiln_ai/datamodel/project.py +8 -1
  38. kiln_ai/datamodel/registry.py +0 -15
  39. kiln_ai/datamodel/run_config.py +62 -0
  40. kiln_ai/datamodel/task.py +2 -77
  41. kiln_ai/datamodel/task_output.py +6 -1
  42. kiln_ai/datamodel/task_run.py +41 -0
  43. kiln_ai/datamodel/test_basemodel.py +3 -3
  44. kiln_ai/datamodel/test_example_models.py +175 -0
  45. kiln_ai/datamodel/test_external_tool_server.py +691 -0
  46. kiln_ai/datamodel/test_registry.py +8 -3
  47. kiln_ai/datamodel/test_task.py +15 -47
  48. kiln_ai/datamodel/test_tool_id.py +239 -0
  49. kiln_ai/datamodel/tool_id.py +83 -0
  50. kiln_ai/tools/__init__.py +8 -0
  51. kiln_ai/tools/base_tool.py +82 -0
  52. kiln_ai/tools/built_in_tools/__init__.py +13 -0
  53. kiln_ai/tools/built_in_tools/math_tools.py +124 -0
  54. kiln_ai/tools/built_in_tools/test_math_tools.py +204 -0
  55. kiln_ai/tools/mcp_server_tool.py +95 -0
  56. kiln_ai/tools/mcp_session_manager.py +243 -0
  57. kiln_ai/tools/test_base_tools.py +199 -0
  58. kiln_ai/tools/test_mcp_server_tool.py +457 -0
  59. kiln_ai/tools/test_mcp_session_manager.py +1585 -0
  60. kiln_ai/tools/test_tool_registry.py +473 -0
  61. kiln_ai/tools/tool_registry.py +64 -0
  62. kiln_ai/utils/config.py +22 -0
  63. kiln_ai/utils/open_ai_types.py +94 -0
  64. kiln_ai/utils/project_utils.py +17 -0
  65. kiln_ai/utils/test_config.py +138 -1
  66. kiln_ai/utils/test_open_ai_types.py +131 -0
  67. {kiln_ai-0.19.0.dist-info → kiln_ai-0.20.1.dist-info}/METADATA +6 -5
  68. {kiln_ai-0.19.0.dist-info → kiln_ai-0.20.1.dist-info}/RECORD +70 -47
  69. {kiln_ai-0.19.0.dist-info → kiln_ai-0.20.1.dist-info}/WHEEL +0 -0
  70. {kiln_ai-0.19.0.dist-info → kiln_ai-0.20.1.dist-info}/licenses/LICENSE.txt +0 -0
@@ -0,0 +1,691 @@
1
+ from typing import Any, Dict
2
+ from unittest.mock import Mock, patch
3
+
4
+ import pytest
5
+
6
+ from kiln_ai.datamodel.external_tool_server import ExternalToolServer, ToolServerType
7
+ from kiln_ai.utils.config import MCP_SECRETS_KEY, Config
8
+ from kiln_ai.utils.exhaustive_error import raise_exhaustive_enum_error
9
+
10
+
11
+ class TestExternalToolServer:
12
+ @pytest.fixture
13
+ def mock_config(self):
14
+ """Mock Config.shared() to avoid file system dependencies."""
15
+ with patch.object(Config, "shared") as mock_shared:
16
+ config_instance = Mock()
17
+ config_instance.get_value.return_value = {}
18
+ config_instance.update_settings = Mock()
19
+ mock_shared.return_value = config_instance
20
+ yield config_instance
21
+
22
+ @pytest.fixture
23
+ def remote_mcp_base_props(self) -> Dict[str, Any]:
24
+ """Base properties for remote MCP server."""
25
+ return {
26
+ "server_url": "https://api.example.com/mcp",
27
+ "headers": {"Content-Type": "application/json"},
28
+ }
29
+
30
+ @pytest.fixture
31
+ def local_mcp_base_props(self) -> Dict[str, Any]:
32
+ """Base properties for local MCP server."""
33
+ return {
34
+ "command": "python",
35
+ "args": ["-m", "mcp_server"],
36
+ "env_vars": {},
37
+ }
38
+
39
+ @pytest.mark.parametrize(
40
+ "server_type, properties",
41
+ [
42
+ (
43
+ ToolServerType.remote_mcp,
44
+ {
45
+ "server_url": "https://api.example.com/mcp",
46
+ "headers": {"Authorization": "Bearer token123"},
47
+ },
48
+ ),
49
+ (
50
+ ToolServerType.local_mcp,
51
+ {
52
+ "command": "python",
53
+ "args": ["-m", "server"],
54
+ "env_vars": {"API_KEY": "secret123"},
55
+ },
56
+ ),
57
+ ],
58
+ )
59
+ def test_valid_server_creation(self, mock_config, server_type, properties):
60
+ """Test creating valid servers of both types."""
61
+ server = ExternalToolServer(
62
+ name="test-server",
63
+ type=server_type,
64
+ description="Test server",
65
+ properties=properties,
66
+ )
67
+
68
+ assert server.name == "test-server"
69
+ assert server.type == server_type
70
+ assert server.description == "Test server"
71
+ assert server.properties == properties
72
+
73
+ @pytest.mark.parametrize(
74
+ "server_type, invalid_props, expected_error",
75
+ [
76
+ # Remote MCP validation errors
77
+ (ToolServerType.remote_mcp, {}, "server_url must be a string"),
78
+ (ToolServerType.remote_mcp, {"server_url": ""}, "server_url is required"),
79
+ (
80
+ ToolServerType.remote_mcp,
81
+ {"server_url": 123},
82
+ "server_url must be a string",
83
+ ),
84
+ (
85
+ ToolServerType.remote_mcp,
86
+ {"server_url": "http://test.com"},
87
+ "headers must be set",
88
+ ),
89
+ (
90
+ ToolServerType.remote_mcp,
91
+ {"server_url": "http://test.com", "headers": "not-a-dict"},
92
+ "headers must be a dictionary",
93
+ ),
94
+ (
95
+ ToolServerType.remote_mcp,
96
+ {
97
+ "server_url": "http://test.com",
98
+ "headers": {},
99
+ "secret_header_keys": "not-a-list",
100
+ },
101
+ "secret_header_keys must be a list",
102
+ ),
103
+ (
104
+ ToolServerType.remote_mcp,
105
+ {
106
+ "server_url": "http://test.com",
107
+ "headers": {},
108
+ "secret_header_keys": [123],
109
+ },
110
+ "secret_header_keys must contain only strings",
111
+ ),
112
+ # Local MCP validation errors
113
+ (ToolServerType.local_mcp, {}, "command must be a string"),
114
+ (ToolServerType.local_mcp, {"command": ""}, "command is required"),
115
+ (ToolServerType.local_mcp, {"command": 123}, "command must be a string"),
116
+ (
117
+ ToolServerType.local_mcp,
118
+ {"command": "python"},
119
+ "arguments must be a list",
120
+ ),
121
+ (
122
+ ToolServerType.local_mcp,
123
+ {"command": "python", "args": "not-a-list"},
124
+ "arguments must be a list",
125
+ ),
126
+ (
127
+ ToolServerType.local_mcp,
128
+ {"command": "python", "args": [], "env_vars": "not-a-dict"},
129
+ "environment variables must be a dictionary",
130
+ ),
131
+ (
132
+ ToolServerType.local_mcp,
133
+ {
134
+ "command": "python",
135
+ "args": [],
136
+ "env_vars": {},
137
+ "secret_env_var_keys": "not-a-list",
138
+ },
139
+ "secret_env_var_keys must be a list",
140
+ ),
141
+ (
142
+ ToolServerType.local_mcp,
143
+ {
144
+ "command": "python",
145
+ "args": [],
146
+ "env_vars": {},
147
+ "secret_env_var_keys": [123],
148
+ },
149
+ "secret_env_var_keys must contain only strings",
150
+ ),
151
+ ],
152
+ )
153
+ def test_validation_errors(
154
+ self, mock_config, server_type, invalid_props, expected_error
155
+ ):
156
+ """Test validation errors for invalid configurations."""
157
+ with pytest.raises((ValueError, Exception)) as exc_info:
158
+ ExternalToolServer(
159
+ name="test-server", type=server_type, properties=invalid_props
160
+ )
161
+ # Check that the expected error message is in the exception string
162
+ assert expected_error in str(exc_info.value)
163
+
164
+ def test_get_secret_keys_remote_mcp(self, mock_config, remote_mcp_base_props):
165
+ """Test get_secret_keys for remote MCP servers."""
166
+ # No secret keys defined
167
+ server = ExternalToolServer(
168
+ name="test-server",
169
+ type=ToolServerType.remote_mcp,
170
+ properties=remote_mcp_base_props,
171
+ )
172
+ assert server.get_secret_keys() == []
173
+
174
+ # With secret header keys
175
+ props_with_secrets = {
176
+ **remote_mcp_base_props,
177
+ "secret_header_keys": ["Authorization", "X-API-Key"],
178
+ }
179
+ server = ExternalToolServer(
180
+ name="test-server",
181
+ type=ToolServerType.remote_mcp,
182
+ properties=props_with_secrets,
183
+ )
184
+ assert server.get_secret_keys() == ["Authorization", "X-API-Key"]
185
+
186
+ def test_get_secret_keys_local_mcp(self, mock_config, local_mcp_base_props):
187
+ """Test get_secret_keys for local MCP servers."""
188
+ # No secret keys defined
189
+ server = ExternalToolServer(
190
+ name="test-server",
191
+ type=ToolServerType.local_mcp,
192
+ properties=local_mcp_base_props,
193
+ )
194
+ assert server.get_secret_keys() == []
195
+
196
+ # With secret env var keys
197
+ props_with_secrets = {
198
+ **local_mcp_base_props,
199
+ "secret_env_var_keys": ["API_KEY", "SECRET_TOKEN"],
200
+ }
201
+ server = ExternalToolServer(
202
+ name="test-server",
203
+ type=ToolServerType.local_mcp,
204
+ properties=props_with_secrets,
205
+ )
206
+ assert server.get_secret_keys() == ["API_KEY", "SECRET_TOKEN"]
207
+
208
+ def test_secret_processing_remote_mcp_initialization(self, mock_config):
209
+ """Test secret processing during remote MCP server initialization."""
210
+ properties = {
211
+ "server_url": "https://api.example.com/mcp",
212
+ "headers": {
213
+ "Content-Type": "application/json",
214
+ "Authorization": "Bearer secret123",
215
+ "X-API-Key": "api-key-456",
216
+ },
217
+ "secret_header_keys": ["Authorization", "X-API-Key"],
218
+ }
219
+
220
+ server = ExternalToolServer(
221
+ name="test-server", type=ToolServerType.remote_mcp, properties=properties
222
+ )
223
+
224
+ # Secrets should be extracted to _unsaved_secrets
225
+ assert server._unsaved_secrets == {
226
+ "Authorization": "Bearer secret123",
227
+ "X-API-Key": "api-key-456",
228
+ }
229
+
230
+ # Secrets should be removed from headers
231
+ assert server.properties["headers"] == {"Content-Type": "application/json"}
232
+
233
+ def test_secret_processing_local_mcp_initialization(self, mock_config):
234
+ """Test secret processing during local MCP server initialization."""
235
+ properties = {
236
+ "command": "python",
237
+ "args": ["-m", "server"],
238
+ "env_vars": {
239
+ "PATH": "/usr/bin",
240
+ "API_KEY": "secret123",
241
+ "DB_PASSWORD": "db-secret-456",
242
+ },
243
+ "secret_env_var_keys": ["API_KEY", "DB_PASSWORD"],
244
+ }
245
+
246
+ server = ExternalToolServer(
247
+ name="test-server", type=ToolServerType.local_mcp, properties=properties
248
+ )
249
+
250
+ # Secrets should be extracted to _unsaved_secrets
251
+ assert server._unsaved_secrets == {
252
+ "API_KEY": "secret123",
253
+ "DB_PASSWORD": "db-secret-456",
254
+ }
255
+
256
+ # Secrets should be removed from env_vars
257
+ assert server.properties["env_vars"] == {"PATH": "/usr/bin"}
258
+
259
+ def test_secret_processing_property_update_remote_mcp(
260
+ self, mock_config, remote_mcp_base_props
261
+ ):
262
+ """Test secret processing when properties are updated via __setattr__ for remote MCP."""
263
+ server = ExternalToolServer(
264
+ name="test-server",
265
+ type=ToolServerType.remote_mcp,
266
+ properties={
267
+ **remote_mcp_base_props,
268
+ "secret_header_keys": ["Authorization"],
269
+ },
270
+ )
271
+
272
+ # Clear any existing unsaved secrets
273
+ server._unsaved_secrets.clear()
274
+
275
+ # Update properties with secrets
276
+ new_properties = {
277
+ **remote_mcp_base_props,
278
+ "headers": {
279
+ **remote_mcp_base_props["headers"],
280
+ "Authorization": "Bearer new-token",
281
+ },
282
+ "secret_header_keys": ["Authorization"],
283
+ }
284
+
285
+ server.properties = new_properties
286
+
287
+ # Secret should be processed (extracted and removed from headers)
288
+ assert server._unsaved_secrets == {"Authorization": "Bearer new-token"}
289
+ assert "Authorization" not in server.properties["headers"]
290
+
291
+ def test_secret_processing_clears_existing_secrets(
292
+ self, mock_config, remote_mcp_base_props
293
+ ):
294
+ """Test that secret processing clears existing _unsaved_secrets."""
295
+ server = ExternalToolServer(
296
+ name="test-server",
297
+ type=ToolServerType.remote_mcp,
298
+ properties={
299
+ **remote_mcp_base_props,
300
+ "secret_header_keys": ["Authorization"],
301
+ },
302
+ )
303
+
304
+ # Manually add some unsaved secrets
305
+ server._unsaved_secrets = {"OldSecret": "old-value"}
306
+
307
+ # Update properties - should clear old secrets
308
+ new_properties = {
309
+ **remote_mcp_base_props,
310
+ "headers": {
311
+ **remote_mcp_base_props["headers"],
312
+ "Authorization": "Bearer new-token",
313
+ },
314
+ "secret_header_keys": ["Authorization"],
315
+ }
316
+
317
+ server.properties = new_properties
318
+
319
+ # Only new secret should remain
320
+ assert server._unsaved_secrets == {"Authorization": "Bearer new-token"}
321
+ assert "OldSecret" not in server._unsaved_secrets
322
+
323
+ def test_retrieve_secrets_from_config(self, mock_config, remote_mcp_base_props):
324
+ """Test retrieving secrets from config storage."""
325
+ server = ExternalToolServer(
326
+ name="test-server",
327
+ type=ToolServerType.remote_mcp,
328
+ properties={
329
+ **remote_mcp_base_props,
330
+ "secret_header_keys": ["Authorization", "X-API-Key"],
331
+ },
332
+ )
333
+ server.id = "server-123"
334
+
335
+ # Mock config to return saved secrets
336
+ mock_config.get_value.return_value = {
337
+ "server-123::Authorization": "Bearer config-token",
338
+ "server-123::X-API-Key": "config-api-key",
339
+ "other-server::Authorization": "other-token",
340
+ }
341
+
342
+ secrets, missing = server.retrieve_secrets()
343
+
344
+ assert secrets == {
345
+ "Authorization": "Bearer config-token",
346
+ "X-API-Key": "config-api-key",
347
+ }
348
+ assert missing == []
349
+
350
+ def test_retrieve_secrets_from_unsaved(self, mock_config, remote_mcp_base_props):
351
+ """Test retrieving secrets from unsaved storage when not in config."""
352
+ server = ExternalToolServer(
353
+ name="test-server",
354
+ type=ToolServerType.remote_mcp,
355
+ properties={
356
+ **remote_mcp_base_props,
357
+ "secret_header_keys": ["Authorization", "X-API-Key"],
358
+ },
359
+ )
360
+ server.id = "server-123"
361
+ server._unsaved_secrets = {
362
+ "Authorization": "Bearer unsaved-token",
363
+ "X-API-Key": "unsaved-api-key",
364
+ }
365
+
366
+ # Mock config to return empty
367
+ mock_config.get_value.return_value = {}
368
+
369
+ secrets, missing = server.retrieve_secrets()
370
+
371
+ assert secrets == {
372
+ "Authorization": "Bearer unsaved-token",
373
+ "X-API-Key": "unsaved-api-key",
374
+ }
375
+ assert missing == []
376
+
377
+ def test_retrieve_secrets_config_takes_precedence(
378
+ self, mock_config, remote_mcp_base_props
379
+ ):
380
+ """Test that config secrets take precedence over unsaved secrets."""
381
+ server = ExternalToolServer(
382
+ name="test-server",
383
+ type=ToolServerType.remote_mcp,
384
+ properties={
385
+ **remote_mcp_base_props,
386
+ "secret_header_keys": ["Authorization"],
387
+ },
388
+ )
389
+ server.id = "server-123"
390
+ server._unsaved_secrets = {"Authorization": "Bearer unsaved-token"}
391
+
392
+ # Mock config to return saved secret
393
+ mock_config.get_value.return_value = {
394
+ "server-123::Authorization": "Bearer config-token"
395
+ }
396
+
397
+ secrets, missing = server.retrieve_secrets()
398
+
399
+ assert secrets == {"Authorization": "Bearer config-token"}
400
+ assert missing == []
401
+
402
+ def test_retrieve_secrets_with_missing_values(
403
+ self, mock_config, remote_mcp_base_props
404
+ ):
405
+ """Test retrieving secrets when some are missing."""
406
+ server = ExternalToolServer(
407
+ name="test-server",
408
+ type=ToolServerType.remote_mcp,
409
+ properties={
410
+ **remote_mcp_base_props,
411
+ "secret_header_keys": ["Authorization", "X-API-Key", "Missing-Key"],
412
+ },
413
+ )
414
+ server.id = "server-123"
415
+
416
+ # Mock config with only partial secrets
417
+ mock_config.get_value.return_value = {
418
+ "server-123::Authorization": "Bearer config-token"
419
+ }
420
+
421
+ secrets, missing = server.retrieve_secrets()
422
+
423
+ assert secrets == {"Authorization": "Bearer config-token"}
424
+ assert set(missing) == {"X-API-Key", "Missing-Key"}
425
+
426
+ def test_retrieve_secrets_no_secret_keys(self, mock_config, remote_mcp_base_props):
427
+ """Test retrieving secrets when no secret keys are defined."""
428
+ server = ExternalToolServer(
429
+ name="test-server",
430
+ type=ToolServerType.remote_mcp,
431
+ properties=remote_mcp_base_props, # No secret_header_keys
432
+ )
433
+
434
+ secrets, missing = server.retrieve_secrets()
435
+
436
+ assert secrets == {}
437
+ assert missing == []
438
+
439
+ def test_save_secrets(self, mock_config, remote_mcp_base_props):
440
+ """Test saving unsaved secrets to config."""
441
+ server = ExternalToolServer(
442
+ name="test-server",
443
+ type=ToolServerType.remote_mcp,
444
+ properties={
445
+ **remote_mcp_base_props,
446
+ "secret_header_keys": ["Authorization", "X-API-Key"],
447
+ },
448
+ )
449
+ server.id = "server-123"
450
+ server._unsaved_secrets = {
451
+ "Authorization": "Bearer token",
452
+ "X-API-Key": "api-key",
453
+ }
454
+
455
+ # Mock existing config secrets
456
+ existing_secrets = {"other-server::key": "other-value"}
457
+ mock_config.get_value.return_value = existing_secrets
458
+
459
+ server._save_secrets()
460
+
461
+ # Should update config with new secrets
462
+ expected_secrets = {
463
+ "other-server::key": "other-value",
464
+ "server-123::Authorization": "Bearer token",
465
+ "server-123::X-API-Key": "api-key",
466
+ }
467
+ mock_config.update_settings.assert_called_once_with(
468
+ {MCP_SECRETS_KEY: expected_secrets}
469
+ )
470
+
471
+ # Should clear unsaved secrets
472
+ assert server._unsaved_secrets == {}
473
+
474
+ def test_save_secrets_no_id_error(self, mock_config, remote_mcp_base_props):
475
+ """Test that saving secrets without ID raises error."""
476
+ server = ExternalToolServer(
477
+ name="test-server",
478
+ type=ToolServerType.remote_mcp,
479
+ properties={
480
+ **remote_mcp_base_props,
481
+ "secret_header_keys": ["Authorization"],
482
+ },
483
+ )
484
+ # Manually set unsaved secrets to bypass the empty check
485
+ server._unsaved_secrets = {"Authorization": "Bearer token"}
486
+ # Explicitly set ID to None to test the error condition
487
+ server.id = None
488
+
489
+ with pytest.raises(
490
+ ValueError, match="Server ID cannot be None when saving secrets"
491
+ ):
492
+ server._save_secrets()
493
+
494
+ def test_save_secrets_with_no_unsaved_secrets(
495
+ self, mock_config, remote_mcp_base_props
496
+ ):
497
+ """Test that saving secrets with no unsaved secrets does nothing."""
498
+ server = ExternalToolServer(
499
+ name="test-server",
500
+ type=ToolServerType.remote_mcp,
501
+ properties={
502
+ **remote_mcp_base_props,
503
+ "secret_header_keys": ["Authorization"],
504
+ },
505
+ )
506
+ server.id = "server-123"
507
+
508
+ # No _unsaved_secrets set
509
+
510
+ server._save_secrets()
511
+
512
+ # Should not call update_settings
513
+ mock_config.update_settings.assert_not_called()
514
+
515
+ def test_delete_secrets(self, mock_config, remote_mcp_base_props):
516
+ """Test deleting secrets from config."""
517
+ server = ExternalToolServer(
518
+ name="test-server",
519
+ type=ToolServerType.remote_mcp,
520
+ properties={
521
+ **remote_mcp_base_props,
522
+ "secret_header_keys": ["Authorization", "X-API-Key"],
523
+ },
524
+ )
525
+ server.id = "server-123"
526
+
527
+ # Mock existing config secrets
528
+ existing_secrets = {
529
+ "server-123::Authorization": "Bearer token",
530
+ "server-123::X-API-Key": "api-key",
531
+ "other-server::Authorization": "other-token",
532
+ }
533
+ mock_config.get_value.return_value = existing_secrets
534
+
535
+ server.delete_secrets()
536
+
537
+ # Should remove only this server's secrets
538
+ expected_secrets = {"other-server::Authorization": "other-token"}
539
+ mock_config.update_settings.assert_called_once_with(
540
+ {MCP_SECRETS_KEY: expected_secrets}
541
+ )
542
+
543
+ def test_delete_secrets_with_no_existing_secrets(
544
+ self, mock_config, remote_mcp_base_props
545
+ ):
546
+ """Test deleting secrets when none exist in config."""
547
+ server = ExternalToolServer(
548
+ name="test-server",
549
+ type=ToolServerType.remote_mcp,
550
+ properties={
551
+ **remote_mcp_base_props,
552
+ "secret_header_keys": ["Authorization"],
553
+ },
554
+ )
555
+ server.id = "server-123"
556
+
557
+ # Mock empty config
558
+ mock_config.get_value.return_value = {}
559
+
560
+ server.delete_secrets()
561
+
562
+ # Should still call update_settings with empty dict
563
+ mock_config.update_settings.assert_called_once_with({MCP_SECRETS_KEY: {}})
564
+
565
+ def test_save_to_file_saves_secrets_first(self, mock_config, remote_mcp_base_props):
566
+ """Test that save_to_file automatically saves unsaved secrets first."""
567
+ server = ExternalToolServer(
568
+ name="test-server",
569
+ type=ToolServerType.remote_mcp,
570
+ properties={
571
+ **remote_mcp_base_props,
572
+ "secret_header_keys": ["Authorization"],
573
+ },
574
+ )
575
+ server.id = "server-123"
576
+ server._unsaved_secrets = {"Authorization": "Bearer token"}
577
+
578
+ mock_config.get_value.return_value = {}
579
+
580
+ with patch(
581
+ "kiln_ai.datamodel.basemodel.KilnParentedModel.save_to_file"
582
+ ) as mock_parent_save:
583
+ server.save_to_file()
584
+
585
+ # Should save secrets first
586
+ mock_config.update_settings.assert_called_once()
587
+ assert server._unsaved_secrets == {}
588
+
589
+ # Should call parent save_to_file
590
+ mock_parent_save.assert_called_once()
591
+
592
+ def test_save_to_file_no_unsaved_secrets(self, mock_config, remote_mcp_base_props):
593
+ """Test save_to_file when no unsaved secrets exist."""
594
+ server = ExternalToolServer(
595
+ name="test-server",
596
+ type=ToolServerType.remote_mcp,
597
+ properties=remote_mcp_base_props,
598
+ )
599
+
600
+ with patch(
601
+ "kiln_ai.datamodel.basemodel.KilnParentedModel.save_to_file"
602
+ ) as mock_parent_save:
603
+ server.save_to_file()
604
+
605
+ # Should not save secrets
606
+ mock_config.update_settings.assert_not_called()
607
+
608
+ # Should still call parent save_to_file
609
+ mock_parent_save.assert_called_once()
610
+
611
+ def test_config_secret_key_format(self, mock_config, remote_mcp_base_props):
612
+ """Test the _config_secret_key method formats keys correctly."""
613
+ server = ExternalToolServer(
614
+ name="test-server",
615
+ type=ToolServerType.remote_mcp,
616
+ properties=remote_mcp_base_props,
617
+ )
618
+ server.id = "server-123"
619
+
620
+ assert server._config_secret_key("Authorization") == "server-123::Authorization"
621
+ assert server._config_secret_key("X-API-Key") == "server-123::X-API-Key"
622
+
623
+ def test_model_serialization_excludes_secrets(self, mock_config):
624
+ """Test that model serialization excludes _unsaved_secrets private attribute and secrets from properties."""
625
+ # Test all server types to ensure we update this test when new types are added
626
+ for server_type in ToolServerType:
627
+ match server_type:
628
+ case ToolServerType.remote_mcp:
629
+ server = ExternalToolServer(
630
+ name="test-remote-server",
631
+ type=server_type,
632
+ properties={
633
+ "server_url": "https://api.example.com/mcp",
634
+ "headers": {"Authorization": "Bearer secret"},
635
+ "secret_header_keys": ["Authorization"],
636
+ },
637
+ )
638
+ data = server.model_dump()
639
+ assert "_unsaved_secrets" not in data
640
+ assert "Authorization" not in data["properties"]["headers"]
641
+
642
+ case ToolServerType.local_mcp:
643
+ server = ExternalToolServer(
644
+ name="test-local-server",
645
+ type=server_type,
646
+ properties={
647
+ "command": "python",
648
+ "args": ["-m", "server"],
649
+ "env_vars": {"API_KEY": "secret"},
650
+ "secret_env_var_keys": ["API_KEY"],
651
+ },
652
+ )
653
+ data = server.model_dump()
654
+ assert "_unsaved_secrets" not in data
655
+ assert "API_KEY" not in data["properties"]["env_vars"]
656
+
657
+ case _:
658
+ raise_exhaustive_enum_error(server_type)
659
+
660
+ def test_empty_secret_keys_list(self, mock_config, remote_mcp_base_props):
661
+ """Test behavior with empty secret_header_keys list."""
662
+ properties = {**remote_mcp_base_props, "secret_header_keys": []}
663
+
664
+ server = ExternalToolServer(
665
+ name="test-server", type=ToolServerType.remote_mcp, properties=properties
666
+ )
667
+
668
+ assert server.get_secret_keys() == []
669
+ secrets, missing = server.retrieve_secrets()
670
+ assert secrets == {}
671
+ assert missing == []
672
+
673
+ def test_none_mcp_secrets_in_config(self, mock_config, remote_mcp_base_props):
674
+ """Test behavior when MCP_SECRETS_KEY returns None from config."""
675
+ server = ExternalToolServer(
676
+ name="test-server",
677
+ type=ToolServerType.remote_mcp,
678
+ properties={
679
+ **remote_mcp_base_props,
680
+ "secret_header_keys": ["Authorization"],
681
+ },
682
+ )
683
+ server.id = "server-123"
684
+
685
+ # Mock config returning None for MCP_SECRETS_KEY
686
+ mock_config.get_value.return_value = None
687
+
688
+ secrets, missing = server.retrieve_secrets()
689
+
690
+ assert secrets == {}
691
+ assert missing == ["Authorization"]