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.

Files changed (62) hide show
  1. aixtools/_version.py +2 -2
  2. aixtools/agents/agent.py +26 -7
  3. aixtools/agents/print_nodes.py +54 -0
  4. aixtools/agents/prompt.py +2 -2
  5. aixtools/compliance/private_data.py +1 -1
  6. aixtools/evals/discovery.py +174 -0
  7. aixtools/evals/evals.py +74 -0
  8. aixtools/evals/run_evals.py +110 -0
  9. aixtools/logging/log_objects.py +24 -23
  10. aixtools/mcp/client.py +148 -2
  11. aixtools/server/__init__.py +0 -6
  12. aixtools/server/path.py +88 -31
  13. aixtools/testing/aix_test_model.py +9 -1
  14. aixtools/tools/doctor/mcp_tool_doctor.py +79 -0
  15. aixtools/tools/doctor/tool_doctor.py +4 -0
  16. aixtools/tools/doctor/tool_recommendation.py +5 -0
  17. aixtools/utils/config.py +0 -1
  18. {aixtools-0.1.10.dist-info → aixtools-0.2.0.dist-info}/METADATA +186 -30
  19. {aixtools-0.1.10.dist-info → aixtools-0.2.0.dist-info}/RECORD +23 -55
  20. aixtools-0.2.0.dist-info/entry_points.txt +4 -0
  21. aixtools-0.2.0.dist-info/top_level.txt +1 -0
  22. aixtools/server/workspace_privacy.py +0 -65
  23. aixtools-0.1.10.dist-info/entry_points.txt +0 -2
  24. aixtools-0.1.10.dist-info/top_level.txt +0 -5
  25. docker/mcp-base/Dockerfile +0 -33
  26. docker/mcp-base/zscaler.crt +0 -28
  27. notebooks/example_faulty_mcp_server.ipynb +0 -74
  28. notebooks/example_mcp_server_stdio.ipynb +0 -76
  29. notebooks/example_raw_mcp_client.ipynb +0 -84
  30. notebooks/example_tool_doctor.ipynb +0 -65
  31. scripts/config.sh +0 -28
  32. scripts/lint.sh +0 -32
  33. scripts/log_view.sh +0 -18
  34. scripts/run_example_mcp_server.sh +0 -14
  35. scripts/run_faulty_mcp_server.sh +0 -13
  36. scripts/run_server.sh +0 -29
  37. scripts/test.sh +0 -30
  38. tests/unit/__init__.py +0 -0
  39. tests/unit/a2a/__init__.py +0 -0
  40. tests/unit/a2a/google_sdk/__init__.py +0 -0
  41. tests/unit/a2a/google_sdk/pydantic_ai_adapter/__init__.py +0 -0
  42. tests/unit/a2a/google_sdk/pydantic_ai_adapter/test_agent_executor.py +0 -188
  43. tests/unit/a2a/google_sdk/pydantic_ai_adapter/test_storage.py +0 -156
  44. tests/unit/a2a/google_sdk/test_card.py +0 -114
  45. tests/unit/a2a/google_sdk/test_remote_agent_connection.py +0 -413
  46. tests/unit/a2a/google_sdk/test_utils.py +0 -208
  47. tests/unit/agents/__init__.py +0 -0
  48. tests/unit/agents/test_prompt.py +0 -363
  49. tests/unit/compliance/test_private_data.py +0 -329
  50. tests/unit/google/__init__.py +0 -1
  51. tests/unit/google/test_client.py +0 -233
  52. tests/unit/mcp/__init__.py +0 -0
  53. tests/unit/mcp/test_client.py +0 -242
  54. tests/unit/server/__init__.py +0 -0
  55. tests/unit/server/test_path.py +0 -225
  56. tests/unit/server/test_utils.py +0 -362
  57. tests/unit/utils/__init__.py +0 -0
  58. tests/unit/utils/test_files.py +0 -146
  59. tests/unit/vault/__init__.py +0 -0
  60. tests/unit/vault/test_vault.py +0 -246
  61. {tests → aixtools/evals}/__init__.py +0 -0
  62. {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
- from typing import Any
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)
@@ -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
- from pathlib import Path, PurePath, PurePosixPath
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(service_name: str = None, *, in_sandbox: bool = False, ctx: Context | tuple = None) -> PurePath:
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
- If `service_name` is None, then the path to entire workspace folder (as mounted to a sandbox) is returned.
20
- If `in_sandbox` is True, it returns a path in sandbox, e.g.: `/workspace/mcp_repl`.
21
- If `in_sandbox` is False, it returns the path based on user and session IDs in the format:
22
- `<DATA_DIR>/workspaces/<user_id>/<session_id>/<service_name>`, where `DATA_DIR` should come from
23
- the environment variables, e.g.:
24
- `/data/workspaces/foo-user/bar-session/mcp_repl`.
25
- The `ctx` is used to get user and session IDs tuple. It can be passed directly or via HTTP headers from `Context`.
26
- If `ctx` is None, the current FastMCP request HTTP headers are used.
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 PurePath object.
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
- if in_sandbox:
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 container_to_host_path(path: PurePosixPath, *, ctx: Context | tuple = None) -> Path | None:
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
- old_root = CONTAINER_WORKSPACE_PATH
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
- return new_root / PurePosixPath(path).relative_to(old_root)
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
- """Convert a host path to a path in a sandbox container."""
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 = CONTAINER_WORKSPACE_PATH
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 exc:
72
- raise ValueError(f"Host path must be a subdir of '{old_root}', got '{path}' instead") from exc
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(_model_name=self.model_name, _structured_response=model_response, _messages=messages)
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()
@@ -1,3 +1,7 @@
1
+ """
2
+ Tool doctor: Analyze tools and give recommendations for improvement.
3
+ """
4
+
1
5
  from aixtools.agents import get_agent, run_agent
2
6
  from aixtools.tools.doctor.tool_recommendation import ToolRecommendation
3
7
 
@@ -1,5 +1,10 @@
1
1
  from pydantic import BaseModel
2
2
 
3
+ """
4
+ These are classes that represent recommendations for improving tools.
5
+ They are ued by ToolDoctor
6
+ """
7
+
3
8
 
4
9
  class ArgumentRecommendation(BaseModel):
5
10
  """A recommendation for an argument"""
aixtools/utils/config.py CHANGED
@@ -71,7 +71,6 @@ logging.warning("Using DATA_DIR='%s'", DATA_DIR)
71
71
  VDB_CHROMA_PATH = DATA_DB_DIR / "chroma.db"
72
72
  VDB_DEFAULT_SIMILARITY_THRESHOLD = 0.85
73
73
 
74
-
75
74
  # ---
76
75
  # Variables in '.env' file
77
76
  # Explicitly load specific variables