kiln-ai 0.21.0__py3-none-any.whl → 0.22.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 (53) hide show
  1. kiln_ai/adapters/extractors/litellm_extractor.py +52 -32
  2. kiln_ai/adapters/extractors/test_litellm_extractor.py +169 -71
  3. kiln_ai/adapters/ml_embedding_model_list.py +330 -28
  4. kiln_ai/adapters/ml_model_list.py +503 -23
  5. kiln_ai/adapters/model_adapters/litellm_adapter.py +39 -8
  6. kiln_ai/adapters/model_adapters/test_litellm_adapter.py +78 -0
  7. kiln_ai/adapters/model_adapters/test_litellm_adapter_tools.py +119 -5
  8. kiln_ai/adapters/model_adapters/test_saving_adapter_results.py +9 -3
  9. kiln_ai/adapters/model_adapters/test_structured_output.py +6 -9
  10. kiln_ai/adapters/test_ml_embedding_model_list.py +89 -279
  11. kiln_ai/adapters/test_ml_model_list.py +0 -10
  12. kiln_ai/adapters/vector_store/lancedb_adapter.py +24 -70
  13. kiln_ai/adapters/vector_store/lancedb_helpers.py +101 -0
  14. kiln_ai/adapters/vector_store/test_lancedb_adapter.py +9 -16
  15. kiln_ai/adapters/vector_store/test_lancedb_helpers.py +142 -0
  16. kiln_ai/adapters/vector_store_loaders/__init__.py +0 -0
  17. kiln_ai/adapters/vector_store_loaders/test_lancedb_loader.py +282 -0
  18. kiln_ai/adapters/vector_store_loaders/test_vector_store_loader.py +544 -0
  19. kiln_ai/adapters/vector_store_loaders/vector_store_loader.py +91 -0
  20. kiln_ai/datamodel/basemodel.py +31 -3
  21. kiln_ai/datamodel/external_tool_server.py +206 -54
  22. kiln_ai/datamodel/extraction.py +14 -0
  23. kiln_ai/datamodel/task.py +5 -0
  24. kiln_ai/datamodel/task_output.py +41 -11
  25. kiln_ai/datamodel/test_attachment.py +3 -3
  26. kiln_ai/datamodel/test_basemodel.py +269 -13
  27. kiln_ai/datamodel/test_datasource.py +50 -0
  28. kiln_ai/datamodel/test_external_tool_server.py +534 -152
  29. kiln_ai/datamodel/test_extraction_model.py +31 -0
  30. kiln_ai/datamodel/test_task.py +35 -1
  31. kiln_ai/datamodel/test_tool_id.py +106 -1
  32. kiln_ai/datamodel/tool_id.py +49 -0
  33. kiln_ai/tools/base_tool.py +30 -6
  34. kiln_ai/tools/built_in_tools/math_tools.py +12 -4
  35. kiln_ai/tools/kiln_task_tool.py +162 -0
  36. kiln_ai/tools/mcp_server_tool.py +7 -5
  37. kiln_ai/tools/mcp_session_manager.py +50 -24
  38. kiln_ai/tools/rag_tools.py +17 -6
  39. kiln_ai/tools/test_kiln_task_tool.py +527 -0
  40. kiln_ai/tools/test_mcp_server_tool.py +4 -15
  41. kiln_ai/tools/test_mcp_session_manager.py +186 -226
  42. kiln_ai/tools/test_rag_tools.py +86 -5
  43. kiln_ai/tools/test_tool_registry.py +199 -5
  44. kiln_ai/tools/tool_registry.py +49 -17
  45. kiln_ai/utils/filesystem.py +4 -4
  46. kiln_ai/utils/open_ai_types.py +19 -2
  47. kiln_ai/utils/pdf_utils.py +21 -0
  48. kiln_ai/utils/test_open_ai_types.py +88 -12
  49. kiln_ai/utils/test_pdf_utils.py +14 -1
  50. {kiln_ai-0.21.0.dist-info → kiln_ai-0.22.1.dist-info}/METADATA +79 -1
  51. {kiln_ai-0.21.0.dist-info → kiln_ai-0.22.1.dist-info}/RECORD +53 -45
  52. {kiln_ai-0.21.0.dist-info → kiln_ai-0.22.1.dist-info}/WHEEL +0 -0
  53. {kiln_ai-0.21.0.dist-info → kiln_ai-0.22.1.dist-info}/licenses/LICENSE.txt +0 -0
@@ -1,9 +1,14 @@
1
- from typing import Any, Dict
1
+ from typing import Dict
2
2
  from unittest.mock import Mock, patch
3
3
 
4
4
  import pytest
5
5
 
6
- from kiln_ai.datamodel.external_tool_server import ExternalToolServer, ToolServerType
6
+ from kiln_ai.datamodel.external_tool_server import (
7
+ ExternalToolServer,
8
+ LocalServerProperties,
9
+ RemoteServerProperties,
10
+ ToolServerType,
11
+ )
7
12
  from kiln_ai.utils.config import MCP_SECRETS_KEY, Config
8
13
  from kiln_ai.utils.exhaustive_error import raise_exhaustive_enum_error
9
14
 
@@ -16,11 +21,12 @@ class TestExternalToolServer:
16
21
  config_instance = Mock()
17
22
  config_instance.get_value.return_value = {}
18
23
  config_instance.update_settings = Mock()
24
+ config_instance.user_id = "test-user"
19
25
  mock_shared.return_value = config_instance
20
26
  yield config_instance
21
27
 
22
28
  @pytest.fixture
23
- def remote_mcp_base_props(self) -> Dict[str, Any]:
29
+ def remote_mcp_base_props(self) -> RemoteServerProperties:
24
30
  """Base properties for remote MCP server."""
25
31
  return {
26
32
  "server_url": "https://api.example.com/mcp",
@@ -28,7 +34,29 @@ class TestExternalToolServer:
28
34
  }
