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,124 @@
1
+ from typing import Union
2
+
3
+ from kiln_ai.datamodel.tool_id import KilnBuiltInToolId
4
+ from kiln_ai.tools.base_tool import KilnTool
5
+
6
+
7
+ class AddTool(KilnTool):
8
+ """
9
+ A concrete tool that adds two numbers together.
10
+ Demonstrates how to use the KilnTool base class.
11
+ """
12
+
13
+ def __init__(self):
14
+ parameters_schema = {
15
+ "type": "object",
16
+ "properties": {
17
+ "a": {"type": "number", "description": "The first number to add"},
18
+ "b": {"type": "number", "description": "The second number to add"},
19
+ },
20
+ "required": ["a", "b"],
21
+ }
22
+
23
+ super().__init__(
24
+ tool_id=KilnBuiltInToolId.ADD_NUMBERS,
25
+ name="add",
26
+ description="Add two numbers together and return the result",
27
+ parameters_schema=parameters_schema,
28
+ )
29
+
30
+ async def run(self, a: Union[int, float], b: Union[int, float]) -> str:
31
+ """Add two numbers and return the result."""
32
+ return str(a + b)
33
+
34
+
35
+ class SubtractTool(KilnTool):
36
+ """
37
+ A concrete tool that subtracts two numbers.
38
+ """
39
+
40
+ def __init__(self):
41
+ parameters_schema = {
42
+ "type": "object",
43
+ "properties": {
44
+ "a": {"type": "number", "description": "The first number (minuend)"},
45
+ "b": {
46
+ "type": "number",
47
+ "description": "The second number to subtract (subtrahend)",
48
+ },
49
+ },
50
+ "required": ["a", "b"],
51
+ }
52
+
53
+ super().__init__(
54
+ tool_id=KilnBuiltInToolId.SUBTRACT_NUMBERS,
55
+ name="subtract",
56
+ description="Subtract the second number from the first number and return the result",
57
+ parameters_schema=parameters_schema,
58
+ )
59
+
60
+ async def run(self, a: Union[int, float], b: Union[int, float]) -> str:
61
+ """Subtract b from a and return the result."""
62
+ return str(a - b)
63
+
64
+
65
+ class MultiplyTool(KilnTool):
66
+ """
67
+ A concrete tool that multiplies two numbers together.
68
+ """
69
+
70
+ def __init__(self):
71
+ parameters_schema = {
72
+ "type": "object",
73
+ "properties": {
74
+ "a": {"type": "number", "description": "The first number to multiply"},
75
+ "b": {"type": "number", "description": "The second number to multiply"},
76
+ },
77
+ "required": ["a", "b"],
78
+ }
79
+
80
+ super().__init__(
81
+ tool_id=KilnBuiltInToolId.MULTIPLY_NUMBERS,
82
+ name="multiply",
83
+ description="Multiply two numbers together and return the result",
84
+ parameters_schema=parameters_schema,
85
+ )
86
+
87
+ async def run(self, a: Union[int, float], b: Union[int, float]) -> str:
88
+ """Multiply two numbers and return the result."""
89
+ return str(a * b)
90
+
91
+
92
+ class DivideTool(KilnTool):
93
+ """
94
+ A concrete tool that divides two numbers.
95
+ """
96
+
97
+ def __init__(self):
98
+ parameters_schema = {
99
+ "type": "object",
100
+ "properties": {
101
+ "a": {
102
+ "type": "number",
103
+ "description": "The dividend (number to be divided)",
104
+ },
105
+ "b": {
106
+ "type": "number",
107
+ "description": "The divisor (number to divide by)",
108
+ },
109
+ },
110
+ "required": ["a", "b"],
111
+ }
112
+
113
+ super().__init__(
114
+ tool_id=KilnBuiltInToolId.DIVIDE_NUMBERS,
115
+ name="divide",
116
+ description="Divide the first number by the second number and return the result",
117
+ parameters_schema=parameters_schema,
118
+ )
119
+
120
+ async def run(self, a: Union[int, float], b: Union[int, float]) -> str:
121
+ """Divide a by b and return the result."""
122
+ if b == 0:
123
+ raise ZeroDivisionError("Cannot divide by zero")
124
+ return str(a / b)
@@ -0,0 +1,204 @@
1
+ import pytest
2
+
3
+ from kiln_ai.datamodel.tool_id import KilnBuiltInToolId
4
+ from kiln_ai.tools.built_in_tools.math_tools import (
5
+ AddTool,
6
+ DivideTool,
7
+ MultiplyTool,
8
+ SubtractTool,
9
+ )
10
+
11
+
12
+ class TestAddTool:
13
+ """Test the AddTool class."""
14
+
15
+ async def test_init(self):
16
+ """Test AddTool initialization."""
17
+ tool = AddTool()
18
+ assert await tool.id() == KilnBuiltInToolId.ADD_NUMBERS
19
+ assert await tool.name() == "add"
20
+ assert (
21
+ await tool.description() == "Add two numbers together and return the result"
22
+ )
23
+
24
+ async def test_toolcall_definition(self):
25
+ """Test AddTool toolcall definition structure."""
26
+ tool = AddTool()
27
+ definition = await tool.toolcall_definition()
28
+
29
+ assert definition["type"] == "function"
30
+ assert definition["function"]["name"] == "add"
31
+ assert (
32
+ definition["function"]["description"]
33
+ == "Add two numbers together and return the result"
34
+ )
35
+ assert "properties" in definition["function"]["parameters"]
36
+ assert "a" in definition["function"]["parameters"]["properties"]
37
+ assert "b" in definition["function"]["parameters"]["properties"]
38
+
39
+ @pytest.mark.parametrize(
40
+ "a, b, expected",
41
+ [
42
+ (1, 2, "3"),
43
+ (0, 0, "0"),
44
+ (-1, 1, "0"),
45
+ (2.5, 3.5, "6.0"),
46
+ (-2.5, -3.5, "-6.0"),
47
+ (100, 200, "300"),
48
+ ],
49
+ )
50
+ async def test_run_various_inputs(self, a, b, expected):
51
+ """Test AddTool run method with various inputs."""
52
+ tool = AddTool()
53
+ result = await tool.run(a=a, b=b)
54
+ assert result == expected
55
+
56
+
57
+ class TestSubtractTool:
58
+ """Test the SubtractTool class."""
59
+
60
+ async def test_init(self):
61
+ """Test SubtractTool initialization."""
62
+ tool = SubtractTool()
63
+ assert await tool.id() == KilnBuiltInToolId.SUBTRACT_NUMBERS
64
+ assert await tool.name() == "subtract"
65
+ assert (
66
+ await tool.description()
67
+ == "Subtract the second number from the first number and return the result"
68
+ )
69
+
70
+ async def test_toolcall_definition(self):
71
+ """Test SubtractTool toolcall definition structure."""
72
+ tool = SubtractTool()
73
+ definition = await tool.toolcall_definition()
74
+
75
+ assert definition["type"] == "function"
76
+ assert definition["function"]["name"] == "subtract"
77
+ assert (
78
+ definition["function"]["description"]
79
+ == "Subtract the second number from the first number and return the result"
80
+ )
81
+ assert "properties" in definition["function"]["parameters"]
82
+ assert "a" in definition["function"]["parameters"]["properties"]
83
+ assert "b" in definition["function"]["parameters"]["properties"]
84
+
85
+ @pytest.mark.parametrize(
86
+ "a, b, expected",
87
+ [
88
+ (5, 3, "2"),
89
+ (0, 0, "0"),
90
+ (1, -1, "2"),
91
+ (5.5, 2.5, "3.0"),
92
+ (-2.5, -3.5, "1.0"),
93
+ (100, 200, "-100"),
94
+ ],
95
+ )
96
+ async def test_run_various_inputs(self, a, b, expected):
97
+ """Test SubtractTool run method with various inputs."""
98
+ tool = SubtractTool()
99
+ result = await tool.run(a=a, b=b)
100
+ assert result == expected
101
+
102
+
103
+ class TestMultiplyTool:
104
+ """Test the MultiplyTool class."""
105
+
106
+ async def test_init(self):
107
+ """Test MultiplyTool initialization."""
108
+ tool = MultiplyTool()
109
+ assert await tool.id() == KilnBuiltInToolId.MULTIPLY_NUMBERS
110
+ assert await tool.name() == "multiply"
111
+ assert (
112
+ await tool.description()
113
+ == "Multiply two numbers together and return the result"
114
+ )
115
+
116
+ async def test_toolcall_definition(self):
117
+ """Test MultiplyTool toolcall definition structure."""
118
+ tool = MultiplyTool()
119
+ definition = await tool.toolcall_definition()
120
+
121
+ assert definition["type"] == "function"
122
+ assert definition["function"]["name"] == "multiply"
123
+ assert (
124
+ definition["function"]["description"]
125
+ == "Multiply two numbers together and return the result"
126
+ )
127
+ assert "properties" in definition["function"]["parameters"]
128
+ assert "a" in definition["function"]["parameters"]["properties"]
129
+ assert "b" in definition["function"]["parameters"]["properties"]
130
+
131
+ @pytest.mark.parametrize(
132
+ "a, b, expected",
133
+ [
134
+ (2, 3, "6"),
135
+ (0, 5, "0"),
136
+ (-2, 3, "-6"),
137
+ (2.5, 4, "10.0"),
138
+ (-2.5, -4, "10.0"),
139
+ (1, 1, "1"),
140
+ ],
141
+ )
142
+ async def test_run_various_inputs(self, a, b, expected):
143
+ """Test MultiplyTool run method with various inputs."""
144
+ tool = MultiplyTool()
145
+ result = await tool.run(a=a, b=b)
146
+ assert result == expected
147
+
148
+
149
+ class TestDivideTool:
150
+ """Test the DivideTool class."""
151
+
152
+ async def test_init(self):
153
+ """Test DivideTool initialization."""
154
+ tool = DivideTool()
155
+ assert await tool.id() == KilnBuiltInToolId.DIVIDE_NUMBERS
156
+ assert await tool.name() == "divide"
157
+ assert (
158
+ await tool.description()
159
+ == "Divide the first number by the second number and return the result"
160
+ )
161
+
162
+ async def test_toolcall_definition(self):
163
+ """Test DivideTool toolcall definition structure."""
164
+ tool = DivideTool()
165
+ definition = await tool.toolcall_definition()
166
+
167
+ assert definition["type"] == "function"
168
+ assert definition["function"]["name"] == "divide"
169
+ assert (
170
+ definition["function"]["description"]
171
+ == "Divide the first number by the second number and return the result"
172
+ )
173
+ assert "properties" in definition["function"]["parameters"]
174
+ assert "a" in definition["function"]["parameters"]["properties"]
175
+ assert "b" in definition["function"]["parameters"]["properties"]
176
+
177
+ @pytest.mark.parametrize(
178
+ "a, b, expected",
179
+ [
180
+ (6, 2, "3.0"),
181
+ (1, 1, "1.0"),
182
+ (-6, 2, "-3.0"),
183
+ (7.5, 2.5, "3.0"),
184
+ (-10, -2, "5.0"),
185
+ (0, 5, "0.0"),
186
+ ],
187
+ )
188
+ async def test_run_various_inputs(self, a, b, expected):
189
+ """Test DivideTool run method with various inputs."""
190
+ tool = DivideTool()
191
+ result = await tool.run(a=a, b=b)
192
+ assert result == expected
193
+
194
+ async def test_divide_by_zero(self):
195
+ """Test that division by zero raises ZeroDivisionError."""
196
+ tool = DivideTool()
197
+ with pytest.raises(ZeroDivisionError, match="Cannot divide by zero"):
198
+ await tool.run(a=5, b=0)
199
+
200
+ async def test_divide_zero_by_zero(self):
201
+ """Test that zero divided by zero raises ZeroDivisionError."""
202
+ tool = DivideTool()
203
+ with pytest.raises(ZeroDivisionError, match="Cannot divide by zero"):
204
+ await tool.run(a=0, b=0)
@@ -0,0 +1,95 @@
1
+ from typing import Any, Dict
2
+
3
+ from mcp.types import CallToolResult, TextContent
4
+ from mcp.types import Tool as MCPTool
5
+
6
+ from kiln_ai.datamodel.external_tool_server import ExternalToolServer
7
+ from kiln_ai.datamodel.tool_id import MCP_REMOTE_TOOL_ID_PREFIX, ToolId
8
+ from kiln_ai.tools.base_tool import KilnToolInterface
9
+ from kiln_ai.tools.mcp_session_manager import MCPSessionManager
10
+
11
+
12
+ class MCPServerTool(KilnToolInterface):
13
+ def __init__(self, data_model: ExternalToolServer, name: str):
14
+ self._tool_id = f"{MCP_REMOTE_TOOL_ID_PREFIX}{data_model.id}::{name}"
15
+ self._tool_server_model = data_model
16
+ self._name = name
17
+ self._tool: MCPTool | None = None
18
+
19
+ async def id(self) -> ToolId:
20
+ return self._tool_id
21
+
22
+ async def name(self) -> str:
23
+ return self._name
24
+
25
+ async def description(self) -> str:
26
+ await self._load_tool_properties()
27
+ return self._description
28
+
29
+ async def toolcall_definition(self) -> Dict[str, Any]:
30
+ """Generate OpenAI-compatible tool definition."""
31
+ await self._load_tool_properties()
32
+ return {
33
+ "type": "function",
34
+ "function": {
35
+ "name": await self.name(),
36
+ "description": await self.description(),
37
+ "parameters": self._parameters_schema,
38
+ },
39
+ }
40
+
41
+ async def run(self, **kwargs) -> Any:
42
+ result = await self._call_tool(**kwargs)
43
+
44
+ if result.isError:
45
+ raise ValueError(
46
+ f"Tool {await self.name()} returned an error: {result.content}"
47
+ )
48
+
49
+ if not result.content:
50
+ raise ValueError("Tool returned no content")
51
+
52
+ # raise error if the first block is not a text block
53
+ if not isinstance(result.content[0], TextContent):
54
+ raise ValueError("First block must be a text block")
55
+
56
+ # raise error if there is more than one content block
57
+ if len(result.content) > 1:
58
+ raise ValueError("Tool returned multiple content blocks, expected one")
59
+
60
+ return result.content[0].text
61
+
62
+ # Call the MCP Tool
63
+ async def _call_tool(self, **kwargs) -> CallToolResult:
64
+ async with MCPSessionManager.shared().mcp_client(
65
+ self._tool_server_model
66
+ ) as session:
67
+ result = await session.call_tool(
68
+ name=await self.name(),
69
+ arguments=kwargs,
70
+ )
71
+ return result
72
+
73
+ async def _load_tool_properties(self):
74
+ if self._tool is not None:
75
+ return
76
+
77
+ tool = await self._get_tool(self._name)
78
+ self._tool = tool
79
+ self._description = tool.description or "N/A"
80
+ self._parameters_schema = tool.inputSchema or {
81
+ "type": "object",
82
+ "properties": {},
83
+ }
84
+
85
+ # Get the MCP Tool from the server
86
+ async def _get_tool(self, tool_name: str) -> MCPTool:
87
+ async with MCPSessionManager.shared().mcp_client(
88
+ self._tool_server_model
89
+ ) as session:
90
+ tools = await session.list_tools()
91
+
92
+ tool = next((tool for tool in tools.tools if tool.name == tool_name), None)
93
+ if tool is None:
94
+ raise ValueError(f"Tool {tool_name} not found")
95
+ return tool
@@ -0,0 +1,243 @@
1
+ import logging
2
+ import os
3
+ import subprocess
4
+ import sys
5
+ from contextlib import asynccontextmanager
6
+ from typing import AsyncGenerator
7
+
8
+ import httpx
9
+ from mcp import StdioServerParameters
10
+ from mcp.client.session import ClientSession
11
+ from mcp.client.stdio import stdio_client
12
+ from mcp.client.streamable_http import streamablehttp_client
13
+ from mcp.shared.exceptions import McpError
14
+
15
+ from kiln_ai.datamodel.external_tool_server import ExternalToolServer, ToolServerType
16
+ from kiln_ai.utils.config import Config
17
+ from kiln_ai.utils.exhaustive_error import raise_exhaustive_enum_error
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+
22
+ class MCPSessionManager:
23
+ """
24
+ This class is a singleton that manages MCP sessions for remote MCP servers.
25
+ """
26
+
27
+ _shared_instance = None
28
+
29
+ def __init__(self):
30
+ self._shell_path = None
31
+
32
+ @classmethod
33
+ def shared(cls):
34
+ if cls._shared_instance is None:
35
+ cls._shared_instance = cls()
36
+ return cls._shared_instance
37
+
38
+ @asynccontextmanager
39
+ async def mcp_client(
40
+ self,
41
+ tool_server: ExternalToolServer,
42
+ ) -> AsyncGenerator[
43
+ ClientSession,
44
+ None,
45
+ ]:
46
+ match tool_server.type:
47
+ case ToolServerType.remote_mcp:
48
+ async with self._create_remote_mcp_session(tool_server) as session:
49
+ yield session
50
+ case ToolServerType.local_mcp:
51
+ async with self._create_local_mcp_session(tool_server) as session:
52
+ yield session
53
+ case _:
54
+ raise_exhaustive_enum_error(tool_server.type)
55
+
56
+ def _extract_first_exception(
57
+ self, exception: Exception, target_type: type | tuple[type, ...]
58
+ ) -> Exception | None:
59
+ """
60
+ Extract first relevant exception from ExceptionGroup or handle direct exceptions
61
+ """
62
+ # Check if the exception itself is of the target type
63
+ if isinstance(exception, target_type):
64
+ return exception
65
+
66
+ # Handle ExceptionGroup
67
+ if hasattr(exception, "exceptions"):
68
+ exceptions_attr = getattr(exception, "exceptions", None)
69
+ if exceptions_attr:
70
+ for nested_exc in exceptions_attr:
71
+ result = self._extract_first_exception(nested_exc, target_type)
72
+ if result:
73
+ return result
74
+
75
+ return None
76
+
77
+ @asynccontextmanager
78
+ async def _create_remote_mcp_session(
79
+ self,
80
+ tool_server: ExternalToolServer,
81
+ ) -> AsyncGenerator[ClientSession, None]:
82
+ """
83
+ Create a session for a remote MCP server.
84
+ """
85
+ # Make sure the server_url is set
86
+ server_url = tool_server.properties.get("server_url")
87
+ if not server_url:
88
+ raise ValueError("server_url is required")
89
+
90
+ # Make a copy of the headers to avoid modifying the original object
91
+ headers = tool_server.properties.get("headers", {}).copy()
92
+
93
+ # Retrieve secret headers from configuration and merge with regular headers
94
+ secret_headers, _ = tool_server.retrieve_secrets()
95
+ headers.update(secret_headers)
96
+
97
+ try:
98
+ async with streamablehttp_client(server_url, headers=headers) as (
99
+ read_stream,
100
+ write_stream,
101
+ _,
102
+ ):
103
+ # Create a session using the client streams
104
+ async with ClientSession(read_stream, write_stream) as session:
105
+ await session.initialize()
106
+ yield session
107
+ except Exception as e:
108
+ # Handle HTTP errors with user-friendly messages
109
+
110
+ # Check for HTTPStatusError
111
+ http_error = self._extract_first_exception(e, httpx.HTTPStatusError)
112
+ if http_error and isinstance(http_error, httpx.HTTPStatusError):
113
+ raise ValueError(
114
+ f"The MCP server rejected the request. "
115
+ f"Status {http_error.response.status_code}. "
116
+ f"Response from server:\n{http_error.response.reason_phrase}"
117
+ )
118
+
119
+ # Check for connection errors
120
+ connection_error_types = (ConnectionError, OSError, httpx.RequestError)
121
+ connection_error = self._extract_first_exception(e, connection_error_types)
122
+ if connection_error and isinstance(
123
+ connection_error, connection_error_types
124
+ ):
125
+ raise RuntimeError(
126
+ f"Unable to connect to MCP server. Please verify the configurations are correct, the server is running, and your network connection is working. Original error: {connection_error}"
127
+ ) from e
128
+
129
+ # If no known error types found, re-raise the original exception
130
+ raise RuntimeError(
131
+ f"Failed to connect to the MCP Server. Check the server's docs for troubleshooting. Original error: {e}"
132
+ ) from e
133
+
134
+ @asynccontextmanager
135
+ async def _create_local_mcp_session(
136
+ self,
137
+ tool_server: ExternalToolServer,
138
+ ) -> AsyncGenerator[ClientSession, None]:
139
+ """
140
+ Create a session for a local MCP server.
141
+ """
142
+ command = tool_server.properties.get("command")
143
+ if not command:
144
+ raise ValueError(
145
+ "Attempted to start local MCP server, but no command was provided"
146
+ )
147
+
148
+ args = tool_server.properties.get("args", [])
149
+ if not isinstance(args, list):
150
+ raise ValueError(
151
+ "Attempted to start local MCP server, but args is not a list of strings"
152
+ )
153
+
154
+ # Make a copy of the env_vars to avoid modifying the original object
155
+ env_vars = tool_server.properties.get("env_vars", {}).copy()
156
+
157
+ # Retrieve secret environment variables from configuration and merge with regular env_vars
158
+ secret_env_vars, _ = tool_server.retrieve_secrets()
159
+ env_vars.update(secret_env_vars)
160
+
161
+ # Set PATH, only if not explicitly set during MCP tool setup
162
+ if "PATH" not in env_vars:
163
+ env_vars["PATH"] = self._get_path()
164
+
165
+ # Set the server parameters
166
+ server_params = StdioServerParameters(
167
+ command=command,
168
+ args=args,
169
+ env=env_vars,
170
+ )
171
+
172
+ try:
173
+ async with stdio_client(server_params) as (read, write):
174
+ async with ClientSession(read, write) as session:
175
+ await session.initialize()
176
+ yield session
177
+ except Exception as e:
178
+ # Check for MCP errors. Things like wrong arguments would fall here.
179
+ mcp_error = self._extract_first_exception(e, McpError)
180
+ if mcp_error and isinstance(mcp_error, McpError):
181
+ self._raise_local_mcp_error(mcp_error)
182
+
183
+ # Re-raise the original error but with a friendlier message
184
+ self._raise_local_mcp_error(e)
185
+
186
+ def _raise_local_mcp_error(self, e: Exception):
187
+ """
188
+ Raise a ValueError with a friendlier message for local MCP errors.
189
+ """
190
+ raise RuntimeError(
191
+ f"MCP server failed to start. Please verify your command, arguments, and environment variables, and consult the server's documentation for the correct setup. Original error: {e}"
192
+ ) from e
193
+
194
+ def _get_path(self) -> str:
195
+ """
196
+ Builds a PATH environment variable. From environment, Kiln Config, and loading rc files.
197
+ """
198
+
199
+ # If the user sets a custom MCP path, use only it. This also functions as a way to disable the shell path loading.
200
+ custom_mcp_path = Config.shared().get_value("custom_mcp_path")
201
+ if custom_mcp_path is not None:
202
+ return custom_mcp_path
203
+ else:
204
+ return self.get_shell_path()
205
+
206
+ def get_shell_path(self) -> str:
207
+ # Windows has a global PATH, so we don't need to source rc files
208
+ if sys.platform in ("win32", "Windows"):
209
+ return os.environ.get("PATH", "")
210
+
211
+ # Cache
212
+ if self._shell_path is not None:
213
+ return self._shell_path
214
+
215
+ # Attempt to get shell PATH from preferred shell, which will source rc files, run scripts like `brew shellenv`, etc.
216
+ shell_path = None
217
+ try:
218
+ shell = os.environ.get("SHELL", "/bin/bash")
219
+ # Use -l (login) flag to source ~/.profile, ~/.bash_profile, ~/.zprofile, etc.
220
+ result = subprocess.run(
221
+ [shell, "-l", "-c", "echo $PATH"],
222
+ capture_output=True,
223
+ text=True,
224
+ timeout=3,
225
+ )
226
+ if result.returncode == 0:
227
+ shell_path = result.stdout.strip()
228
+ except (subprocess.TimeoutExpired, subprocess.SubprocessError, Exception) as e:
229
+ logger.error(f"Shell path exception details: {e}")
230
+
231
+ # Fallback to environment PATH
232
+ if shell_path is None:
233
+ logger.error(
234
+ "Error getting shell PATH. You may not be able to find MCP server commands like 'npx'. You can set a custom MCP path in the Kiln config file. See docs for details."
235
+ )
236
+ shell_path = os.environ.get("PATH", "")
237
+
238
+ self._shell_path = shell_path
239
+ return shell_path
240
+
241
+ def clear_shell_path_cache(self):
242
+ """Clear the cached shell path. Typically used when adding a new tool, which might have just been installed."""
243
+ self._shell_path = None