aixtools 0.1.10__py3-none-any.whl → 0.2.0__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 aixtools might be problematic. Click here for more details.
- aixtools/_version.py +2 -2
- aixtools/agents/agent.py +26 -7
- aixtools/agents/print_nodes.py +54 -0
- aixtools/agents/prompt.py +2 -2
- aixtools/compliance/private_data.py +1 -1
- aixtools/evals/discovery.py +174 -0
- aixtools/evals/evals.py +74 -0
- aixtools/evals/run_evals.py +110 -0
- aixtools/logging/log_objects.py +24 -23
- aixtools/mcp/client.py +148 -2
- aixtools/server/__init__.py +0 -6
- aixtools/server/path.py +88 -31
- aixtools/testing/aix_test_model.py +9 -1
- aixtools/tools/doctor/mcp_tool_doctor.py +79 -0
- aixtools/tools/doctor/tool_doctor.py +4 -0
- aixtools/tools/doctor/tool_recommendation.py +5 -0
- aixtools/utils/config.py +0 -1
- {aixtools-0.1.10.dist-info → aixtools-0.2.0.dist-info}/METADATA +186 -30
- {aixtools-0.1.10.dist-info → aixtools-0.2.0.dist-info}/RECORD +23 -55
- aixtools-0.2.0.dist-info/entry_points.txt +4 -0
- aixtools-0.2.0.dist-info/top_level.txt +1 -0
- aixtools/server/workspace_privacy.py +0 -65
- aixtools-0.1.10.dist-info/entry_points.txt +0 -2
- aixtools-0.1.10.dist-info/top_level.txt +0 -5
- docker/mcp-base/Dockerfile +0 -33
- docker/mcp-base/zscaler.crt +0 -28
- notebooks/example_faulty_mcp_server.ipynb +0 -74
- notebooks/example_mcp_server_stdio.ipynb +0 -76
- notebooks/example_raw_mcp_client.ipynb +0 -84
- notebooks/example_tool_doctor.ipynb +0 -65
- scripts/config.sh +0 -28
- scripts/lint.sh +0 -32
- scripts/log_view.sh +0 -18
- scripts/run_example_mcp_server.sh +0 -14
- scripts/run_faulty_mcp_server.sh +0 -13
- scripts/run_server.sh +0 -29
- scripts/test.sh +0 -30
- tests/unit/__init__.py +0 -0
- tests/unit/a2a/__init__.py +0 -0
- tests/unit/a2a/google_sdk/__init__.py +0 -0
- tests/unit/a2a/google_sdk/pydantic_ai_adapter/__init__.py +0 -0
- tests/unit/a2a/google_sdk/pydantic_ai_adapter/test_agent_executor.py +0 -188
- tests/unit/a2a/google_sdk/pydantic_ai_adapter/test_storage.py +0 -156
- tests/unit/a2a/google_sdk/test_card.py +0 -114
- tests/unit/a2a/google_sdk/test_remote_agent_connection.py +0 -413
- tests/unit/a2a/google_sdk/test_utils.py +0 -208
- tests/unit/agents/__init__.py +0 -0
- tests/unit/agents/test_prompt.py +0 -363
- tests/unit/compliance/test_private_data.py +0 -329
- tests/unit/google/__init__.py +0 -1
- tests/unit/google/test_client.py +0 -233
- tests/unit/mcp/__init__.py +0 -0
- tests/unit/mcp/test_client.py +0 -242
- tests/unit/server/__init__.py +0 -0
- tests/unit/server/test_path.py +0 -225
- tests/unit/server/test_utils.py +0 -362
- tests/unit/utils/__init__.py +0 -0
- tests/unit/utils/test_files.py +0 -146
- tests/unit/vault/__init__.py +0 -0
- tests/unit/vault/test_vault.py +0 -246
- {tests → aixtools/evals}/__init__.py +0 -0
- {aixtools-0.1.10.dist-info → aixtools-0.2.0.dist-info}/WHEEL +0 -0
aixtools/mcp/client.py
CHANGED
|
@@ -1,14 +1,22 @@
|
|
|
1
1
|
"""MCP server utilities with caching and robust error handling."""
|
|
2
2
|
|
|
3
3
|
import asyncio
|
|
4
|
-
|
|
4
|
+
import logging
|
|
5
|
+
from contextlib import asynccontextmanager
|
|
6
|
+
from datetime import timedelta
|
|
7
|
+
from typing import Any, AsyncGenerator
|
|
5
8
|
|
|
6
9
|
import anyio
|
|
10
|
+
import httpx
|
|
11
|
+
from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream
|
|
7
12
|
from cachebox import TTLCache
|
|
13
|
+
from fastmcp.client.logging import LogMessage
|
|
8
14
|
from mcp import types as mcp_types
|
|
15
|
+
from mcp.client import streamable_http
|
|
9
16
|
from mcp.shared.exceptions import McpError
|
|
17
|
+
from mcp.shared.message import SessionMessage
|
|
10
18
|
from pydantic_ai import RunContext, exceptions
|
|
11
|
-
from pydantic_ai.mcp import MCPServerStreamableHTTP, ToolResult
|
|
19
|
+
from pydantic_ai.mcp import MCPServerStdio, MCPServerStreamableHTTP, ToolResult
|
|
12
20
|
from pydantic_ai.toolsets.abstract import ToolsetTool
|
|
13
21
|
|
|
14
22
|
from aixtools.context import SessionIdTuple
|
|
@@ -16,11 +24,55 @@ from aixtools.logging.logging_config import get_logger
|
|
|
16
24
|
|
|
17
25
|
MCP_TOOL_CACHE_TTL = 300 # 5 minutes
|
|
18
26
|
DEFAULT_MCP_CONNECTION_TIMEOUT = 30
|
|
27
|
+
DEFAULT_MCP_READ_TIMEOUT = float(60 * 5) # 5 minutes
|
|
19
28
|
CACHE_KEY = "TOOL_LIST"
|
|
20
29
|
|
|
21
30
|
logger = get_logger(__name__)
|
|
22
31
|
|
|
23
32
|
|
|
33
|
+
# Default log_handler for MCP clients
|
|
34
|
+
LOGGING_LEVEL_MAP = logging.getLevelNamesMapping()
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
async def default_mcp_log_handler(message: LogMessage):
|
|
38
|
+
"""
|
|
39
|
+
Handles incoming logs from the MCP server and forwards them
|
|
40
|
+
to the standard Python logging system.
|
|
41
|
+
"""
|
|
42
|
+
msg = message.data.get("msg")
|
|
43
|
+
extra = message.data.get("extra")
|
|
44
|
+
|
|
45
|
+
# Convert the MCP log level to a Python log level
|
|
46
|
+
level = LOGGING_LEVEL_MAP.get(message.level.upper(), logging.INFO)
|
|
47
|
+
|
|
48
|
+
# Log the message using the standard logging library
|
|
49
|
+
logger.log(level, msg, extra=extra)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def get_mcp_client(
|
|
53
|
+
url: str | None = None,
|
|
54
|
+
command: str | None = None,
|
|
55
|
+
args: list[str] = None,
|
|
56
|
+
log_handler: callable = default_mcp_log_handler, # type: ignore
|
|
57
|
+
) -> MCPServerStreamableHTTP | MCPServerStdio:
|
|
58
|
+
"""
|
|
59
|
+
Create an MCP client instance based on the provided URL or command.
|
|
60
|
+
By providing a log_handler, incoming logs from the MCP server can be shown, which improves debugging.
|
|
61
|
+
|
|
62
|
+
Args:
|
|
63
|
+
url (str | None): The URL of the MCP server.
|
|
64
|
+
command (str | None): The command to start a local MCP server (STDIO MCP).
|
|
65
|
+
args (list[str] | None): Additional arguments for the command (STDIO MCP).
|
|
66
|
+
"""
|
|
67
|
+
if args is None:
|
|
68
|
+
args = []
|
|
69
|
+
if url:
|
|
70
|
+
return MCPServerStreamableHTTP(url="http://127.0.0.1:8089/mcp/", log_handler=log_handler)
|
|
71
|
+
if command:
|
|
72
|
+
return MCPServerStdio(command=command, args=args, log_handler=log_handler)
|
|
73
|
+
raise ValueError("Either url or command must be provided to create MCP client.")
|
|
74
|
+
|
|
75
|
+
|
|
24
76
|
def get_mcp_headers(session_id_tuple: SessionIdTuple) -> dict[str, str] | None:
|
|
25
77
|
"""
|
|
26
78
|
Generate headers for MCP server requests.
|
|
@@ -145,6 +197,23 @@ class CachedMCPServerStreamableHTTP(MCPServerStreamableHTTP):
|
|
|
145
197
|
logger.warning("MCP %s: %s exception %s: %s", self.url, func.__name__, type(exc), exc)
|
|
146
198
|
return fallback(exc)
|
|
147
199
|
|
|
200
|
+
@property
|
|
201
|
+
def _transport_client(self):
|
|
202
|
+
"""Override base transport client with wrapper logging and suppressing exceptions"""
|
|
203
|
+
return patched_streamablehttp_client
|
|
204
|
+
|
|
205
|
+
@asynccontextmanager
|
|
206
|
+
async def client_streams(self):
|
|
207
|
+
"""Override base client_streams with wrapper logging and suppressing exceptions"""
|
|
208
|
+
try:
|
|
209
|
+
async with super().client_streams() as streams: # pylint: disable=contextmanager-generator-missing-cleanup
|
|
210
|
+
try:
|
|
211
|
+
yield streams
|
|
212
|
+
except Exception as exc: # pylint: disable=broad-except
|
|
213
|
+
logger.error("MCP %s: client_streams; %s: %s", self.url, type(exc).__name__, exc)
|
|
214
|
+
except Exception as exc: # pylint: disable=broad-except
|
|
215
|
+
logger.error("MCP %s: client_streams: %s: %s", self.url, type(exc).__name__, exc)
|
|
216
|
+
|
|
148
217
|
async def __aenter__(self):
|
|
149
218
|
"""Enter the context of the cached MCP server with complete cancellation isolation."""
|
|
150
219
|
async with self._isolation_lock:
|
|
@@ -272,3 +341,80 @@ class CachedMCPServerStreamableHTTP(MCPServerStreamableHTTP):
|
|
|
272
341
|
raise exceptions.ModelRetry(text)
|
|
273
342
|
|
|
274
343
|
return content[0] if len(content) == 1 else content
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
class PatchedStreamableHTTPTransport(streamable_http.StreamableHTTPTransport):
|
|
347
|
+
"""Patched StreamableHTTPTransport with exception suppression for _handle_post_request."""
|
|
348
|
+
|
|
349
|
+
async def _handle_post_request(self, ctx: streamable_http.RequestContext) -> None:
|
|
350
|
+
"""Patched _handle_post_request with proper error handling."""
|
|
351
|
+
try:
|
|
352
|
+
await super()._handle_post_request(ctx)
|
|
353
|
+
except Exception as exc: # pylint: disable=broad-except
|
|
354
|
+
logger.error("MCP %s: _handle_post_request %s: %s", self.url, type(exc).__name__, exc)
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
@asynccontextmanager
|
|
358
|
+
async def patched_streamablehttp_client( # noqa: PLR0913, pylint: disable=too-many-arguments,too-many-positional-arguments,too-many-locals
|
|
359
|
+
url: str,
|
|
360
|
+
headers: dict[str, str] | None = None,
|
|
361
|
+
timeout: float | timedelta = 30,
|
|
362
|
+
sse_read_timeout: float | timedelta = DEFAULT_MCP_READ_TIMEOUT,
|
|
363
|
+
terminate_on_close: bool = True,
|
|
364
|
+
httpx_client_factory: streamable_http.McpHttpClientFactory = streamable_http.create_mcp_http_client,
|
|
365
|
+
auth: httpx.Auth | None = None,
|
|
366
|
+
) -> AsyncGenerator[
|
|
367
|
+
tuple[
|
|
368
|
+
MemoryObjectReceiveStream[SessionMessage | Exception],
|
|
369
|
+
MemoryObjectSendStream[SessionMessage],
|
|
370
|
+
streamable_http.GetSessionIdCallback,
|
|
371
|
+
],
|
|
372
|
+
None,
|
|
373
|
+
]:
|
|
374
|
+
"""Patched version of `streamablehttp_client` with exception suppression."""
|
|
375
|
+
try:
|
|
376
|
+
transport = PatchedStreamableHTTPTransport(url, headers, timeout, sse_read_timeout, auth)
|
|
377
|
+
|
|
378
|
+
read_stream_writer, read_stream = anyio.create_memory_object_stream[SessionMessage | Exception](0)
|
|
379
|
+
write_stream, write_stream_reader = anyio.create_memory_object_stream[SessionMessage](0)
|
|
380
|
+
async with anyio.create_task_group() as tg:
|
|
381
|
+
try:
|
|
382
|
+
async with httpx_client_factory(
|
|
383
|
+
headers=transport.request_headers,
|
|
384
|
+
timeout=httpx.Timeout(transport.timeout, read=transport.sse_read_timeout),
|
|
385
|
+
auth=transport.auth,
|
|
386
|
+
) as client:
|
|
387
|
+
# Define callbacks that need access to tg
|
|
388
|
+
def start_get_stream() -> None:
|
|
389
|
+
tg.start_soon(transport.handle_get_stream, client, read_stream_writer)
|
|
390
|
+
|
|
391
|
+
tg.start_soon(
|
|
392
|
+
transport.post_writer,
|
|
393
|
+
client,
|
|
394
|
+
write_stream_reader,
|
|
395
|
+
read_stream_writer,
|
|
396
|
+
write_stream,
|
|
397
|
+
start_get_stream,
|
|
398
|
+
tg,
|
|
399
|
+
)
|
|
400
|
+
|
|
401
|
+
try:
|
|
402
|
+
yield (
|
|
403
|
+
read_stream,
|
|
404
|
+
write_stream,
|
|
405
|
+
transport.get_session_id,
|
|
406
|
+
)
|
|
407
|
+
except GeneratorExit:
|
|
408
|
+
logger.warning("patched_streamablehttp_client: GeneratorExit caught, closing streams.")
|
|
409
|
+
finally:
|
|
410
|
+
if transport.session_id and terminate_on_close:
|
|
411
|
+
await transport.terminate_session(client)
|
|
412
|
+
tg.cancel_scope.cancel()
|
|
413
|
+
finally:
|
|
414
|
+
await read_stream_writer.aclose()
|
|
415
|
+
await write_stream.aclose()
|
|
416
|
+
except Exception as exc: # pylint: disable=broad-except
|
|
417
|
+
if str(exc) == "Attempted to exit cancel scope in a different task than it was entered in":
|
|
418
|
+
logger.warning("MCP %s: patched_streamablehttp_client: enter/exit cancel scope task mismatch.", url)
|
|
419
|
+
else:
|
|
420
|
+
logger.error("MCP %s: patched_streamablehttp_client: %s: %s", url, type(exc).__name__, exc)
|
aixtools/server/__init__.py
CHANGED
|
@@ -13,10 +13,6 @@ from .utils import (
|
|
|
13
13
|
get_session_id_tuple,
|
|
14
14
|
run_in_thread,
|
|
15
15
|
)
|
|
16
|
-
from .workspace_privacy import (
|
|
17
|
-
is_session_private,
|
|
18
|
-
set_session_private,
|
|
19
|
-
)
|
|
20
16
|
|
|
21
17
|
__all__ = [
|
|
22
18
|
"get_workspace_path",
|
|
@@ -24,6 +20,4 @@ __all__ = [
|
|
|
24
20
|
"container_to_host_path",
|
|
25
21
|
"host_to_container_path",
|
|
26
22
|
"run_in_thread",
|
|
27
|
-
"is_session_private",
|
|
28
|
-
"set_session_private",
|
|
29
23
|
]
|
aixtools/server/path.py
CHANGED
|
@@ -2,47 +2,82 @@
|
|
|
2
2
|
Workspace path handling for user sessions.
|
|
3
3
|
"""
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
import posixpath
|
|
6
|
+
from pathlib import Path, PurePosixPath
|
|
6
7
|
|
|
7
8
|
from fastmcp import Context
|
|
8
9
|
|
|
9
10
|
from ..utils.config import DATA_DIR
|
|
10
11
|
from .utils import get_session_id_tuple
|
|
11
12
|
|
|
12
|
-
WORKSPACES_ROOT_DIR = DATA_DIR / "workspaces" # Path on the host where workspaces are stored
|
|
13
|
+
WORKSPACES_ROOT_DIR = (DATA_DIR / "workspaces").resolve() # Path on the host where workspaces are stored
|
|
13
14
|
CONTAINER_WORKSPACE_PATH = PurePosixPath("/workspace") # Path inside the sandbox container where workspace is mounted
|
|
14
15
|
|
|
15
16
|
|
|
16
|
-
def get_workspace_path(
|
|
17
|
+
def get_workspace_path(ctx: Context | tuple | None = None) -> Path:
|
|
17
18
|
"""
|
|
18
|
-
Get the workspace path for a specific service (e.g. MCP server).
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
the environment variables
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
19
|
+
Get the workspace path (in the host) for a specific service (e.g. MCP server).
|
|
20
|
+
Returns the path based on user and session IDs in the format:
|
|
21
|
+
|
|
22
|
+
<DATA_DIR>/workspaces/<user_id>/<session_id>
|
|
23
|
+
|
|
24
|
+
where `DATA_DIR` should come from the environment variables
|
|
25
|
+
Example workspace path:
|
|
26
|
+
|
|
27
|
+
/data/workspaces/foo-user/bar-session
|
|
28
|
+
|
|
29
|
+
The `ctx` is used to get user and session IDs tuple. It can be passed directly
|
|
30
|
+
or via HTTP headers from `Context`. If `ctx` is None, the current FastMCP
|
|
31
|
+
request HTTP headers are used.
|
|
27
32
|
|
|
28
33
|
Args:
|
|
29
34
|
ctx: The FastMCP context, which contains the user session.
|
|
30
|
-
service_name: The name of the service (e.g. "mcp_server").
|
|
31
|
-
in_sandbox: If True, use a sandbox path; otherwise, use user/session-based path.
|
|
32
35
|
|
|
33
|
-
Returns: The workspace path as a
|
|
36
|
+
Returns: The workspace path as a Path object.
|
|
37
|
+
"""
|
|
38
|
+
user_id, session_id = ctx if isinstance(ctx, tuple) else get_session_id_tuple(ctx)
|
|
39
|
+
return WORKSPACES_ROOT_DIR / user_id / session_id
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def get_workspace_path_sandbox() -> PurePosixPath:
|
|
43
|
+
"""
|
|
44
|
+
Get the workspace path in the sandbox container.
|
|
45
|
+
|
|
46
|
+
We return PurePosixPath to ensure compatibility with Linux containers.
|
|
47
|
+
|
|
48
|
+
The paths inside the sandbox cannot be resolved (because they don't exist
|
|
49
|
+
on the host), so we use PurePosixPath instead of Path. Also Path could be
|
|
50
|
+
a WindowsPath on Windows hosts, which would be incorrect for Linux containers.
|
|
51
|
+
|
|
52
|
+
Returns: The workspace path as a PurePosixPath object.
|
|
34
53
|
"""
|
|
35
|
-
|
|
36
|
-
path = CONTAINER_WORKSPACE_PATH
|
|
37
|
-
else:
|
|
38
|
-
user_id, session_id = ctx if isinstance(ctx, tuple) else get_session_id_tuple(ctx)
|
|
39
|
-
path = WORKSPACES_ROOT_DIR / user_id / session_id
|
|
40
|
-
if service_name:
|
|
41
|
-
path = path / service_name
|
|
42
|
-
return path
|
|
54
|
+
return CONTAINER_WORKSPACE_PATH
|
|
43
55
|
|
|
44
56
|
|
|
45
|
-
def
|
|
57
|
+
def path_normalize(p: PurePosixPath) -> PurePosixPath:
|
|
58
|
+
"""
|
|
59
|
+
Normalize a PurePosixPath (remove redundant separators and up-level references).
|
|
60
|
+
"""
|
|
61
|
+
return PurePosixPath(posixpath.normpath(p.as_posix()))
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def path_chroot(path: Path, old_root: Path, new_root: Path) -> Path:
|
|
65
|
+
"""
|
|
66
|
+
Change the root of a given path from old_root to new_root.
|
|
67
|
+
If the path is not absolute (e.g. 'my_file.txt', './my_file.txt', 'my_dir/file.txt')
|
|
68
|
+
we treat it as relative to the 'new_root'
|
|
69
|
+
"""
|
|
70
|
+
if not Path(path).is_absolute():
|
|
71
|
+
new_path = Path(new_root / path).resolve()
|
|
72
|
+
new_root = Path(new_root).resolve()
|
|
73
|
+
if not new_path.is_relative_to(new_root):
|
|
74
|
+
raise ValueError(f"Path must not escape the workspace root: '{path}'")
|
|
75
|
+
return Path(new_path)
|
|
76
|
+
# Otherwise, we treat it as absolute and change the root
|
|
77
|
+
return new_root / Path(path).relative_to(old_root)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def container_to_host_path(path: PurePosixPath, *, ctx: Context | tuple | None = None) -> Path | None:
|
|
46
81
|
"""
|
|
47
82
|
Convert a path in a sandbox container to a host path
|
|
48
83
|
|
|
@@ -54,19 +89,41 @@ def container_to_host_path(path: PurePosixPath, *, ctx: Context | tuple = None)
|
|
|
54
89
|
Returns:
|
|
55
90
|
Path to the file on the host, or None if the conversion fails.
|
|
56
91
|
"""
|
|
57
|
-
|
|
92
|
+
# Try without service name (maybe the LLM forgot to put the SERVICE_NAME in the path)
|
|
93
|
+
old_root = get_workspace_path_sandbox()
|
|
58
94
|
new_root = get_workspace_path(ctx=ctx)
|
|
59
95
|
try:
|
|
60
|
-
|
|
96
|
+
# Relative paths are treated as relative to the new_root
|
|
97
|
+
if not PurePosixPath(path).is_absolute():
|
|
98
|
+
# Resolve paths to prevent escaping the workspace root
|
|
99
|
+
new_path = Path(new_root / path).resolve()
|
|
100
|
+
new_root = Path(new_root.resolve())
|
|
101
|
+
if not new_path.is_relative_to(new_root):
|
|
102
|
+
raise ValueError(f"Path must not escape the workspace root: '{path}'")
|
|
103
|
+
return new_path
|
|
104
|
+
# Otherwise, we treat it as absolute and change the root
|
|
105
|
+
return new_root / Path(path).relative_to(old_root)
|
|
61
106
|
except ValueError as e:
|
|
62
107
|
raise ValueError(f"Container path must be a subdir of '{old_root}', got '{path}' instead") from e
|
|
63
108
|
|
|
64
109
|
|
|
65
|
-
def host_to_container_path(path: Path, *, ctx: Context | tuple = None) -> PurePosixPath:
|
|
66
|
-
"""
|
|
110
|
+
def host_to_container_path(path: Path, *, ctx: Context | tuple | None = None) -> PurePosixPath:
|
|
111
|
+
"""
|
|
112
|
+
Convert a host path to a path in a sandbox container.
|
|
113
|
+
Paths inside the sandbox MUST be PurePosixPath (i.e. we use Linux containers).
|
|
114
|
+
"""
|
|
67
115
|
old_root = get_workspace_path(ctx=ctx)
|
|
68
|
-
new_root =
|
|
116
|
+
new_root = get_workspace_path_sandbox()
|
|
69
117
|
try:
|
|
118
|
+
# Relative paths are treated as relative to the new_root
|
|
119
|
+
if not Path(path).is_absolute():
|
|
120
|
+
# Normalize paths to prevent escaping the workspace root (we cannot resolve PurePosixPaths)
|
|
121
|
+
new_path = path_normalize(new_root / path)
|
|
122
|
+
new_root = path_normalize(new_root)
|
|
123
|
+
if not new_path.is_relative_to(new_root):
|
|
124
|
+
raise ValueError(f"Path must not escape the workspace root: '{path}'")
|
|
125
|
+
return new_path
|
|
126
|
+
# Otherwise, we treat it as absolute and change the root
|
|
70
127
|
return new_root / Path(path).relative_to(old_root)
|
|
71
|
-
except ValueError as
|
|
72
|
-
raise ValueError(f"Host path must be a subdir of '{old_root}', got '{path}' instead") from
|
|
128
|
+
except ValueError as e:
|
|
129
|
+
raise ValueError(f"Host path must be a subdir of either '{old_root}', got '{path}' instead") from e
|
|
@@ -108,9 +108,17 @@ class AixTestModel(Model):
|
|
|
108
108
|
messages: list[ModelMessage],
|
|
109
109
|
model_settings: ModelSettings | None,
|
|
110
110
|
model_request_parameters: ModelRequestParameters,
|
|
111
|
+
*args, # pylint: disable=unused-argument # Accept additional arguments for compatibility with pydantic-ai 1.0.9
|
|
112
|
+
**kwargs, # pylint: disable=unused-argument
|
|
111
113
|
) -> AsyncIterator[StreamedResponse]:
|
|
112
114
|
model_response = await self._request(messages, model_settings, model_request_parameters)
|
|
113
|
-
yield TestStreamedResponse(
|
|
115
|
+
yield TestStreamedResponse(
|
|
116
|
+
_model_name=self.model_name,
|
|
117
|
+
_structured_response=model_response,
|
|
118
|
+
_messages=messages,
|
|
119
|
+
model_request_parameters=model_request_parameters,
|
|
120
|
+
_provider_name="",
|
|
121
|
+
)
|
|
114
122
|
|
|
115
123
|
@property
|
|
116
124
|
def model_name(self) -> str:
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import asyncio
|
|
3
|
+
|
|
4
|
+
from pydantic_ai.mcp import MCPServerStdio, MCPServerStreamableHTTP
|
|
5
|
+
|
|
6
|
+
from aixtools.agents import get_agent, run_agent
|
|
7
|
+
from aixtools.tools.doctor.tool_doctor import TOOL_DOCTOR_PROMPT
|
|
8
|
+
from aixtools.tools.doctor.tool_recommendation import ToolRecommendation
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
async def tool_doctor_mcp(
|
|
12
|
+
mcp_url: str = "http://127.0.0.1:8000/mcp",
|
|
13
|
+
mcp_server: MCPServerStreamableHTTP | MCPServerStdio | None = None,
|
|
14
|
+
verbose: bool = False,
|
|
15
|
+
debug: bool = False,
|
|
16
|
+
) -> list[ToolRecommendation]:
|
|
17
|
+
"""
|
|
18
|
+
Run the tool doctor agent to analyze tools from an MCP server and give recommendations.
|
|
19
|
+
|
|
20
|
+
Usage examples:
|
|
21
|
+
# Using an http MCP server
|
|
22
|
+
ret = await tool_doctor_mcp(mcp_url='http://127.0.0.1:8000/mcp')
|
|
23
|
+
print(ret)
|
|
24
|
+
|
|
25
|
+
# Using a stdio MCP server
|
|
26
|
+
server = MCPServerStdio(command='fastmcp', args=['run', 'my_mcp_server.py'])
|
|
27
|
+
ret = await tool_doctor_mcp(mcp_server=server)
|
|
28
|
+
print(ret)
|
|
29
|
+
"""
|
|
30
|
+
if mcp_server is None:
|
|
31
|
+
mcp_server = MCPServerStreamableHTTP(url=mcp_url)
|
|
32
|
+
agent = get_agent(toolsets=[mcp_server], output_type=list[ToolRecommendation])
|
|
33
|
+
async with agent:
|
|
34
|
+
ret, nodes = await run_agent(agent, TOOL_DOCTOR_PROMPT, verbose=verbose, debug=debug)
|
|
35
|
+
return ret # type: ignore
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def main_cli():
|
|
39
|
+
"""Command line interface for tool doctor MCP."""
|
|
40
|
+
parser = argparse.ArgumentParser(description="Analyze tools from an MCP server and provide recommendations")
|
|
41
|
+
|
|
42
|
+
# MCP server connection options
|
|
43
|
+
server_group = parser.add_mutually_exclusive_group()
|
|
44
|
+
server_group.add_argument(
|
|
45
|
+
"--mcp-url",
|
|
46
|
+
default="http://127.0.0.1:8000/mcp",
|
|
47
|
+
help="URL of the HTTP MCP server (default: http://127.0.0.1:8000/mcp)",
|
|
48
|
+
)
|
|
49
|
+
server_group.add_argument("--stdio-command", help="Command to run STDIO MCP server (e.g., 'fastmcp')")
|
|
50
|
+
|
|
51
|
+
parser.add_argument(
|
|
52
|
+
"--stdio-args",
|
|
53
|
+
nargs="*",
|
|
54
|
+
default=[],
|
|
55
|
+
help="Arguments for STDIO MCP server command (e.g., 'run', 'my_server.py')",
|
|
56
|
+
)
|
|
57
|
+
parser.add_argument("--verbose", action="store_true", help="Enable verbose output")
|
|
58
|
+
parser.add_argument("--debug", action="store_true", help="Enable debug output")
|
|
59
|
+
|
|
60
|
+
args = parser.parse_args()
|
|
61
|
+
|
|
62
|
+
async def run():
|
|
63
|
+
mcp_server = None
|
|
64
|
+
if args.stdio_command:
|
|
65
|
+
mcp_server = MCPServerStdio(command=args.stdio_command, args=args.stdio_args)
|
|
66
|
+
recommendations = await tool_doctor_mcp(mcp_server=mcp_server, verbose=args.verbose, debug=args.debug)
|
|
67
|
+
else:
|
|
68
|
+
recommendations = await tool_doctor_mcp(mcp_url=args.mcp_url, verbose=args.verbose, debug=args.debug)
|
|
69
|
+
|
|
70
|
+
print("Tool Doctor Recommendations:")
|
|
71
|
+
print("=" * 50)
|
|
72
|
+
for i, rec in enumerate(recommendations, 1):
|
|
73
|
+
print(f"\n{i}. {rec}")
|
|
74
|
+
|
|
75
|
+
asyncio.run(run())
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
if __name__ == "__main__":
|
|
79
|
+
main_cli()
|