29
35
 
30
36
  @pytest.fixture
31
- def local_mcp_base_props(self) -> Dict[str, Any]:
37
+ def sample_remote_mcp_secrets(self) -> Dict[str, str]:
38
+ return {
39
+ "Authorization": "Bearer token123",
40
+ "X-API-Key": "api-key-456",
41
+ }
42
+
43
+ @pytest.fixture
44
+ def remote_mcp_props_with_secrets(
45
+ self, remote_mcp_base_props, sample_remote_mcp_secrets
46
+ ) -> RemoteServerProperties:
47
+ """Properties for remote MCP server with secrets."""
48
+ base_headers = remote_mcp_base_props.get("headers", {})
49
+ return {
50
+ "server_url": remote_mcp_base_props["server_url"],
51
+ "headers": {
52
+ **base_headers,
53
+ **sample_remote_mcp_secrets,
54
+ },
55
+ "secret_header_keys": list(sample_remote_mcp_secrets.keys()),
56
+ }
57
+
58
+ @pytest.fixture
59
+ def local_mcp_base_props(self) -> LocalServerProperties:
32
60
  """Base properties for local MCP server."""
33
61
  return {
34
62
  "command": "python",
@@ -36,6 +64,17 @@ class TestExternalToolServer:
36
64
  "env_vars": {},
37
65
  }
38
66
 
67
+ @pytest.fixture
68
+ def kiln_task_base_props(self) -> dict:
69
+ """Base properties for kiln task server."""
70
+ return {
71
+ "task_id": "task-123",
72
+ "run_config_id": "run-config-456",
73
+ "name": "test_task_tool",
74
+ "description": "A test task tool for unit testing",
75
+ "is_archived": False,
76
+ }
77
+
39
78
  @pytest.mark.parametrize(
40
79
  "server_type, properties",
41
80
  [
@@ -54,42 +93,274 @@ class TestExternalToolServer:
54
93
  "env_vars": {"API_KEY": "secret123"},
55
94
  },
56
95
  ),
96
+ (
97
+ ToolServerType.kiln_task,
98
+ {
99
+ "task_id": "task-123",
100
+ "run_config_id": "run-config-456",
101
+ "name": "test_task_tool",
102
+ "description": "A test task tool for unit testing",
103
+ "is_archived": False,
104
+ },
105
+ ),
57
106
  ],
58
107
  )
59
- def test_valid_server_creation(self, mock_config, server_type, properties):
108
+ def test_valid_server_creation(self, server_type, properties):
60
109
  """Test creating valid servers of both types."""
61
110
  server = ExternalToolServer(
62
- name="test-server",
111
+ name="test_server",
63
112
  type=server_type,
64
113
  description="Test server",
65
114
  properties=properties,
66
115
  )
67
116
 
68
- assert server.name == "test-server"
117
+ assert server.name == "test_server"
69
118
  assert server.type == server_type
70
119
  assert server.description == "Test server"
71
120
  assert server.properties == properties
72
121
 
