sandboxy 0.0.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.
Files changed (60) hide show
  1. sandboxy/__init__.py +3 -0
  2. sandboxy/agents/__init__.py +21 -0
  3. sandboxy/agents/base.py +66 -0
  4. sandboxy/agents/llm_prompt.py +308 -0
  5. sandboxy/agents/loader.py +222 -0
  6. sandboxy/api/__init__.py +5 -0
  7. sandboxy/api/app.py +76 -0
  8. sandboxy/api/routes/__init__.py +1 -0
  9. sandboxy/api/routes/agents.py +92 -0
  10. sandboxy/api/routes/local.py +1388 -0
  11. sandboxy/api/routes/tools.py +106 -0
  12. sandboxy/cli/__init__.py +1 -0
  13. sandboxy/cli/main.py +1196 -0
  14. sandboxy/cli/type_detector.py +48 -0
  15. sandboxy/config.py +49 -0
  16. sandboxy/core/__init__.py +1 -0
  17. sandboxy/core/async_runner.py +824 -0
  18. sandboxy/core/mdl_parser.py +441 -0
  19. sandboxy/core/runner.py +599 -0
  20. sandboxy/core/safe_eval.py +165 -0
  21. sandboxy/core/state.py +234 -0
  22. sandboxy/datasets/__init__.py +20 -0
  23. sandboxy/datasets/loader.py +193 -0
  24. sandboxy/datasets/runner.py +442 -0
  25. sandboxy/errors.py +166 -0
  26. sandboxy/local/context.py +235 -0
  27. sandboxy/local/results.py +173 -0
  28. sandboxy/logging.py +31 -0
  29. sandboxy/mcp/__init__.py +25 -0
  30. sandboxy/mcp/client.py +360 -0
  31. sandboxy/mcp/wrapper.py +99 -0
  32. sandboxy/providers/__init__.py +34 -0
  33. sandboxy/providers/anthropic_provider.py +271 -0
  34. sandboxy/providers/base.py +123 -0
  35. sandboxy/providers/http_client.py +101 -0
  36. sandboxy/providers/openai_provider.py +282 -0
  37. sandboxy/providers/openrouter.py +958 -0
  38. sandboxy/providers/registry.py +199 -0
  39. sandboxy/scenarios/__init__.py +11 -0
  40. sandboxy/scenarios/comparison.py +491 -0
  41. sandboxy/scenarios/loader.py +262 -0
  42. sandboxy/scenarios/runner.py +468 -0
  43. sandboxy/scenarios/unified.py +1434 -0
  44. sandboxy/session/__init__.py +21 -0
  45. sandboxy/session/manager.py +278 -0
  46. sandboxy/tools/__init__.py +34 -0
  47. sandboxy/tools/base.py +127 -0
  48. sandboxy/tools/loader.py +270 -0
  49. sandboxy/tools/yaml_tools.py +708 -0
  50. sandboxy/ui/__init__.py +27 -0
  51. sandboxy/ui/dist/assets/index-CgAkYWrJ.css +1 -0
  52. sandboxy/ui/dist/assets/index-D4zoGFcr.js +347 -0
  53. sandboxy/ui/dist/index.html +14 -0
  54. sandboxy/utils/__init__.py +3 -0
  55. sandboxy/utils/time.py +20 -0
  56. sandboxy-0.0.1.dist-info/METADATA +241 -0
  57. sandboxy-0.0.1.dist-info/RECORD +60 -0
  58. sandboxy-0.0.1.dist-info/WHEEL +4 -0
  59. sandboxy-0.0.1.dist-info/entry_points.txt +3 -0
  60. sandboxy-0.0.1.dist-info/licenses/LICENSE +201 -0
