datarobot-genai 0.2.13__py3-none-any.whl → 0.2.20__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.
- datarobot_genai/drmcp/core/config.py +24 -0
- datarobot_genai/drmcp/core/tool_config.py +8 -0
- datarobot_genai/drmcp/core/utils.py +7 -0
- datarobot_genai/drmcp/test_utils/elicitation_test_tool.py +89 -0
- datarobot_genai/drmcp/test_utils/integration_mcp_server.py +7 -0
- datarobot_genai/drmcp/test_utils/mcp_utils_ete.py +9 -1
- datarobot_genai/drmcp/test_utils/mcp_utils_integration.py +17 -4
- datarobot_genai/drmcp/test_utils/openai_llm_mcp_client.py +71 -8
- datarobot_genai/drmcp/test_utils/test_interactive.py +205 -0
- datarobot_genai/drmcp/test_utils/tool_base_ete.py +22 -20
- datarobot_genai/drmcp/tools/clients/confluence.py +201 -4
- datarobot_genai/drmcp/tools/clients/gdrive.py +248 -0
- datarobot_genai/drmcp/tools/clients/jira.py +119 -5
- datarobot_genai/drmcp/tools/confluence/tools.py +109 -2
- datarobot_genai/drmcp/tools/gdrive/__init__.py +0 -0
- datarobot_genai/drmcp/tools/gdrive/tools.py +88 -0
- datarobot_genai/drmcp/tools/jira/tools.py +142 -0
- datarobot_genai/drmcp/tools/predictive/data.py +60 -32
- datarobot_genai/nat/agent.py +20 -7
- datarobot_genai/nat/helpers.py +87 -0
- {datarobot_genai-0.2.13.dist-info → datarobot_genai-0.2.20.dist-info}/METADATA +1 -1
- {datarobot_genai-0.2.13.dist-info → datarobot_genai-0.2.20.dist-info}/RECORD +26 -20
- {datarobot_genai-0.2.13.dist-info → datarobot_genai-0.2.20.dist-info}/WHEEL +0 -0
- {datarobot_genai-0.2.13.dist-info → datarobot_genai-0.2.20.dist-info}/entry_points.txt +0 -0
- {datarobot_genai-0.2.13.dist-info → datarobot_genai-0.2.20.dist-info}/licenses/AUTHORS +0 -0
- {datarobot_genai-0.2.13.dist-info → datarobot_genai-0.2.20.dist-info}/licenses/LICENSE +0 -0
|
@@ -245,6 +245,30 @@ class MCPServerConfig(BaseSettings):
|
|
|
245
245
|
os.getenv("CONFLUENCE_CLIENT_ID") and os.getenv("CONFLUENCE_CLIENT_SECRET")
|
|
246
246
|
)
|
|
247
247
|
|
|
248
|
+
# Gdrive tools
|
|
249
|
+
enable_gdrive_tools: bool = Field(
|
|
250
|
+
default=False,
|
|
251
|
+
validation_alias=AliasChoices(
|
|
252
|
+
RUNTIME_PARAM_ENV_VAR_NAME_PREFIX + "ENABLE_GDRIVE_TOOLS",
|
|
253
|
+
"ENABLE_GDRIVE_TOOLS",
|
|
254
|
+
),
|
|
255
|
+
description="Enable/disable GDrive tools",
|
|
256
|
+
)
|
|
257
|
+
is_gdrive_oauth_provider_configured: bool = Field(
|
|
258
|
+
default=False,
|
|
259
|
+
validation_alias=AliasChoices(
|
|
260
|
+
RUNTIME_PARAM_ENV_VAR_NAME_PREFIX + "IS_GDRIVE_OAUTH_PROVIDER_CONFIGURED",
|
|
261
|
+
"IS_GDRIVE_OAUTH_PROVIDER_CONFIGURED",
|
|
262
|
+
),
|
|
263
|
+
description="Whether GDrive OAuth provider is configured for GDrive integration",
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
@property
|
|
267
|
+
def is_gdrive_oauth_configured(self) -> bool:
|
|
268
|
+
return self.is_gdrive_oauth_provider_configured or bool(
|
|
269
|
+
os.getenv("GDRIVE_CLIENT_ID") and os.getenv("GDRIVE_CLIENT_SECRET")
|
|
270
|
+
)
|
|
271
|
+
|
|
248
272
|
@field_validator(
|
|
249
273
|
"otel_attributes",
|
|
250
274
|
mode="before",
|
|
@@ -29,6 +29,7 @@ class ToolType(str, Enum):
|
|
|
29
29
|
PREDICTIVE = "predictive"
|
|
30
30
|
JIRA = "jira"
|
|
31
31
|
CONFLUENCE = "confluence"
|
|
32
|
+
GDRIVE = "gdrive"
|
|
32
33
|
|
|
33
34
|
|
|
34
35
|
class ToolConfig(TypedDict):
|
|
@@ -64,6 +65,13 @@ TOOL_CONFIGS: dict[ToolType, ToolConfig] = {
|
|
|
64
65
|
package_prefix="datarobot_genai.drmcp.tools.confluence",
|
|
65
66
|
config_field_name="enable_confluence_tools",
|
|
66
67
|
),
|
|
68
|
+
ToolType.GDRIVE: ToolConfig(
|
|
69
|
+
name="gdrive",
|
|
70
|
+
oauth_check=lambda config: config.is_gdrive_oauth_configured,
|
|
71
|
+
directory="gdrive",
|
|
72
|
+
package_prefix="datarobot_genai.drmcp.tools.gdrive",
|
|
73
|
+
config_field_name="enable_gdrive_tools",
|
|
74
|
+
),
|
|
67
75
|
}
|
|
68
76
|
|
|
69
77
|
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
import base64
|
|
15
15
|
import uuid
|
|
16
16
|
from typing import Any
|
|
17
|
+
from urllib.parse import urlparse
|
|
17
18
|
|
|
18
19
|
import boto3
|
|
19
20
|
from fastmcp.resources import HttpResource
|
|
@@ -129,3 +130,9 @@ def format_response_as_tool_result(data: bytes, content_type: str, charset: str)
|
|
|
129
130
|
}
|
|
130
131
|
|
|
131
132
|
return ToolResult(structured_content=payload)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def is_valid_url(url: str) -> bool:
|
|
136
|
+
"""Check if a URL is valid."""
|
|
137
|
+
result = urlparse(url)
|
|
138
|
+
return all([result.scheme, result.netloc])
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# Copyright 2025 DataRobot, Inc.
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
# See the License for the specific language governing permissions and
|
|
13
|
+
# limitations under the License.
|
|
14
|
+
|
|
15
|
+
"""Test tool for elicitation testing.
|
|
16
|
+
|
|
17
|
+
This module registers a test tool that can be used to test elicitation support.
|
|
18
|
+
It should be imported in tests that need it.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from fastmcp import Context
|
|
22
|
+
from fastmcp.server.context import AcceptedElicitation
|
|
23
|
+
from fastmcp.server.context import CancelledElicitation
|
|
24
|
+
from fastmcp.server.context import DeclinedElicitation
|
|
25
|
+
|
|
26
|
+
from datarobot_genai.drmcp.core.mcp_instance import mcp
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@mcp.tool(
|
|
30
|
+
name="get_user_greeting",
|
|
31
|
+
description=(
|
|
32
|
+
"Get a personalized greeting for a user. "
|
|
33
|
+
"Requires a username - if not provided, will request it via elicitation."
|
|
34
|
+
),
|
|
35
|
+
tags={"test", "elicitation"},
|
|
36
|
+
)
|
|
37
|
+
async def get_user_greeting(ctx: Context, username: str | None = None) -> dict:
|
|
38
|
+
"""
|
|
39
|
+
Get a personalized greeting for a user.
|
|
40
|
+
|
|
41
|
+
This tool demonstrates FastMCP's built-in elicitation by requiring a username parameter.
|
|
42
|
+
If username is not provided, it uses ctx.elicit() to request it from the user.
|
|
43
|
+
|
|
44
|
+
Args:
|
|
45
|
+
ctx: FastMCP context (automatically injected)
|
|
46
|
+
username: The username to greet. If None, elicitation will be triggered.
|
|
47
|
+
|
|
48
|
+
Returns
|
|
49
|
+
-------
|
|
50
|
+
Dictionary with greeting message or error if elicitation was declined/cancelled
|
|
51
|
+
"""
|
|
52
|
+
if not username:
|
|
53
|
+
# Use elicitation to request username from the client
|
|
54
|
+
try:
|
|
55
|
+
result = await ctx.elicit(
|
|
56
|
+
message="Username is required to generate a personalized greeting",
|
|
57
|
+
response_type=str,
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
if isinstance(result, AcceptedElicitation):
|
|
61
|
+
username = result.data
|
|
62
|
+
elif isinstance(result, DeclinedElicitation):
|
|
63
|
+
return {
|
|
64
|
+
"status": "error",
|
|
65
|
+
"error": "Username declined by user",
|
|
66
|
+
"message": "Cannot generate greeting without username",
|
|
67
|
+
}
|
|
68
|
+
elif isinstance(result, CancelledElicitation):
|
|
69
|
+
return {
|
|
70
|
+
"status": "error",
|
|
71
|
+
"error": "Operation cancelled",
|
|
72
|
+
"message": "Greeting request was cancelled",
|
|
73
|
+
}
|
|
74
|
+
except Exception:
|
|
75
|
+
# Elicitation not supported by client - return graceful skip
|
|
76
|
+
return {
|
|
77
|
+
"status": "skipped",
|
|
78
|
+
"message": (
|
|
79
|
+
"Elicitation not supported by client. "
|
|
80
|
+
"Username parameter is required when client does not support elicitation."
|
|
81
|
+
),
|
|
82
|
+
"elicitation_supported": False,
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return {
|
|
86
|
+
"status": "success",
|
|
87
|
+
"message": f"Hello, {username}! Welcome to the DataRobot MCP server.",
|
|
88
|
+
"username": username,
|
|
89
|
+
}
|
|
@@ -26,6 +26,13 @@ from typing import Any
|
|
|
26
26
|
|
|
27
27
|
from datarobot_genai.drmcp import create_mcp_server
|
|
28
28
|
|
|
29
|
+
# Import elicitation test tool to register it with the MCP server
|
|
30
|
+
try:
|
|
31
|
+
from datarobot_genai.drmcp.test_utils import elicitation_test_tool # noqa: F401
|
|
32
|
+
except ImportError:
|
|
33
|
+
# Test utils not available (e.g., running in production)
|
|
34
|
+
pass
|
|
35
|
+
|
|
29
36
|
# Import user components (will be used conditionally)
|
|
30
37
|
try:
|
|
31
38
|
from app.core.server_lifecycle import ServerLifecycle # type: ignore # noqa: F401
|
|
@@ -15,6 +15,7 @@ import asyncio
|
|
|
15
15
|
import os
|
|
16
16
|
from collections.abc import AsyncGenerator
|
|
17
17
|
from contextlib import asynccontextmanager
|
|
18
|
+
from typing import Any
|
|
18
19
|
|
|
19
20
|
import aiohttp
|
|
20
21
|
from aiohttp import ClientSession as HttpClientSession
|
|
@@ -78,6 +79,7 @@ def get_headers() -> dict[str, str]:
|
|
|
78
79
|
@asynccontextmanager
|
|
79
80
|
async def ete_test_mcp_session(
|
|
80
81
|
additional_headers: dict[str, str] | None = None,
|
|
82
|
+
elicitation_callback: Any | None = None,
|
|
81
83
|
) -> AsyncGenerator[ClientSession, None]:
|
|
82
84
|
"""Create an MCP session for each test.
|
|
83
85
|
|
|
@@ -85,6 +87,10 @@ async def ete_test_mcp_session(
|
|
|
85
87
|
----------
|
|
86
88
|
additional_headers : dict[str, str], optional
|
|
87
89
|
Additional headers to include in the MCP session (e.g., auth headers for testing).
|
|
90
|
+
elicitation_callback : callable, optional
|
|
91
|
+
Callback function to handle elicitation requests from the server.
|
|
92
|
+
The callback should have signature:
|
|
93
|
+
async def callback(context, params: ElicitRequestParams) -> ElicitResult
|
|
88
94
|
"""
|
|
89
95
|
try:
|
|
90
96
|
headers = get_headers()
|
|
@@ -96,7 +102,9 @@ async def ete_test_mcp_session(
|
|
|
96
102
|
write_stream,
|
|
97
103
|
_,
|
|
98
104
|
):
|
|
99
|
-
async with ClientSession(
|
|
105
|
+
async with ClientSession(
|
|
106
|
+
read_stream, write_stream, elicitation_callback=elicitation_callback
|
|
107
|
+
) as session:
|
|
100
108
|
await asyncio.wait_for(session.initialize(), timeout=5)
|
|
101
109
|
yield session
|
|
102
110
|
except asyncio.TimeoutError:
|
|
@@ -17,6 +17,7 @@ import contextlib
|
|
|
17
17
|
import os
|
|
18
18
|
from collections.abc import AsyncGenerator
|
|
19
19
|
from pathlib import Path
|
|
20
|
+
from typing import Any
|
|
20
21
|
|
|
21
22
|
from mcp import ClientSession
|
|
22
23
|
from mcp.client.stdio import StdioServerParameters
|
|
@@ -34,7 +35,12 @@ def integration_test_mcp_server_params() -> StdioServerParameters:
|
|
|
34
35
|
or "https://test.datarobot.com/api/v2",
|
|
35
36
|
"MCP_SERVER_LOG_LEVEL": os.environ.get("MCP_SERVER_LOG_LEVEL") or "WARNING",
|
|
36
37
|
"APP_LOG_LEVEL": os.environ.get("APP_LOG_LEVEL") or "WARNING",
|
|
37
|
-
|
|
38
|
+
# Disable all OTEL telemetry for integration tests
|
|
39
|
+
"OTEL_ENABLED": "false",
|
|
40
|
+
"OTEL_SDK_DISABLED": "true",
|
|
41
|
+
"OTEL_TRACES_EXPORTER": "none",
|
|
42
|
+
"OTEL_LOGS_EXPORTER": "none",
|
|
43
|
+
"OTEL_METRICS_EXPORTER": "none",
|
|
38
44
|
"MCP_SERVER_REGISTER_DYNAMIC_TOOLS_ON_STARTUP": os.environ.get(
|
|
39
45
|
"MCP_SERVER_REGISTER_DYNAMIC_TOOLS_ON_STARTUP"
|
|
40
46
|
)
|
|
@@ -64,7 +70,9 @@ def integration_test_mcp_server_params() -> StdioServerParameters:
|
|
|
64
70
|
|
|
65
71
|
@contextlib.asynccontextmanager
|
|
66
72
|
async def integration_test_mcp_session(
|
|
67
|
-
server_params: StdioServerParameters | None = None,
|
|
73
|
+
server_params: StdioServerParameters | None = None,
|
|
74
|
+
timeout: int = 30,
|
|
75
|
+
elicitation_callback: Any | None = None,
|
|
68
76
|
) -> AsyncGenerator[ClientSession, None]:
|
|
69
77
|
"""
|
|
70
78
|
Create and connect a client for the MCP server as a context manager.
|
|
@@ -72,6 +80,7 @@ async def integration_test_mcp_session(
|
|
|
72
80
|
Args:
|
|
73
81
|
server_params: Parameters for configuring the server connection
|
|
74
82
|
timeout: Timeout
|
|
83
|
+
elicitation_callback: Optional callback for handling elicitation requests
|
|
75
84
|
|
|
76
85
|
Yields
|
|
77
86
|
------
|
|
@@ -86,8 +95,12 @@ async def integration_test_mcp_session(
|
|
|
86
95
|
|
|
87
96
|
try:
|
|
88
97
|
async with stdio_client(server_params) as (read_stream, write_stream):
|
|
89
|
-
async with ClientSession(
|
|
90
|
-
|
|
98
|
+
async with ClientSession(
|
|
99
|
+
read_stream, write_stream, elicitation_callback=elicitation_callback
|
|
100
|
+
) as session:
|
|
101
|
+
init_result = await asyncio.wait_for(session.initialize(), timeout=timeout)
|
|
102
|
+
# Store the init result on the session for tests that need to inspect capabilities
|
|
103
|
+
session._init_result = init_result # type: ignore[attr-defined]
|
|
91
104
|
yield session
|
|
92
105
|
|
|
93
106
|
except asyncio.TimeoutError:
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
# limitations under the License.
|
|
14
14
|
|
|
15
15
|
import json
|
|
16
|
+
from ast import literal_eval
|
|
16
17
|
from typing import Any
|
|
17
18
|
|
|
18
19
|
import openai
|
|
@@ -44,12 +45,39 @@ class LLMResponse:
|
|
|
44
45
|
|
|
45
46
|
|
|
46
47
|
class LLMMCPClient:
|
|
47
|
-
"""
|
|
48
|
+
"""
|
|
49
|
+
Client for interacting with LLMs via MCP.
|
|
48
50
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
+
Note: Elicitation is handled at the protocol level by FastMCP's ctx.elicit().
|
|
52
|
+
Tools using FastMCP's built-in elicitation will work automatically.
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
def __init__(
|
|
56
|
+
self,
|
|
57
|
+
config: str,
|
|
58
|
+
):
|
|
59
|
+
"""
|
|
60
|
+
Initialize the LLM MCP client.
|
|
61
|
+
|
|
62
|
+
Args:
|
|
63
|
+
config: Configuration string or dict with:
|
|
64
|
+
- openai_api_key: OpenAI API key
|
|
65
|
+
- openai_api_base: Optional Azure OpenAI endpoint
|
|
66
|
+
- openai_api_deployment_id: Optional Azure deployment ID
|
|
67
|
+
- openai_api_version: Optional Azure API version
|
|
68
|
+
- model: Model name (default: "gpt-3.5-turbo")
|
|
69
|
+
- save_llm_responses: Whether to save responses (default: True)
|
|
70
|
+
"""
|
|
51
71
|
# Parse config string to extract parameters
|
|
52
|
-
|
|
72
|
+
if isinstance(config, str):
|
|
73
|
+
# Try JSON first (safer), fall back to literal_eval for Python dict strings
|
|
74
|
+
try:
|
|
75
|
+
config_dict = json.loads(config)
|
|
76
|
+
except json.JSONDecodeError:
|
|
77
|
+
# Fall back to literal_eval for Python dict literal strings
|
|
78
|
+
config_dict = literal_eval(config)
|
|
79
|
+
else:
|
|
80
|
+
config_dict = config
|
|
53
81
|
|
|
54
82
|
openai_api_key = config_dict.get("openai_api_key")
|
|
55
83
|
openai_api_base = config_dict.get("openai_api_base")
|
|
@@ -93,7 +121,21 @@ class LLMMCPClient:
|
|
|
93
121
|
async def _call_mcp_tool(
|
|
94
122
|
self, tool_name: str, parameters: dict[str, Any], mcp_session: ClientSession
|
|
95
123
|
) -> str:
|
|
96
|
-
"""
|
|
124
|
+
"""
|
|
125
|
+
Call an MCP tool and return the result as a string.
|
|
126
|
+
|
|
127
|
+
Note: Elicitation is handled at the protocol level by FastMCP's ctx.elicit().
|
|
128
|
+
Tools using FastMCP's built-in elicitation will work automatically.
|
|
129
|
+
|
|
130
|
+
Args:
|
|
131
|
+
tool_name: Name of the tool to call
|
|
132
|
+
parameters: Parameters to pass to the tool
|
|
133
|
+
mcp_session: MCP client session
|
|
134
|
+
|
|
135
|
+
Returns
|
|
136
|
+
-------
|
|
137
|
+
Result text from the tool call
|
|
138
|
+
"""
|
|
97
139
|
result: CallToolResult = await mcp_session.call_tool(tool_name, parameters)
|
|
98
140
|
content = (
|
|
99
141
|
result.content[0].text
|
|
@@ -177,7 +219,26 @@ class LLMMCPClient:
|
|
|
177
219
|
async def process_prompt_with_mcp_support(
|
|
178
220
|
self, prompt: str, mcp_session: ClientSession, output_file_name: str = ""
|
|
179
221
|
) -> LLMResponse:
|
|
180
|
-
"""
|
|
222
|
+
"""
|
|
223
|
+
Process a prompt with MCP tool support and elicitation handling.
|
|
224
|
+
|
|
225
|
+
This method:
|
|
226
|
+
1. Adds MCP tools to available tools
|
|
227
|
+
2. Sends prompt to LLM
|
|
228
|
+
3. Processes tool calls
|
|
229
|
+
4. Continues until LLM provides final response
|
|
230
|
+
|
|
231
|
+
Note: Elicitation is handled at the protocol level by FastMCP's ctx.elicit().
|
|
232
|
+
|
|
233
|
+
Args:
|
|
234
|
+
prompt: User prompt
|
|
235
|
+
mcp_session: MCP client session
|
|
236
|
+
output_file_name: Optional file name to save response
|
|
237
|
+
|
|
238
|
+
Returns
|
|
239
|
+
-------
|
|
240
|
+
LLMResponse with content, tool calls, and tool results
|
|
241
|
+
"""
|
|
181
242
|
# Add MCP tools to available tools
|
|
182
243
|
await self._add_mcp_tool_to_available_tools(mcp_session)
|
|
183
244
|
|
|
@@ -191,8 +252,10 @@ class LLMMCPClient:
|
|
|
191
252
|
"content": (
|
|
192
253
|
"You are a helpful AI assistant that can use tools to help users. "
|
|
193
254
|
"If you need more information to provide a complete response, you can make "
|
|
194
|
-
"multiple tool calls
|
|
195
|
-
"
|
|
255
|
+
"multiple tool calls or ask the user for more info, but prefer tool calls "
|
|
256
|
+
"when possible. "
|
|
257
|
+
"When dealing with file paths, use them as raw paths without converting "
|
|
258
|
+
"to file:// URLs."
|
|
196
259
|
),
|
|
197
260
|
},
|
|
198
261
|
{"role": "user", "content": prompt},
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
|
|
3
|
+
# Copyright 2025 DataRobot, Inc.
|
|
4
|
+
#
|
|
5
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
6
|
+
# you may not use this file except in compliance with the License.
|
|
7
|
+
# You may obtain a copy of the License at
|
|
8
|
+
#
|
|
9
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
10
|
+
#
|
|
11
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
12
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
13
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
14
|
+
# See the License for the specific language governing permissions and
|
|
15
|
+
# limitations under the License.
|
|
16
|
+
|
|
17
|
+
"""Interactive MCP Client Test Script.
|
|
18
|
+
|
|
19
|
+
This script allows you to test arbitrary commands with the MCP server
|
|
20
|
+
using an LLM agent that can decide which tools to call.
|
|
21
|
+
|
|
22
|
+
Supports elicitation - when tools require user input (like authentication tokens),
|
|
23
|
+
the script will prompt you interactively.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
import asyncio
|
|
27
|
+
import json
|
|
28
|
+
import os
|
|
29
|
+
import sys
|
|
30
|
+
import traceback
|
|
31
|
+
from pathlib import Path
|
|
32
|
+
from typing import Any
|
|
33
|
+
|
|
34
|
+
from dotenv import load_dotenv
|
|
35
|
+
from mcp import ClientSession
|
|
36
|
+
from mcp.client.streamable_http import streamablehttp_client
|
|
37
|
+
from mcp.shared.context import RequestContext
|
|
38
|
+
from mcp.types import ElicitRequestParams
|
|
39
|
+
from mcp.types import ElicitResult
|
|
40
|
+
|
|
41
|
+
from datarobot_genai.drmcp import get_dr_mcp_server_url
|
|
42
|
+
from datarobot_genai.drmcp import get_headers
|
|
43
|
+
from datarobot_genai.drmcp.test_utils.openai_llm_mcp_client import LLMMCPClient
|
|
44
|
+
from datarobot_genai.drmcp.test_utils.openai_llm_mcp_client import LLMResponse
|
|
45
|
+
from datarobot_genai.drmcp.test_utils.openai_llm_mcp_client import ToolCall
|
|
46
|
+
|
|
47
|
+
# Re-export for backwards compatibility
|
|
48
|
+
__all__ = ["LLMMCPClient", "LLMResponse", "ToolCall", "test_mcp_interactive"]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
async def test_mcp_interactive() -> None:
|
|
52
|
+
"""Test the MCP server interactively with LLM agent."""
|
|
53
|
+
# Check for required environment variables
|
|
54
|
+
openai_api_key = os.environ.get("OPENAI_API_KEY")
|
|
55
|
+
if not openai_api_key:
|
|
56
|
+
print("❌ Error: OPENAI_API_KEY environment variable is required")
|
|
57
|
+
print("Please set it in your .env file or export it")
|
|
58
|
+
return
|
|
59
|
+
|
|
60
|
+
# Optional Azure OpenAI settings
|
|
61
|
+
openai_api_base = os.environ.get("OPENAI_API_BASE")
|
|
62
|
+
openai_api_deployment_id = os.environ.get("OPENAI_API_DEPLOYMENT_ID")
|
|
63
|
+
openai_api_version = os.environ.get("OPENAI_API_VERSION")
|
|
64
|
+
|
|
65
|
+
print("🤖 Initializing LLM MCP Client...")
|
|
66
|
+
|
|
67
|
+
# Initialize the LLM client with elicitation handler
|
|
68
|
+
config = {
|
|
69
|
+
"openai_api_key": openai_api_key,
|
|
70
|
+
"openai_api_base": openai_api_base,
|
|
71
|
+
"openai_api_deployment_id": openai_api_deployment_id,
|
|
72
|
+
"openai_api_version": openai_api_version,
|
|
73
|
+
"save_llm_responses": False,
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
llm_client = LLMMCPClient(str(config))
|
|
77
|
+
|
|
78
|
+
# Get MCP server URL
|
|
79
|
+
mcp_server_url = get_dr_mcp_server_url()
|
|
80
|
+
if not mcp_server_url:
|
|
81
|
+
print("❌ Error: MCP server URL is not configured")
|
|
82
|
+
print("Please set DR_MCP_SERVER_URL environment variable or run: task test-interactive")
|
|
83
|
+
return
|
|
84
|
+
|
|
85
|
+
print(f"🔗 Connecting to MCP server at: {mcp_server_url}")
|
|
86
|
+
|
|
87
|
+
# Elicitation handler: prompt user for required values
|
|
88
|
+
async def elicitation_handler(
|
|
89
|
+
context: RequestContext[ClientSession, Any], params: ElicitRequestParams
|
|
90
|
+
) -> ElicitResult:
|
|
91
|
+
print(f"\n📋 Elicitation Request: {params.message}")
|
|
92
|
+
if params.requestedSchema:
|
|
93
|
+
print(f" Schema: {params.requestedSchema}")
|
|
94
|
+
|
|
95
|
+
while True:
|
|
96
|
+
try:
|
|
97
|
+
response = input(" Enter value (or 'decline'/'cancel'): ").strip()
|
|
98
|
+
except (EOFError, KeyboardInterrupt):
|
|
99
|
+
return ElicitResult(action="cancel")
|
|
100
|
+
|
|
101
|
+
if response.lower() == "decline":
|
|
102
|
+
return ElicitResult(action="decline")
|
|
103
|
+
if response.lower() == "cancel":
|
|
104
|
+
return ElicitResult(action="cancel")
|
|
105
|
+
if response:
|
|
106
|
+
return ElicitResult(action="accept", content={"value": response})
|
|
107
|
+
print(" Please enter a value or 'decline'/'cancel'")
|
|
108
|
+
|
|
109
|
+
try:
|
|
110
|
+
async with streamablehttp_client(
|
|
111
|
+
url=mcp_server_url,
|
|
112
|
+
headers=get_headers(),
|
|
113
|
+
) as (read_stream, write_stream, _):
|
|
114
|
+
async with ClientSession(
|
|
115
|
+
read_stream,
|
|
116
|
+
write_stream,
|
|
117
|
+
elicitation_callback=elicitation_handler,
|
|
118
|
+
) as session:
|
|
119
|
+
await session.initialize()
|
|
120
|
+
|
|
121
|
+
print("✅ Connected to MCP server!")
|
|
122
|
+
print("📋 Available tools:")
|
|
123
|
+
|
|
124
|
+
tools_result = await session.list_tools()
|
|
125
|
+
for i, tool in enumerate(tools_result.tools, 1):
|
|
126
|
+
print(f" {i}. {tool.name}: {tool.description}")
|
|
127
|
+
|
|
128
|
+
print("\n" + "=" * 60)
|
|
129
|
+
print("🎯 Interactive Testing Mode")
|
|
130
|
+
print("=" * 60)
|
|
131
|
+
print("Type your questions/commands. The AI will decide which tools to use.")
|
|
132
|
+
print("If a tool requires additional information, you will be prompted.")
|
|
133
|
+
print("Type 'quit' or 'exit' to stop.")
|
|
134
|
+
print()
|
|
135
|
+
|
|
136
|
+
while True:
|
|
137
|
+
try:
|
|
138
|
+
user_input = input("🤔 You: ").strip()
|
|
139
|
+
|
|
140
|
+
if user_input.lower() in ["quit", "exit", "q"]:
|
|
141
|
+
print("👋 Goodbye!")
|
|
142
|
+
break
|
|
143
|
+
|
|
144
|
+
if not user_input:
|
|
145
|
+
continue
|
|
146
|
+
except (EOFError, KeyboardInterrupt):
|
|
147
|
+
print("\n👋 Goodbye!")
|
|
148
|
+
break
|
|
149
|
+
|
|
150
|
+
print("🤖 AI is thinking...")
|
|
151
|
+
|
|
152
|
+
response = await llm_client.process_prompt_with_mcp_support(
|
|
153
|
+
prompt=user_input,
|
|
154
|
+
mcp_session=session,
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
print("\n🤖 AI Response:")
|
|
158
|
+
print("-" * 40)
|
|
159
|
+
print(response.content)
|
|
160
|
+
|
|
161
|
+
if response.tool_calls:
|
|
162
|
+
print("\n🔧 Tools Used:")
|
|
163
|
+
for i, tool_call in enumerate(response.tool_calls, 1):
|
|
164
|
+
print(f" {i}. {tool_call.tool_name}")
|
|
165
|
+
print(f" Parameters: {tool_call.parameters}")
|
|
166
|
+
print(f" Reasoning: {tool_call.reasoning}")
|
|
167
|
+
|
|
168
|
+
if i <= len(response.tool_results):
|
|
169
|
+
result = response.tool_results[i - 1]
|
|
170
|
+
try:
|
|
171
|
+
result_data = json.loads(result)
|
|
172
|
+
if result_data.get("status") == "error":
|
|
173
|
+
error_msg = result_data.get("error", "Unknown error")
|
|
174
|
+
print(f" ❌ Error: {error_msg}")
|
|
175
|
+
elif result_data.get("status") == "success":
|
|
176
|
+
print(" ✅ Success")
|
|
177
|
+
except json.JSONDecodeError:
|
|
178
|
+
if len(result) > 100:
|
|
179
|
+
print(f" Result: {result[:100]}...")
|
|
180
|
+
else:
|
|
181
|
+
print(f" Result: {result}")
|
|
182
|
+
|
|
183
|
+
print("\n" + "=" * 60)
|
|
184
|
+
except Exception as e:
|
|
185
|
+
print(f"❌ Connection Error: {e}")
|
|
186
|
+
print(f" Server URL: {mcp_server_url}")
|
|
187
|
+
traceback.print_exc()
|
|
188
|
+
return
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
if __name__ == "__main__":
|
|
192
|
+
# Ensure we're in the right directory
|
|
193
|
+
if not Path("src").exists():
|
|
194
|
+
print("❌ Error: Please run this script from the project root")
|
|
195
|
+
sys.exit(1)
|
|
196
|
+
|
|
197
|
+
# Load environment variables from .env file
|
|
198
|
+
print("📄 Loading environment variables...")
|
|
199
|
+
load_dotenv()
|
|
200
|
+
|
|
201
|
+
print("🚀 Starting Interactive MCP Client Test")
|
|
202
|
+
print("Make sure the MCP server is running with: task drmcp-dev")
|
|
203
|
+
print()
|
|
204
|
+
|
|
205
|
+
asyncio.run(test_mcp_interactive())
|
|
@@ -116,28 +116,30 @@ class ToolBaseE2E:
|
|
|
116
116
|
f"Should have called {test_expectations.tool_calls_expected[i].name} tool, but "
|
|
117
117
|
f"got: {tool_call.tool_name}"
|
|
118
118
|
)
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
if
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
119
|
+
assert (
|
|
120
|
+
tool_call.parameters == test_expectations.tool_calls_expected[i].parameters
|
|
121
|
+
), (
|
|
122
|
+
f"Should have called {tool_call.tool_name} tool with the correct parameters, "
|
|
123
|
+
f"but got: {tool_call.parameters}"
|
|
124
|
+
)
|
|
125
|
+
if test_expectations.tool_calls_expected[i].result != SHOULD_NOT_BE_EMPTY:
|
|
126
|
+
expected_result = test_expectations.tool_calls_expected[i].result
|
|
127
|
+
if isinstance(expected_result, str):
|
|
128
|
+
assert expected_result in response.tool_results[i], (
|
|
129
|
+
f"Should have called {tool_call.tool_name} tool with the correct "
|
|
130
|
+
f"result, but got: {response.tool_results[i]}"
|
|
131
|
+
)
|
|
132
|
+
else:
|
|
133
|
+
actual_result = json.loads(response.tool_results[i])
|
|
134
|
+
assert _check_dict_has_keys(expected_result, actual_result), (
|
|
135
|
+
f"Should have called {tool_call.tool_name} tool with the correct "
|
|
136
|
+
f"result structure, but got: {response.tool_results[i]}"
|
|
137
|
+
)
|
|
130
138
|
else:
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
f"
|
|
134
|
-
f"structure, but got: {response.tool_results[i]}"
|
|
139
|
+
assert len(response.tool_results[i]) > 0, (
|
|
140
|
+
f"Should have called {tool_call.tool_name} tool with non-empty result, but "
|
|
141
|
+
f"got: {response.tool_results[i]}"
|
|
135
142
|
)
|
|
136
|
-
else:
|
|
137
|
-
assert len(response.tool_results[i]) > 0, (
|
|
138
|
-
f"Should have called {tool_call.tool_name} tool with non-empty result, but "
|
|
139
|
-
f"got: {response.tool_results[i]}"
|
|
140
|
-
)
|
|
141
143
|
|
|
142
144
|
# Verify LLM provided comprehensive response
|
|
143
145
|
assert len(response.content) > 100, "LLM should provide detailed response"
|