122
+ @pytest.mark.parametrize(
123
+ "server_url, expected_error",
124
+ [
125
+ (123, "Server URL must be a string"),
126
+ (" http://test.com", "Server URL must not have leading whitespace"),
127
+ ("ftp://test.com", "Server URL must start with http:// or https://"),
128
+ ("test.com", "Server URL is not a valid URL"),
129
+ ],
130
+ )
131
+ def test_validate_server_url_invalid(self, server_url, expected_error):
132
+ """Test validate_server_url."""
133
+ with pytest.raises(ValueError, match=expected_error):
134
+ ExternalToolServer(
135
+ name="test-server",
136
+ type=ToolServerType.remote_mcp,
137
+ properties={"server_url": server_url},
138
+ )
139
+
140
+ @pytest.mark.parametrize(
141
+ "server_url",
142
+ [
143
+ "http://test.com",
144
+ "https://test.com",
145
+ ],
146
+ )
147
+ def test_validate_server_url_valid(self, server_url):
148
+ """Test validate_server_url with valid inputs."""
149
+ # Should not raise any exception
150
+ ExternalToolServer(
151
+ name="test-server",
152
+ type=ToolServerType.remote_mcp,
153
+ properties={"server_url": server_url},
154
+ )
155
+
156
+ @pytest.mark.parametrize(
157
+ "headers, expected_error",
158
+ [
159
+ (123, "headers must be a dictionary"),
160
+ ("not-a-dict", "headers must be a dictionary"),
161
+ ({"": "ok"}, "Header name is required"),
162
+ ({"X-Key": ""}, "Header value is required"),
163
+ ({"X-Key": None}, "Header value is required"),
164
+ ({"Bad Name": "ok"}, r'Invalid header name: "Bad Name"'),
165
+ ({"X@Key": "ok"}, r'Invalid header name: "X@Key"'),
166
+ (
167
+ {"X-Key\n": "ok"},
168
+ "Header names/values must not contain invalid characters",
169
+ ),
170
+ (
171
+ {"X-Key": "bad\nvalue"},
172
+ "Header names/values must not contain invalid characters",
173
+ ),
174
+ ],
175
+ )
176
+ def test_validate_headers_invalid(self, headers, expected_error):
177
+ """Test validate_headers."""
178
+ with pytest.raises(ValueError, match=expected_error):
179
+ ExternalToolServer(
180
+ name="test-server",
181
+ type=ToolServerType.remote_mcp,
182
+ properties={
183
+ "server_url": "https://test.com",
184
+ "headers": headers,
185
+ },
186
+ )
187
+
188
+ @pytest.mark.parametrize(
189
+ "headers",
190
+ [
191
+ {"Authorization": "Bearer token123"},
192
+ {"X-API-Key": "api-key-456"},
193
+ ],
194
+ )
195
+ def test_validate_headers_valid(self, headers):
196
+ """Test validate_headers with valid inputs."""
197
+ # Should not raise any exception
198
+ ExternalToolServer(
199
+ name="test-server",
200
+ type=ToolServerType.remote_mcp,
201
+ properties={
202
+ "server_url": "https://test.com",
203
+ "headers": headers,
204
+ },
205
+ )
206
+
207
+ @pytest.mark.parametrize(
208
+ "secret_header_keys, expected_error",
209
+ [
210
+ (
211
+ 123,
212
+ "secret_header_keys must be a list for external tools of type 'remote_mcp'",
213
+ ),
214
+ (
215
+ "not-a-list",
216
+ "secret_header_keys must be a list for external tools of type 'remote_mcp'",
217
+ ),
218
+ ([123], "secret_header_keys must contain only strings"),
219
+ (["ABC", ""], "Secret key is required"),
220
+ ],
221
+ )
222
+ def test_validate_secret_header_keys_invalid(
223
+ self, secret_header_keys, expected_error
224
+ ):
225
+ """Test validate_secret_header_keys with invalid inputs."""
226
+ with pytest.raises(ValueError, match=expected_error):
227
+ ExternalToolServer(
228
+ name="test-server",
229
+ type=ToolServerType.remote_mcp,
230
+ properties={
231
+ "server_url": "https://test.com",
232
+ "headers": {},
233
+ "secret_header_keys": secret_header_keys,
234
+ },
235
+ )
236
+
237
+ def test_validate_secret_header_keys_valid(self):
238
+ """Test validate_secret_header_keys with valid inputs."""
239
+ # Should not raise any exception
240
+ ExternalToolServer(
241
+ name="test-server",
242
+ type=ToolServerType.remote_mcp,
243
+ properties={
244
+ "server_url": "https://test.com",
245
+ "headers": {
246
+ "Authorization": "Bearer token123",
247
+ "X-API-Key": "api-key-456",
248
+ },
249
+ "secret_header_keys": ["Authorization", "X-API-Key"],
250
+ },
251
+ )
252
+
253
+ @pytest.mark.parametrize(
254
+ "env_vars, expected_error",
255
+ [
256
+ # Non-dictionary inputs
257
+ (123, "environment variables must be a dictionary"),
258
+ ("not-a-dict", "environment variables must be a dictionary"),
259
+ # Empty key
260
+ (
261
+ {"": "value"},
262
+ "Invalid environment variable key: . Must start with a letter or underscore.",
263
+ ),
264
+ # Keys that don't start with letter or underscore
265
+ (
266
+ {"123INVALID": "value"},
267
+ "Invalid environment variable key: 123INVALID. Must start with a letter or underscore.",
268
+ ),
269
+ (
270
+ {"-INVALID": "value"},
271
+ "Invalid environment variable key: -INVALID. Must start with a letter or underscore.",
272
+ ),
273
+ # Keys with invalid characters
274
+ (
275
+ {"INVALID-KEY": "value"},
276
+ "Invalid environment variable key: INVALID-KEY. Can only contain letters, digits, and underscores.",
277
+ ),
278
+ (
279
+ {"INVALID.KEY": "value"},
280
+ "Invalid environment variable key: INVALID.KEY. Can only contain letters, digits, and underscores.",
281
+ ),
282
+ # Emojis
283
+ (
284
+ {"API_KEY👍": "value"},
285
+ "Invalid environment variable key: API_KEY👍. Can only contain letters, digits, and underscores.",
286
+ ),
287
+ # Non-ASCII characters
288
+ (
289
+ {"API_KEY_密鑰": "value"},
290
+ "Invalid environment variable key: API_KEY_密鑰. Can only contain letters, digits, and underscores.",
291
+ ),
292
+ # Newlines
293
+ (
294
+ {"API\nKEY": "value"},
295
+ "Invalid environment variable key: API\nKEY. Can only contain letters, digits, and underscores.",
296
+ ),
297
+ # Tabs
298
+ (
299
+ {"Hello\tWorld": "value"},
300
+ "Invalid environment variable key: Hello\tWorld. Can only contain letters, digits, and underscores.",
301
+ ),
302
+ ],
303
+ )
304
+ def test_validate_env_vars_invalid(self, env_vars, expected_error):
305
+ """Test validate_env_vars with invalid inputs."""
306
+ with pytest.raises(ValueError, match=expected_error):
307
+ ExternalToolServer(
308
+ name="test-server",
309
+ type=ToolServerType.local_mcp,
310
+ properties={
311
+ "command": "python",
312
+ "args": [],
313
+ "env_vars": env_vars,
314
+ },
315
+ )
316
+
317
+ @pytest.mark.parametrize(
318
+ "env_vars",
319
+ [
320
+ # Valid cases
321
+ {},
322
+ {"VALID_KEY": "value"},
323
+ {"VALID123": "value"},
324
+ {"_VALID123": "value"},
325
+ # Multiple valid keys
326
+ {"KEY1": "value1", "KEY2": "value2", "_KEY3": "value3"},
327
+ # With paths
328
+ {"PATH": "/usr/bin"},
329
+ ],
330
+ )
331
+ def test_validate_env_vars_valid(self, env_vars):
332
+ """Test validate_env_vars with valid inputs."""
333
+ # Should not raise any exception
334
+ ExternalToolServer(
335
+ name="test-server",
336
+ type=ToolServerType.local_mcp,
337
+ properties={
338
+ "command": "python",
339
+ "args": [],
340
+ "env_vars": env_vars,
341
+ },
342
+ )
343
+
73
344
  @pytest.mark.parametrize(
74
345
  "server_type, invalid_props, expected_error",
75
346
  [
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"),
347
+ # Missing type entirely
79
348
  (
80
- ToolServerType.remote_mcp,
81
- {"server_url": 123},
82
- "server_url must be a string",
349
+ None,
350
+ {},
351
+ "type is required", # Required by pydantic in RemoteServerProperties
83
352
  ),
353
+ # Remote MCP missing server_url
84
354
  (
85
355
  ToolServerType.remote_mcp,
86
- {"server_url": "http://test.com"},
87
- "headers must be set",
356
+ {},
357
+ "Server URL is required to connect to a remote MCP server",
88
358
  ),
359
+ # Remote MCP validation errors
89
360
  (
90
361
  ToolServerType.remote_mcp,
91
- {"server_url": "http://test.com", "headers": "not-a-dict"},
92
- "headers must be a dictionary",
362
+ {"server_url": ""},
363
+ "Server URL is not a valid URL",
93
364
  ),
94
365
  (
95
366
  ToolServerType.remote_mcp,
@@ -110,14 +381,17 @@ class TestExternalToolServer:
110
381
  "secret_header_keys must contain only strings",
111
382
  ),
112
383
  # 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
384
  (
117
385
  ToolServerType.local_mcp,
118
- {"command": "python"},
119
- "arguments must be a list",
386
+ {},
387
+ "command is required to start a local MCP server",
120
388
  ),
389
+ (
390
+ ToolServerType.local_mcp,
391
+ {"command": ""},
392
+ "command must be a non-empty string",
393
+ ), # Required by pydantic in LocalServerProperties
394
+ (ToolServerType.local_mcp, {"command": 123}, "command must be a string"),
121
395
  (
122
396
  ToolServerType.local_mcp,
123
397
  {"command": "python", "args": "not-a-list"},
@@ -148,11 +422,56 @@ class TestExternalToolServer:
148
422
  },
149
423
  "secret_env_var_keys must contain only strings",
150
424
  ),
425
+ # Kiln task validation errors
426
+ (
427
+ ToolServerType.kiln_task,
428
+ {},
429
+ "Tool name cannot be empty",
430
+ ),
431
+ (
432
+ ToolServerType.kiln_task,
433
+ {"name": ""},
434
+ "Tool name cannot be empty",
435
+ ),
436
+ (
437
+ ToolServerType.kiln_task,
438
+ {"name": "test", "description": 123},
439
+ "description must be of type <class 'str'>",
440
+ ),
441
+ (
442
+ ToolServerType.kiln_task,
443
+ {"name": "test", "description": "a" * 129},
444
+ "description must be 128 characters or less",
445
+ ),
446
+ (
447
+ ToolServerType.kiln_task,
448
+ {"name": "test", "description": "test", "is_archived": "not-bool"},
449
+ "is_archived must be of type <class 'bool'>",
450
+ ),
451
+ (
452
+ ToolServerType.kiln_task,
453
+ {
454
+ "name": "test",
455
+ "description": "test",
456
+ "is_archived": False,
457
+ "task_id": 123,
458
+ },
459
+ "task_id must be of type <class 'str'>",
460
+ ),
461
+ (
462
+ ToolServerType.kiln_task,
463
+ {
464
+ "name": "test",
465
+ "description": "test",
466
+ "is_archived": False,
467
+ "task_id": "task-123",
468
+ "run_config_id": 456,
469
+ },
470
+ "run_config_id must be of type <class 'str'>",
471
+ ),
151
472
  ],