sandboxy/mcp/client.py ADDED
@@ -0,0 +1,360 @@
1
+ """MCP client manager - handles connections to MCP servers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import os
7
+ from dataclasses import dataclass, field
8
+ from typing import Any, Literal
9
+
10
+ from pydantic import BaseModel
11
+
12
+ from sandboxy.mcp.wrapper import McpToolWrapper
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+
17
+ @dataclass
18
+ class McpConnection:
19
+ """An active connection to an MCP server."""
20
+
21
+ name: str
22
+ session: Any # ClientSession
23
+ tools: dict[str, McpToolWrapper] = field(default_factory=dict)
24
+ transport: str = "stdio" # "stdio", "sse", or "streamable_http"
25
+ _read_stream: Any = None
26
+ _write_stream: Any = None
27
+ _context: Any = None # For HTTP transports
28
+
29
+
30
+ class McpServerConfig(BaseModel):
31
+ """Configuration for an MCP server.
32
+
33
+ Supports two modes:
34
+ - Local (stdio): Set `command` and optionally `args`/`env`
35
+ - Remote (HTTP): Set `url` and optionally `headers`
36
+ """
37
+
38
+ name: str
39
+
40
+ # Local server (stdio transport)
41
+ command: str | None = None
42
+ args: list[str] = []
43
+ env: dict[str, str] = {}
44
+
45
+ # Remote server (HTTP transport)
46
+ url: str | None = None
47
+ headers: dict[str, str] = {}
48
+ transport: Literal["auto", "stdio", "sse", "streamable_http"] = "auto"
49
+
50
+ def is_http(self) -> bool:
51
+ """Check if this config uses HTTP transport."""
52
+ if self.transport in ("sse", "streamable_http"):
53
+ return True
54
+ if self.transport == "stdio":
55
+ return False
56
+ # Auto-detect: if url is set, use HTTP
57
+ return self.url is not None
58
+
59
+
60
+ class McpManager:
61
+ """Manages connections to multiple MCP servers."""
62
+
63
+ def __init__(self) -> None:
64
+ """Initialize the MCP manager."""
65
+ self._connections: dict[str, McpConnection] = {}
66
+
67
+ async def connect(self, config: McpServerConfig) -> McpConnection:
68
+ """Connect to an MCP server.
69
+
70
+ Args:
71
+ config: Server configuration
72
+
73
+ Returns:
74
+ Active connection with discovered tools
75
+ """
76
+ if config.is_http():
77
+ return await self._connect_http(config)
78
+ return await self._connect_stdio(config)
79
+
80
+ async def _connect_stdio(self, config: McpServerConfig) -> McpConnection:
81
+ """Connect to a local MCP server via stdio."""
82
+ from mcp import ClientSession, StdioServerParameters
83
+ from mcp.client.stdio import stdio_client
84
+
85
+ if not config.command:
86
+ raise ValueError(f"Server '{config.name}' requires 'command' for stdio transport")
87
+
88
+ # Build environment with current env + overrides
89
+ env = os.environ.copy()
90
+ for key, value in config.env.items():
91
+ # Support ${VAR} expansion
92
+ if value.startswith("${") and value.endswith("}"):
93
+ env_key = value[2:-1]
94
+ env[key] = os.environ.get(env_key, "")
95
+ else:
96
+ env[key] = value
97
+
98
+ # Create server parameters
99
+ server_params = StdioServerParameters(
100
+ command=config.command,
101
+ args=config.args,
102
+ env=env,
103
+ )
104
+
105
+ # Connect to server
106
+ read_stream, write_stream = await stdio_client(server_params).__aenter__()
107
+ session = await ClientSession(read_stream, write_stream).__aenter__()
108
+
109
+ # Initialize the session
110
+ await session.initialize()
111
+
112
+ # Discover tools
113
+ tools_result = await session.list_tools()
114
+ tools: dict[str, McpToolWrapper] = {}
115
+
116
+ for tool_info in tools_result.tools:
117
+ wrapper = McpToolWrapper(session, tool_info)
118
+ tools[tool_info.name] = wrapper
119
+
120
+ # Create and store connection
121
+ connection = McpConnection(
122
+ name=config.name,
123
+ session=session,
124
+ tools=tools,
125
+ transport="stdio",
126
+ _read_stream=read_stream,
127
+ _write_stream=write_stream,
128
+ )
129
+ self._connections[config.name] = connection
130
+
131
+ return connection
132
+
133
+ async def _connect_http(self, config: McpServerConfig) -> McpConnection:
134
+ """Connect to a remote MCP server via HTTP (SSE or Streamable HTTP)."""
135
+ from mcp import ClientSession
136
+
137
+ if not config.url:
138
+ raise ValueError(f"Server '{config.name}' requires 'url' for HTTP transport")
139
+
140
+ # Determine transport type
141
+ transport = config.transport
142
+ if transport == "auto":
143
+ # Auto-detect based on URL path
144
+ if config.url.endswith("/sse"):
145
+ transport = "sse"
146
+ else:
147
+ transport = "streamable_http"
148
+
149
+ # Build headers
150
+ headers = dict(config.headers)
151
+
152
+ if transport == "sse":
153
+ from mcp.client.sse import sse_client
154
+
155
+ # Connect via SSE
156
+ context = sse_client(config.url, headers=headers if headers else None)
157
+ read_stream, write_stream = await context.__aenter__()
158
+ else:
159
+ from mcp.client.streamable_http import streamablehttp_client
160
+
161
+ # Connect via Streamable HTTP
162
+ context = streamablehttp_client(config.url, headers=headers if headers else None)
163
+ read_stream, write_stream, _ = await context.__aenter__()
164
+
165
+ session = await ClientSession(read_stream, write_stream).__aenter__()
166
+
167
+ # Initialize the session
168
+ await session.initialize()
169
+
170
+ # Discover tools
171
+ tools_result = await session.list_tools()
172
+ tools: dict[str, McpToolWrapper] = {}
173
+
174
+ for tool_info in tools_result.tools:
175
+ wrapper = McpToolWrapper(session, tool_info)
176
+ tools[tool_info.name] = wrapper
177
+
178
+ # Create and store connection
179
+ connection = McpConnection(
180
+ name=config.name,
181
+ session=session,
182
+ tools=tools,
183
+ transport=transport,
184
+ _read_stream=read_stream,
185
+ _write_stream=write_stream,
186
+ _context=context,
187
+ )
188
+ self._connections[config.name] = connection
189
+
190
+ return connection
191
+
192
+ async def connect_all(
193
+ self,
194
+ configs: list[McpServerConfig],
195
+ ) -> dict[str, McpToolWrapper]:
196
+ """Connect to multiple MCP servers and return all tools.
197
+
198
+ Args:
199
+ configs: List of server configurations
200
+
201
+ Returns:
202
+ Dictionary of tool name to wrapper (tools from all servers)
203
+ """
204
+ all_tools: dict[str, McpToolWrapper] = {}
205
+
206
+ for config in configs:
207
+ try:
208
+ connection = await self.connect(config)
209
+ # Prefix tool names with server name to avoid conflicts
210
+ for tool_name, wrapper in connection.tools.items():
211
+ # Use unprefixed name if unique, otherwise prefix
212
+ if tool_name in all_tools:
213
+ all_tools[f"{config.name}.{tool_name}"] = wrapper
214
+ else:
215
+ all_tools[tool_name] = wrapper
216
+ except Exception as e:
217
+ # Log error but continue with other servers
218
+ logger.warning("Failed to connect to MCP server '%s': %s", config.name, e)
219
+
220
+ return all_tools
221
+
222
+ async def disconnect_all(self) -> None:
223
+ """Disconnect from all MCP servers."""
224
+ for connection in self._connections.values():
225
+ try:
226
+ if connection.session:
227
+ await connection.session.__aexit__(None, None, None)
228
+ if connection._context:
229
+ await connection._context.__aexit__(None, None, None)
230
+ except Exception:
231
+ logger.debug(
232
+ "Error during disconnect from MCP server '%s' (ignored)",
233
+ connection.name,
234
+ exc_info=True,
235
+ )
236
+
237
+ self._connections.clear()
238
+
239
+ def get_all_tools(self) -> dict[str, McpToolWrapper]:
240
+ """Get all tools from all connected servers."""
241
+ all_tools: dict[str, McpToolWrapper] = {}
242
+ for connection in self._connections.values():
243
+ all_tools.update(connection.tools)
244
+ return all_tools
245
+
246
+
247
+ def _extract_tools_info(tools_result: Any) -> list[dict[str, Any]]:
248
+ """Extract tool information from MCP tools result."""
249
+ tools_info: list[dict[str, Any]] = []
250
+
251
+ for tool in tools_result.tools:
252
+ info: dict[str, Any] = {
253
+ "name": tool.name,
254
+ "description": tool.description or "",
255
+ }
256
+
257
+ # Parse input schema for parameters
258
+ if tool.inputSchema:
259
+ schema = tool.inputSchema
260
+ if isinstance(schema, dict):
261
+ props = schema.get("properties", {})
262
+ required = schema.get("required", [])
263
+
264
+ params = []
265
+ for name, prop in props.items():
266
+ param_info = {
267
+ "name": name,
268
+ "type": prop.get("type", "any"),
269
+ "required": name in required,
270
+ "description": prop.get("description", ""),
271
+ }
272
+ params.append(param_info)
273
+
274
+ info["parameters"] = params
275
+ else:
276
+ info["parameters"] = []
277
+ else:
278
+ info["parameters"] = []
279
+
280
+ tools_info.append(info)
281
+
282
+ return tools_info
283
+
284
+
285
+ async def inspect_mcp_server(
286
+ command: str,
287
+ args: list[str] | None = None,
288
+ env: dict[str, str] | None = None,
289
+ ) -> list[dict[str, Any]]:
290
+ """Inspect a local MCP server (stdio) and return its available tools.
291
+
292
+ Args:
293
+ command: Command to start the server
294
+ args: Command arguments
295
+ env: Environment variables
296
+
297
+ Returns:
298
+ List of tool information dictionaries
299
+ """
300
+ from mcp import ClientSession, StdioServerParameters
301
+ from mcp.client.stdio import stdio_client
302
+
303
+ # Build environment
304
+ full_env = os.environ.copy()
305
+ if env:
306
+ full_env.update(env)
307
+
308
+ server_params = StdioServerParameters(
309
+ command=command,
310
+ args=args or [],
311
+ env=full_env,
312
+ )
313
+
314
+ async with stdio_client(server_params) as (read, write):
315
+ async with ClientSession(read, write) as session:
316
+ await session.initialize()
317
+ tools_result = await session.list_tools()
318
+ return _extract_tools_info(tools_result)
319
+
320
+
321
+ async def inspect_mcp_server_http(
322
+ url: str,
323
+ headers: dict[str, str] | None = None,
324
+ transport: Literal["auto", "sse", "streamable_http"] = "auto",
325
+ ) -> list[dict[str, Any]]:
326
+ """Inspect a remote MCP server (HTTP) and return its available tools.
327
+
328
+ Args:
329
+ url: Server URL (e.g., "https://example.com/mcp" or "https://example.com/sse")
330
+ headers: Optional HTTP headers (e.g., for authentication)
331
+ transport: Transport type ("auto", "sse", or "streamable_http")
332
+
333
+ Returns:
334
+ List of tool information dictionaries
335
+ """
336
+ from mcp import ClientSession
337
+
338
+ # Auto-detect transport based on URL
339
+ if transport == "auto":
340
+ if url.endswith("/sse"):
341
+ transport = "sse"
342
+ else:
343
+ transport = "streamable_http"
344
+
345
+ if transport == "sse":
346
+ from mcp.client.sse import sse_client
347
+
348
+ async with sse_client(url, headers=headers) as (read, write):
349
+ async with ClientSession(read, write) as session:
350
+ await session.initialize()
351
+ tools_result = await session.list_tools()
352
+ return _extract_tools_info(tools_result)
353
+ else:
354
+ from mcp.client.streamable_http import streamablehttp_client
355
+
356
+ async with streamablehttp_client(url, headers=headers) as (read, write, _):
357
+ async with ClientSession(read, write) as session:
358
+ await session.initialize()
359
+ tools_result = await session.list_tools()
360
+ return _extract_tools_info(tools_result)
@@ -0,0 +1,99 @@
1
+ """MCP tool wrapper - adapts MCP tools to Sandboxy's Tool protocol."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING, Any
6
+
7
+ from sandboxy.tools.base import ToolResult
8
+
9
+ if TYPE_CHECKING:
10
+ from mcp import ClientSession
11
+ from mcp.types import Tool as McpTool
12
+
13
+
14
+ class McpToolWrapper:
15
+ """Wraps an MCP tool to match Sandboxy's Tool protocol.
16
+
17
+ This allows MCP tools to be used seamlessly alongside YAML mock tools
18
+ in scenarios.
19
+ """
20
+
21
+ def __init__(self, session: ClientSession, tool_info: McpTool) -> None:
22
+ """Initialize the wrapper.
23
+
24
+ Args:
25
+ session: Active MCP client session
26
+ tool_info: Tool information from MCP server
27
+ """
28
+ self.session = session
29
+ self.name = tool_info.name
30
+ self.description = tool_info.description or ""
31
+ self._input_schema = tool_info.inputSchema
32
+
33
+ async def invoke_async(
34
+ self,
35
+ action: str,
36
+ args: dict[str, Any],
37
+ env_state: dict[str, Any],
38
+ ) -> ToolResult:
39
+ """Invoke the MCP tool asynchronously.
40
+
41
+ Args:
42
+ action: Action name (ignored for MCP tools - they're single-action)
43
+ args: Arguments to pass to the tool
44
+ env_state: Environment state (not used by MCP tools)
45
+
46
+ Returns:
47
+ ToolResult with success/error and data
48
+ """
49
+ try:
50
+ result = await self.session.call_tool(self.name, args)
51
+
52
+ # Extract content from MCP result
53
+ # MCP tools return a list of content blocks
54
+ if result.content:
55
+ # Combine text content
56
+ text_parts = []
57
+ for block in result.content:
58
+ if hasattr(block, "text"):
59
+ text_parts.append(block.text)
60
+ elif hasattr(block, "data"):
61
+ text_parts.append(str(block.data))
62
+
63
+ data = "\n".join(text_parts) if text_parts else result.content
64
+
65
+ if result.isError:
66
+ return ToolResult(success=False, error=str(data))
67
+
68
+ return ToolResult(success=True, data=data)
69
+
70
+ return ToolResult(success=True, data=None)
71
+
72
+ except Exception as e:
73
+ return ToolResult(success=False, error=str(e))
74
+
75
+ def invoke(
76
+ self,
77
+ action: str,
78
+ args: dict[str, Any],
79
+ env_state: dict[str, Any],
80
+ ) -> ToolResult:
81
+ """Synchronous invoke - raises error, use invoke_async instead."""
82
+ raise RuntimeError("MCP tools must be invoked asynchronously. Use invoke_async() instead.")
83
+
84
+ def get_actions(self) -> list[dict[str, Any]]:
85
+ """Get available actions with their schemas.
86
+
87
+ MCP tools are single-action, so we return a single "call" action.
88
+ """
89
+ return [
90
+ {
91
+ "name": "call",
92
+ "description": self.description,
93
+ "parameters": self._input_schema
94
+ or {
95
+ "type": "object",
96
+ "properties": {},
97
+ },
98
+ }
99
+ ]
@@ -0,0 +1,34 @@
1
+ """Multi-model provider abstraction layer.
2
+
3
+ Supports multiple LLM providers through a unified interface:
4
+ - OpenRouter (400+ models via single API)
5
+ - OpenAI (direct)
6
+ - Anthropic (direct)
7
+
8
+ Usage:
9
+ from sandboxy.providers import get_provider, ProviderRegistry
10
+
11
+ # Get provider for a specific model
12
+ provider = get_provider("openai/gpt-4o")
13
+ response = await provider.complete("openai/gpt-4o", messages)
14
+
15
+ # Or use the registry
16
+ registry = ProviderRegistry()
17
+ provider = registry.get_provider_for_model("anthropic/claude-3-opus")
18
+ """
19
+
20
+ from sandboxy.providers.base import (
21
+ BaseProvider,
22
+ ModelResponse,
23
+ ProviderError,
24
+ )
25
+ from sandboxy.providers.registry import ProviderRegistry, get_provider, get_registry
26
+
27
+ __all__ = [
28
+ "BaseProvider",
29
+ "ModelResponse",
30
+ "ProviderError",
31
+ "ProviderRegistry",
32
+ "get_provider",
33
+ "get_registry",
34
+ ]