152
473
  )
153
- def test_validation_errors(
154
- self, mock_config, server_type, invalid_props, expected_error
155
- ):
474
+ def test_validation_errors(self, server_type, invalid_props, expected_error):
156
475
  """Test validation errors for invalid configurations."""
157
476
  with pytest.raises((ValueError, Exception)) as exc_info:
158
477
  ExternalToolServer(
@@ -161,7 +480,7 @@ class TestExternalToolServer:
161
480
  # Check that the expected error message is in the exception string
162
481
  assert expected_error in str(exc_info.value)
163
482
 
164
- def test_get_secret_keys_remote_mcp(self, mock_config, remote_mcp_base_props):
483
+ def test_get_secret_keys_remote_mcp(self, remote_mcp_base_props):
165
484
  """Test get_secret_keys for remote MCP servers."""
166
485
  # No secret keys defined
167
486
  server = ExternalToolServer(
@@ -173,17 +492,18 @@ class TestExternalToolServer:
173
492
 
174
493
  # With secret header keys
175
494
  props_with_secrets = {
176
- **remote_mcp_base_props,
495
+ "server_url": remote_mcp_base_props["server_url"],
496
+ "headers": remote_mcp_base_props.get("headers", {}),
177
497
  "secret_header_keys": ["Authorization", "X-API-Key"],
178
498
  }
179
499
  server = ExternalToolServer(
180
500
  name="test-server",
181
501
  type=ToolServerType.remote_mcp,
182
- properties=props_with_secrets,
502
+ properties=props_with_secrets, # type: ignore
183
503
  )
184
504
  assert server.get_secret_keys() == ["Authorization", "X-API-Key"]
185
505
 
186
- def test_get_secret_keys_local_mcp(self, mock_config, local_mcp_base_props):
506
+ def test_get_secret_keys_local_mcp(self, local_mcp_base_props):
187
507
  """Test get_secret_keys for local MCP servers."""
188
508
  # No secret keys defined
189
509
  server = ExternalToolServer(
@@ -195,42 +515,45 @@ class TestExternalToolServer:
195
515
 
196
516
  # With secret env var keys
197
517
  props_with_secrets = {
198
- **local_mcp_base_props,
518
+ "command": local_mcp_base_props["command"],
519
+ "args": local_mcp_base_props.get("args", []),
520
+ "env_vars": local_mcp_base_props.get("env_vars", {}),
199
521
  "secret_env_var_keys": ["API_KEY", "SECRET_TOKEN"],
200
522
  }
201
523
  server = ExternalToolServer(
202
524
  name="test-server",
203
525
  type=ToolServerType.local_mcp,
204
- properties=props_with_secrets,
526
+ properties=props_with_secrets, # type: ignore
205
527
  )
206
528
  assert server.get_secret_keys() == ["API_KEY", "SECRET_TOKEN"]
207
529
 
208
- def test_secret_processing_remote_mcp_initialization(self, mock_config):
530
+ def test_get_secret_keys_kiln_task(self, kiln_task_base_props):
531
+ """Test get_secret_keys for kiln task servers."""
532
+ server = ExternalToolServer(
533
+ name="test_server",
534
+ type=ToolServerType.kiln_task,
535
+ properties=kiln_task_base_props,
536
+ )
537
+ assert server.get_secret_keys() == []
538
+
539
+ def test_secret_processing_remote_mcp_initialization(
540
+ self, remote_mcp_props_with_secrets, sample_remote_mcp_secrets
541
+ ):
209
542
  """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
- }
543
+ properties = remote_mcp_props_with_secrets
219
544
 
220
545
  server = ExternalToolServer(
221
546
  name="test-server", type=ToolServerType.remote_mcp, properties=properties
222
547
  )
223
548
 
224
549
  # Secrets should be extracted to _unsaved_secrets
225
- assert server._unsaved_secrets == {
226
- "Authorization": "Bearer secret123",
227
- "X-API-Key": "api-key-456",
228
- }
550
+ assert server._unsaved_secrets == sample_remote_mcp_secrets
229
551
 
230
552
  # Secrets should be removed from headers
231
- assert server.properties["headers"] == {"Content-Type": "application/json"}
553
+ headers = server.properties.get("headers", {})
554
+ assert headers == {"Content-Type": "application/json"}
232
555
 
233
- def test_secret_processing_local_mcp_initialization(self, mock_config):
556
+ def test_secret_processing_local_mcp_initialization(self):
234
557
  """Test secret processing during local MCP server initialization."""
235
558
  properties = {
236
559
  "command": "python",
@@ -244,7 +567,9 @@ class TestExternalToolServer:
244
567
  }
245
568
 
246
569
  server = ExternalToolServer(
247
- name="test-server", type=ToolServerType.local_mcp, properties=properties
570
+ name="test-server",
571
+ type=ToolServerType.local_mcp,
572
+ properties=properties, # type: ignore
248
573
  )
249
574
 
250
575
  # Secrets should be extracted to _unsaved_secrets
@@ -254,108 +579,108 @@ class TestExternalToolServer:
254
579
  }
255
580
 
256
581
  # Secrets should be removed from env_vars
257
- assert server.properties["env_vars"] == {"PATH": "/usr/bin"}
582
+ env_vars = server.properties.get("env_vars", {})
583
+ assert env_vars == {"PATH": "/usr/bin"}
584
+
585
+ def test_secret_processing_kiln_task_initialization(self, kiln_task_base_props):
586
+ """Test secret processing during kiln task server initialization."""
587
+ server = ExternalToolServer(
588
+ name="test_server",
589
+ type=ToolServerType.kiln_task,
590
+ properties=kiln_task_base_props,
591
+ )
592
+
593
+ # Kiln task servers should have no secrets processed
594
+ assert server._unsaved_secrets == {}
595
+
596
+ # Properties should remain unchanged
597
+ assert server.properties == kiln_task_base_props
258
598
 
259
599
  def test_secret_processing_property_update_remote_mcp(
260
- self, mock_config, remote_mcp_base_props
600
+ self, remote_mcp_props_with_secrets
261
601
  ):
262
602
  """Test secret processing when properties are updated via __setattr__ for remote MCP."""
263
603
  server = ExternalToolServer(
264
604
  name="test-server",
265
605
  type=ToolServerType.remote_mcp,
266
- properties={
267
- **remote_mcp_base_props,
268
- "secret_header_keys": ["Authorization"],
269
- },
606
+ properties=remote_mcp_props_with_secrets,
270
607
  )
271
608
 
272
609
  # Clear any existing unsaved secrets
273
610
  server._unsaved_secrets.clear()
274
611
 
275
- # Update properties with secrets
612
+ # Update properties with new secrets
276
613
  new_properties = {
277
- **remote_mcp_base_props,
614
+ "server_url": remote_mcp_props_with_secrets["server_url"],
278
615
  "headers": {
279
- **remote_mcp_base_props["headers"],
280
- "Authorization": "Bearer new-token",
616
+ "New-Secret-Header": "Bearer new-token",
281
617
  },
282
- "secret_header_keys": ["Authorization"],
618
+ "secret_header_keys": ["New-Secret-Header"],
283
619
  }
284
620
 
285
- server.properties = new_properties
621
+ server.properties = new_properties # type: ignore
286
622
 
287
623
  # 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"]
624
+ assert server._unsaved_secrets == {"New-Secret-Header": "Bearer new-token"}
625
+ headers = server.properties.get("headers", {})
626
+ assert "New-Secret-Header" not in headers
290
627
 
291
628
  def test_secret_processing_clears_existing_secrets(
292
- self, mock_config, remote_mcp_base_props
629
+ self,
630
+ remote_mcp_base_props,
631
+ remote_mcp_props_with_secrets,
632
+ sample_remote_mcp_secrets,
293
633
  ):
294
634
  """Test that secret processing clears existing _unsaved_secrets."""
295
635
  server = ExternalToolServer(
296
636
  name="test-server",
297
637
  type=ToolServerType.remote_mcp,
298
- properties={
299
- **remote_mcp_base_props,
300
- "secret_header_keys": ["Authorization"],
301
- },
638
+ properties=remote_mcp_base_props,
302
639
  )
303
640
 
304
641
  # Manually add some unsaved secrets
305
642
  server._unsaved_secrets = {"OldSecret": "old-value"}
306
643
 
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
644
+ # Update properties with new secrets - this should clear old secrets
645
+ server.properties = remote_mcp_props_with_secrets
318
646
 
319
647
  # Only new secret should remain
320
- assert server._unsaved_secrets == {"Authorization": "Bearer new-token"}
648
+ assert server._unsaved_secrets == sample_remote_mcp_secrets
321
649
  assert "OldSecret" not in server._unsaved_secrets
322
650
 
323
- def test_retrieve_secrets_from_config(self, mock_config, remote_mcp_base_props):
651
+ def test_retrieve_secrets_from_config(
652
+ self, mock_config, remote_mcp_props_with_secrets, sample_remote_mcp_secrets
653
+ ):
324
654
  """Test retrieving secrets from config storage."""
325
655
  server = ExternalToolServer(
326
656
  name="test-server",
327
657
  type=ToolServerType.remote_mcp,
328
- properties={
329
- **remote_mcp_base_props,
330
- "secret_header_keys": ["Authorization", "X-API-Key"],
331
- },
658
+ properties=remote_mcp_props_with_secrets,
332
659
  )
660
+
661
+ # Set ID to test retrieval from config
333
662
  server.id = "server-123"
334
663
 
335
664
  # Mock config to return saved secrets
336
665
  mock_config.get_value.return_value = {
337
- "server-123::Authorization": "Bearer config-token",
338
- "server-123::X-API-Key": "config-api-key",
666
+ "server-123::Authorization": "Bearer token123",
667
+ "server-123::X-API-Key": "api-key-456",
339
668
  "other-server::Authorization": "other-token",
340
669
  }
341
670
 
342
671
  secrets, missing = server.retrieve_secrets()
343
672
 
344
- assert secrets == {
345
- "Authorization": "Bearer config-token",
346
- "X-API-Key": "config-api-key",
347
- }
673
+ assert secrets == sample_remote_mcp_secrets
348
674
  assert missing == []
349
675
 
350
- def test_retrieve_secrets_from_unsaved(self, mock_config, remote_mcp_base_props):
676
+ def test_retrieve_secrets_from_unsaved(
677
+ self, mock_config, remote_mcp_props_with_secrets
678
+ ):
351
679
  """Test retrieving secrets from unsaved storage when not in config."""
352
680
  server = ExternalToolServer(
353
681
  name="test-server",
354
682
  type=ToolServerType.remote_mcp,
355
- properties={
356
- **remote_mcp_base_props,
357
- "secret_header_keys": ["Authorization", "X-API-Key"],
358
- },
683
+ properties=remote_mcp_props_with_secrets,
359
684
  )
360
685
  server.id = "server-123"
361
686
  server._unsaved_secrets = {
@@ -382,7 +707,8 @@ class TestExternalToolServer:
382
707
  name="test-server",
383
708
  type=ToolServerType.remote_mcp,
384
709
  properties={
385
- **remote_mcp_base_props,
710
+ "server_url": remote_mcp_base_props["server_url"],
711
+ "headers": remote_mcp_base_props.get("headers", {}),
386
712
  "secret_header_keys": ["Authorization"],
387
713
  },
388
714
  )
@@ -407,7 +733,8 @@ class TestExternalToolServer:
407
733
  name="test-server",
408
734
  type=ToolServerType.remote_mcp,
409
735
  properties={
410
- **remote_mcp_base_props,
736
+ "server_url": remote_mcp_base_props["server_url"],
737
+ "headers": remote_mcp_base_props.get("headers", {}),
411
738
  "secret_header_keys": ["Authorization", "X-API-Key", "Missing-Key"],
412
739
  },
413
740
  )
@@ -423,7 +750,7 @@ class TestExternalToolServer:
423
750
  assert secrets == {"Authorization": "Bearer config-token"}
424
751
  assert set(missing) == {"X-API-Key", "Missing-Key"}
425
752
 
426
- def test_retrieve_secrets_no_secret_keys(self, mock_config, remote_mcp_base_props):
753
+ def test_retrieve_secrets_no_secret_keys(self, remote_mcp_base_props):
427
754
  """Test retrieving secrets when no secret keys are defined."""
428
755
  server = ExternalToolServer(
429
756
  name="test-server",
@@ -436,21 +763,30 @@ class TestExternalToolServer:
436
763
  assert secrets == {}
437
764
  assert missing == []
438
765
 
439
- def test_save_secrets(self, mock_config, remote_mcp_base_props):
766
+ def test_retrieve_secrets_kiln_task(self, kiln_task_base_props):
767
+ """Test retrieving secrets for kiln task servers (should return empty)."""
768
+ server = ExternalToolServer(
769
+ name="test_server",
770
+ type=ToolServerType.kiln_task,
771
+ properties=kiln_task_base_props,
772
+ )
773
+
774
+ secrets, missing = server.retrieve_secrets()
775
+
776
+ assert secrets == {}
777
+ assert missing == []
778
+
779
+ def test_save_secrets(
780
+ self, mock_config, remote_mcp_props_with_secrets, sample_remote_mcp_secrets
781
+ ):
440
782
  """Test saving unsaved secrets to config."""
441
783
  server = ExternalToolServer(
442
784
  name="test-server",
443
785
  type=ToolServerType.remote_mcp,
444
- properties={
445
- **remote_mcp_base_props,
446
- "secret_header_keys": ["Authorization", "X-API-Key"],
447
- },
786
+ properties=remote_mcp_props_with_secrets,
448
787
  )
449
788
  server.id = "server-123"
450
- server._unsaved_secrets = {
451
- "Authorization": "Bearer token",
452
- "X-API-Key": "api-key",
453
- }
789
+ server._unsaved_secrets = sample_remote_mcp_secrets
454
790
 
455
791
  # Mock existing config secrets
456
792
  existing_secrets = {"other-server::key": "other-value"}
@@ -461,8 +797,8 @@ class TestExternalToolServer:
461
797
  # Should update config with new secrets
462
798
  expected_secrets = {
463
799
  "other-server::key": "other-value",
464
- "server-123::Authorization": "Bearer token",
465
- "server-123::X-API-Key": "api-key",
800
+ "server-123::Authorization": "Bearer token123",
801
+ "server-123::X-API-Key": "api-key-456",
466
802
  }
467
803
  mock_config.update_settings.assert_called_once_with(
468
804
  {MCP_SECRETS_KEY: expected_secrets}
@@ -471,18 +807,14 @@ class TestExternalToolServer:
471
807
  # Should clear unsaved secrets
472
808
  assert server._unsaved_secrets == {}
473
809
 
474
- def test_save_secrets_no_id_error(self, mock_config, remote_mcp_base_props):
810
+ def test_save_secrets_no_id_error(self, remote_mcp_props_with_secrets):
475
811
  """Test that saving secrets without ID raises error."""
476
812
  server = ExternalToolServer(
477
813
  name="test-server",
478
814
  type=ToolServerType.remote_mcp,
479
- properties={
480
- **remote_mcp_base_props,
481
- "secret_header_keys": ["Authorization"],
482
- },
815
+ properties=remote_mcp_props_with_secrets,
483
816
  )
484
- # Manually set unsaved secrets to bypass the empty check
485
- server._unsaved_secrets = {"Authorization": "Bearer token"}
817
+
486
818
  # Explicitly set ID to None to test the error condition
487
819
  server.id = None
488
820
 
@@ -492,42 +824,37 @@ class TestExternalToolServer:
492
824
  server._save_secrets()
493
825
 
494
826
  def test_save_secrets_with_no_unsaved_secrets(
495
- self, mock_config, remote_mcp_base_props
827
+ self, mock_config, remote_mcp_props_with_secrets
496
828
  ):
497
829
  """Test that saving secrets with no unsaved secrets does nothing."""
498
830
  server = ExternalToolServer(
499
831
  name="test-server",
500
832
  type=ToolServerType.remote_mcp,
501
- properties={
502
- **remote_mcp_base_props,
503
- "secret_header_keys": ["Authorization"],
504
- },
833
+ properties=remote_mcp_props_with_secrets,
505
834
  )
506
835
  server.id = "server-123"
507
836
 
508
- # No _unsaved_secrets set
837
+ # Override _unsaved_secrets to empty
838
+ server._unsaved_secrets = {}
509
839
 
510
840
  server._save_secrets()
511
841
 
512
842
  # Should not call update_settings
513
843
  mock_config.update_settings.assert_not_called()
514
844
 
515
- def test_delete_secrets(self, mock_config, remote_mcp_base_props):
845
+ def test_delete_secrets(self, mock_config, remote_mcp_props_with_secrets):
516
846
  """Test deleting secrets from config."""
517
847
  server = ExternalToolServer(
518
848
  name="test-server",
519
849
  type=ToolServerType.remote_mcp,
520
- properties={
521
- **remote_mcp_base_props,
522
- "secret_header_keys": ["Authorization", "X-API-Key"],
523
- },
850
+ properties=remote_mcp_props_with_secrets,
524
851
  )
525
852
  server.id = "server-123"
526
853
 
527
854
  # Mock existing config secrets
528
855
  existing_secrets = {
529
- "server-123::Authorization": "Bearer token",
530
- "server-123::X-API-Key": "api-key",
856
+ "server-123::Authorization": "Bearer token123",
857
+ "server-123::X-API-Key": "api-key-456",
531
858
  "other-server::Authorization": "other-token",
532
859
  }
533
860
  mock_config.get_value.return_value = existing_secrets
@@ -541,16 +868,13 @@ class TestExternalToolServer:
541
868
  )
542
869
 
543
870
  def test_delete_secrets_with_no_existing_secrets(
544
- self, mock_config, remote_mcp_base_props
871
+ self, mock_config, remote_mcp_props_with_secrets
545
872
  ):
546
873
  """Test deleting secrets when none exist in config."""
547
874
  server = ExternalToolServer(
548
875
  name="test-server",
549
876
  type=ToolServerType.remote_mcp,
550
- properties={
551
- **remote_mcp_base_props,
552
- "secret_header_keys": ["Authorization"],
553
- },
877
+ properties=remote_mcp_props_with_secrets,
554
878
  )
555
879
  server.id = "server-123"
556
880
 
@@ -562,18 +886,17 @@ class TestExternalToolServer:
562
886
  # Should still call update_settings with empty dict
563
887
  mock_config.update_settings.assert_called_once_with({MCP_SECRETS_KEY: {}})
564
888
 
565
- def test_save_to_file_saves_secrets_first(self, mock_config, remote_mcp_base_props):
889
+ def test_save_to_file_saves_secrets_first(
890
+ self, mock_config, remote_mcp_props_with_secrets, sample_remote_mcp_secrets
891
+ ):
566
892
  """Test that save_to_file automatically saves unsaved secrets first."""
567
893
  server = ExternalToolServer(
568
894
  name="test-server",
569
895
  type=ToolServerType.remote_mcp,
570
- properties={
571
- **remote_mcp_base_props,
572
- "secret_header_keys": ["Authorization"],
573
- },
896
+ properties=remote_mcp_props_with_secrets,
574
897
  )
575
898
  server.id = "server-123"
576
- server._unsaved_secrets = {"Authorization": "Bearer token"}
899
+ server._unsaved_secrets = sample_remote_mcp_secrets
577
900
 
578
901
  mock_config.get_value.return_value = {}
579
902
 
@@ -608,19 +931,19 @@ class TestExternalToolServer:
608
931
  # Should still call parent save_to_file
609
932
  mock_parent_save.assert_called_once()
610
933
 
611
- def test_config_secret_key_format(self, mock_config, remote_mcp_base_props):
934
+ def test_config_secret_key_format(self, remote_mcp_props_with_secrets):
612
935
  """Test the _config_secret_key method formats keys correctly."""
613
936
  server = ExternalToolServer(
614
937
  name="test-server",
615
938
  type=ToolServerType.remote_mcp,
616
- properties=remote_mcp_base_props,
939
+ properties=remote_mcp_props_with_secrets,
617
940
  )
618
941
  server.id = "server-123"
619
942
 
620
943
  assert server._config_secret_key("Authorization") == "server-123::Authorization"
621
944
  assert server._config_secret_key("X-API-Key") == "server-123::X-API-Key"
622
945
 
623
- def test_model_serialization_excludes_secrets(self, mock_config):
946
+ def test_model_serialization_excludes_secrets(self):
624
947
  """Test that model serialization excludes _unsaved_secrets private attribute and secrets from properties."""
625
948
  # Test all server types to ensure we update this test when new types are added
626
949
  for server_type in ToolServerType:
@@ -654,15 +977,42 @@ class TestExternalToolServer:
654
977
  assert "_unsaved_secrets" not in data
655
978
  assert "API_KEY" not in data["properties"]["env_vars"]
656
979
 
980
+ case ToolServerType.kiln_task:
981
+ server = ExternalToolServer(
982
+ name="test_kiln_task_server",
983
+ type=server_type,
984
+ properties={
985
+ "task_id": "task-123",
986
+ "run_config_id": "run-config-456",
987
+ "name": "test_task_tool",
988
+ "description": "A test task tool",
989
+ "is_archived": False,
990
+ },
991
+ )
992
+ data = server.model_dump()
993
+ assert "_unsaved_secrets" not in data
994
+ # Kiln task properties should be preserved as-is since there are no secrets
995
+ assert data["properties"]["task_id"] == "task-123"
996
+ assert data["properties"]["run_config_id"] == "run-config-456"
997
+ assert data["properties"]["name"] == "test_task_tool"
998
+ assert data["properties"]["description"] == "A test task tool"
999
+ assert data["properties"]["is_archived"] is False
1000
+
657
1001
  case _:
658
1002
  raise_exhaustive_enum_error(server_type)
659
1003
 
660
- def test_empty_secret_keys_list(self, mock_config, remote_mcp_base_props):
1004
+ def test_empty_secret_keys_list(self, remote_mcp_base_props):
661
1005
  """Test behavior with empty secret_header_keys list."""
662
- properties = {**remote_mcp_base_props, "secret_header_keys": []}
1006
+ properties = {
1007
+ "server_url": remote_mcp_base_props["server_url"],
1008
+ "headers": remote_mcp_base_props.get("headers", {}),
1009
+ "secret_header_keys": [],
1010
+ }
663
1011
 
664
1012
  server = ExternalToolServer(
665
- name="test-server", type=ToolServerType.remote_mcp, properties=properties
1013
+ name="test-server",
1014
+ type=ToolServerType.remote_mcp,
1015
+ properties=properties, # type: ignore
666
1016
  )
667
1017
 
668
1018
  assert server.get_secret_keys() == []
@@ -670,22 +1020,54 @@ class TestExternalToolServer:
670
1020
  assert secrets == {}
671
1021
  assert missing == []
672
1022
 
673
- def test_none_mcp_secrets_in_config(self, mock_config, remote_mcp_base_props):
1023
+ def test_none_mcp_secrets_in_config(
1024
+ self, mock_config, remote_mcp_props_with_secrets
1025
+ ):
674
1026
  """Test behavior when MCP_SECRETS_KEY returns None from config."""
675
1027
  server = ExternalToolServer(
676
1028
  name="test-server",
677
1029
  type=ToolServerType.remote_mcp,
678
- properties={
679
- **remote_mcp_base_props,
680
- "secret_header_keys": ["Authorization"],
681
- },
1030
+ properties=remote_mcp_props_with_secrets,
682
1031
  )
683
1032
  server.id = "server-123"
684
1033
 
1034
+ # Override _unsaved_secrets to empty
1035
+ server._unsaved_secrets = {}
1036
+
685
1037
  # Mock config returning None for MCP_SECRETS_KEY
686
1038
  mock_config.get_value.return_value = None
687
1039
 
688
1040
  secrets, missing = server.retrieve_secrets()
689
1041
 
690
1042
  assert secrets == {}
691
- assert missing == ["Authorization"]
1043
+ assert missing == ["Authorization", "X-API-Key"]
1044
+
1045
+ @pytest.mark.parametrize(
1046
+ "data, expected_type",
1047
+ [
1048
+ ({"type": "remote_mcp"}, ToolServerType.remote_mcp),
1049
+ ({"type": "local_mcp"}, ToolServerType.local_mcp),
1050
+ ({"type": "kiln_task"}, ToolServerType.kiln_task),
1051
+ ],
1052
+ )
1053
+ def test_type_from_data_valid(self, data, expected_type):
1054
+ """Test type_from_data with valid data."""
1055
+ result = ExternalToolServer.type_from_data(data)
1056
+ assert result == expected_type
1057
+
1058
+ def test_type_from_data_invalid(self):
1059
+ """Test type_from_data with invalid data."""
1060
+ valid_types = ", ".join(type.value for type in ToolServerType)
1061
+ invalid_type_error = f"type must be one of: {valid_types}"
1062
+
1063
+ test_cases = [
1064
+ ({}, "type is required"),
1065
+ ({"type": None}, "type is required"),
1066
+ ({"type": "invalid_type"}, invalid_type_error),
1067
+ ({"type": 123}, invalid_type_error),
1068
+ ({"type": ""}, invalid_type_error),
1069
+ ]
1070
+
1071
+ for data, expected_error in test_cases:
1072
+ with pytest.raises(ValueError, match=expected_error):
1073
+ ExternalToolServer.type_from_data(data)