toolplane-python-client 0.1.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.
Files changed (81) hide show
  1. toolplane/__init__.py +106 -0
  2. toolplane/common/__init__.py +93 -0
  3. toolplane/common/base_config.py +129 -0
  4. toolplane/common/base_connection_manager.py +171 -0
  5. toolplane/common/base_session_manager.py +321 -0
  6. toolplane/common/base_tool_manager.py +347 -0
  7. toolplane/common/constants.py +47 -0
  8. toolplane/common/utils.py +310 -0
  9. toolplane/core/__init__.py +67 -0
  10. toolplane/core/config.py +107 -0
  11. toolplane/core/connection.py +285 -0
  12. toolplane/core/errors.py +298 -0
  13. toolplane/core/machine.py +480 -0
  14. toolplane/core/request.py +775 -0
  15. toolplane/core/session.py +332 -0
  16. toolplane/core/session_context.py +514 -0
  17. toolplane/core/task.py +130 -0
  18. toolplane/core/tool.py +329 -0
  19. toolplane/http_core/__init__.py +37 -0
  20. toolplane/http_core/http_config.py +97 -0
  21. toolplane/http_core/http_connection.py +409 -0
  22. toolplane/http_core/http_machine.py +298 -0
  23. toolplane/http_core/http_request.py +748 -0
  24. toolplane/http_core/http_session.py +348 -0
  25. toolplane/http_core/http_session_context.py +491 -0
  26. toolplane/http_core/http_task.py +101 -0
  27. toolplane/http_core/http_tool.py +400 -0
  28. toolplane/interfaces/__init__.py +27 -0
  29. toolplane/interfaces/client_interface.py +122 -0
  30. toolplane/interfaces/connection_interface.py +193 -0
  31. toolplane/interfaces/event_interface.py +290 -0
  32. toolplane/interfaces/request_interface.py +439 -0
  33. toolplane/interfaces/session_interface.py +288 -0
  34. toolplane/interfaces/tool_interface.py +441 -0
  35. toolplane/proto/__init__.py +0 -0
  36. toolplane/proto/service_pb2.py +315 -0
  37. toolplane/proto/service_pb2_grpc.py +2240 -0
  38. toolplane/provider_cli.py +268 -0
  39. toolplane/provider_registry.py +77 -0
  40. toolplane/provider_runtime.py +302 -0
  41. toolplane/toolkits/__init__.py +0 -0
  42. toolplane/toolkits/standalone_tools/__init__.py +0 -0
  43. toolplane/toolkits/standalone_tools/create_directory.py +94 -0
  44. toolplane/toolkits/standalone_tools/create_file.py +124 -0
  45. toolplane/toolkits/standalone_tools/file_search.py +229 -0
  46. toolplane/toolkits/standalone_tools/grep_search.py +372 -0
  47. toolplane/toolkits/standalone_tools/launcher.py +146 -0
  48. toolplane/toolkits/standalone_tools/list_dir.py +395 -0
  49. toolplane/toolkits/standalone_tools/read_file.py +346 -0
  50. toolplane/toolkits/standalone_tools/replace_string_in_file.py +407 -0
  51. toolplane/toolkits/standalone_tools/run_tests.py +66 -0
  52. toolplane/toolkits/standalone_tools/semantic_search.py +485 -0
  53. toolplane/toolkits/standalone_tools/standalone_toolkit.py +979 -0
  54. toolplane/toolkits/standalone_tools/test_failure_analysis.py +618 -0
  55. toolplane/toolkits/standalone_tools/test_standalone_toolkit.py +517 -0
  56. toolplane/toolkits/swe/__init__.py +35 -0
  57. toolplane/toolkits/swe/create_directory.py +15 -0
  58. toolplane/toolkits/swe/create_file.py +15 -0
  59. toolplane/toolkits/swe/descriptions.py +273 -0
  60. toolplane/toolkits/swe/execute_bash.py +93 -0
  61. toolplane/toolkits/swe/file_editor.py +775 -0
  62. toolplane/toolkits/swe/file_search.py +16 -0
  63. toolplane/toolkits/swe/finish.py +50 -0
  64. toolplane/toolkits/swe/grep_search.py +19 -0
  65. toolplane/toolkits/swe/list_dir.py +407 -0
  66. toolplane/toolkits/swe/read_file.py +18 -0
  67. toolplane/toolkits/swe/replace_string_in_file.py +17 -0
  68. toolplane/toolkits/swe/search.py +260 -0
  69. toolplane/toolkits/swe/semantic_search.py +20 -0
  70. toolplane/toolkits/swe/str_replace_editor.py +647 -0
  71. toolplane/toolkits/swe/submit.py +29 -0
  72. toolplane/toolkits/swe/swe_toolkit.py +1296 -0
  73. toolplane/toolplane_client.py +686 -0
  74. toolplane/toolplane_http_client.py +681 -0
  75. toolplane/utils/__init__.py +3 -0
  76. toolplane/utils/schema.py +146 -0
  77. toolplane_python_client-0.1.0.dist-info/METADATA +543 -0
  78. toolplane_python_client-0.1.0.dist-info/RECORD +81 -0
  79. toolplane_python_client-0.1.0.dist-info/WHEEL +5 -0
  80. toolplane_python_client-0.1.0.dist-info/entry_points.txt +2 -0
  81. toolplane_python_client-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,400 @@
1
+ """HTTP tool management for Toolplane client."""
2
+
3
+ import json
4
+ import time
5
+ from typing import Any, Callable, Dict, List, Optional
6
+
7
+ from toolplane.utils.schema import generate_schema_from_function
8
+
9
+ from ..common.base_tool_manager import BaseToolManager
10
+ from ..common.utils import validate_tool_name
11
+ from ..core.errors import ToolError, ToolplaneAPIError
12
+ from .http_connection import HTTPConnectionManager
13
+
14
+ # Gateway JSON enums arrive as their proto names (lowercased here); map the
15
+ # terminal ones onto the normalized status names used by the waiters.
16
+ _RESPONSE_STATUS_NAMES = {
17
+ "request_status_done": "done",
18
+ "request_status_failed": "failed",
19
+ "request_status_cancelled": "cancelled",
20
+ "done": "done",
21
+ "failed": "failed",
22
+ "failure": "failed",
23
+ "cancelled": "cancelled",
24
+ }
25
+
26
+
27
+ class HTTPToolManager(BaseToolManager):
28
+ """Manages tool registration and execution for HTTP client."""
29
+
30
+ def __init__(self, connection_manager: HTTPConnectionManager):
31
+ """Initialize HTTP tool manager."""
32
+ super().__init__(connection_manager)
33
+
34
+ def _normalize_tool(self, tool: Dict[str, Any]) -> Dict[str, Any]:
35
+ schema = tool.get("schema", {})
36
+ if isinstance(schema, str):
37
+ try:
38
+ schema = json.loads(schema)
39
+ except Exception:
40
+ schema = {}
41
+
42
+ if not isinstance(schema, dict):
43
+ schema = {}
44
+
45
+ return {
46
+ "id": tool.get("id", ""),
47
+ "name": tool.get("name", ""),
48
+ "description": tool.get("description", ""),
49
+ "schema": schema,
50
+ "config": tool.get("config", {}),
51
+ "created_at": tool.get("createdAt", tool.get("created_at", "")),
52
+ "last_ping_at": tool.get("lastPingAt", tool.get("last_ping_at", "")),
53
+ "session_id": tool.get("sessionId", tool.get("session_id", "")),
54
+ "tags": tool.get("tags", []),
55
+ }
56
+
57
+ def register_tool(
58
+ self,
59
+ session_id: str,
60
+ machine_id: str,
61
+ name: str,
62
+ func: Callable,
63
+ schema: Optional[Dict] = None,
64
+ description: Optional[str] = None,
65
+ stream: bool = False,
66
+ tags: Optional[List[str]] = None,
67
+ ):
68
+ """Register a tool for a session."""
69
+ if tags is None:
70
+ tags = []
71
+
72
+ # Validate tool name
73
+ if not validate_tool_name(name):
74
+ raise ToolError(f"Invalid tool name: {name}")
75
+
76
+ # Generate schema if not provided
77
+ if schema is None:
78
+ schema = generate_schema_from_function(func)
79
+
80
+ # Add description and tags to schema
81
+ if description:
82
+ schema["description"] = description
83
+ schema["tags"] = tags
84
+
85
+ # Store tool locally
86
+ if session_id not in self.tools:
87
+ self.tools[session_id] = {}
88
+ self.tool_schemas[session_id] = {}
89
+ self.streaming_tools[session_id] = set()
90
+
91
+ self.tools[session_id][name] = func
92
+ self.tool_schemas[session_id][name] = schema
93
+
94
+ if stream:
95
+ self.streaming_tools[session_id].add(name)
96
+
97
+ # Register with server
98
+ self._register_tool_with_server(session_id, machine_id, name, schema)
99
+
100
+ def _register_tool_with_server(
101
+ self, session_id: str, machine_id: str, name: str, schema: Dict
102
+ ):
103
+ """Register tool with server."""
104
+ try:
105
+ self.connection_manager.ensure_connected()
106
+
107
+ payload = {
108
+ "sessionId": session_id,
109
+ "machineId": machine_id,
110
+ "name": name,
111
+ "description": schema.get("description", ""),
112
+ "schema": json.dumps(schema.get("schema", {})),
113
+ "tags": schema.get("tags", []),
114
+ "config": {}, # Default config
115
+ }
116
+
117
+ self.connection_manager.register_tool(payload)
118
+
119
+ except Exception as e:
120
+ raise ToolError(f"Failed to register tool {name} with server: {e}")
121
+
122
+ def unregister_tool(self, session_id: str, name: str):
123
+ """Unregister a tool from a session."""
124
+ try:
125
+ # Remove from local storage
126
+ if session_id in self.tools:
127
+ self.tools[session_id].pop(name, None)
128
+ self.tool_schemas[session_id].pop(name, None)
129
+ self.streaming_tools[session_id].discard(name)
130
+
131
+ # Remove from server
132
+ self._unregister_tool_from_server(session_id, name)
133
+
134
+ except Exception as e:
135
+ raise ToolError(f"Failed to unregister tool {name}: {e}")
136
+
137
+ def _unregister_tool_from_server(self, session_id: str, name: str):
138
+ """Unregister tool from server."""
139
+ try:
140
+ self.connection_manager.ensure_connected()
141
+
142
+ # Get tool by name first
143
+ tool_response = self.connection_manager.get_tool_by_name(session_id, name)
144
+
145
+ if "tool" in tool_response:
146
+ tool_id = tool_response["tool"].get("id")
147
+ if tool_id:
148
+ self.connection_manager.delete_tool(session_id, tool_id)
149
+
150
+ except Exception as e:
151
+ raise ToolError(f"Failed to unregister tool {name} from server: {e}")
152
+
153
+ def get_tool(self, session_id: str, name: str) -> Optional[Callable]:
154
+ """Get a tool function by name."""
155
+ return self.tools.get(session_id, {}).get(name)
156
+
157
+ def is_streaming_tool(self, session_id: str, name: str) -> bool:
158
+ """Check if a tool is a streaming tool."""
159
+ return name in self.streaming_tools.get(session_id, set())
160
+
161
+ def get_session_tools(self, session_id: str) -> Dict[str, Callable]:
162
+ """Get all tools for a session."""
163
+ return self.tools.get(session_id, {})
164
+
165
+ def _get_available_tools_from_server(self, session_id: str) -> Dict[str, Any]:
166
+ """Get available tools from server."""
167
+ # Check cache first
168
+ now = time.time()
169
+ cached = self._tool_cache.get(session_id)
170
+ if cached and now - cached[0] < 30: # 30 second cache
171
+ return {"tools": cached[1]}
172
+
173
+ try:
174
+ self.connection_manager.ensure_connected()
175
+
176
+ response = self.connection_manager.list_tools(session_id)
177
+
178
+ # Handle case where response might be a string or dict
179
+ if isinstance(response, str):
180
+ try:
181
+ response = json.loads(response)
182
+ except (TypeError, ValueError, json.JSONDecodeError):
183
+ return {"tools": []}
184
+
185
+ if not isinstance(response, dict):
186
+ return {"tools": []}
187
+
188
+ tools = []
189
+ for tool in response.get("tools", []):
190
+ try:
191
+ # Handle case where tool might be a string
192
+ if isinstance(tool, str):
193
+ try:
194
+ tool = json.loads(tool)
195
+ except (TypeError, ValueError, json.JSONDecodeError):
196
+ continue
197
+
198
+ if not isinstance(tool, dict):
199
+ continue
200
+
201
+ tools.append(self._normalize_tool(tool))
202
+ except Exception:
203
+ continue
204
+
205
+ # Cache result
206
+ self._tool_cache[session_id] = (now, tools)
207
+ return {"tools": tools}
208
+
209
+ except Exception as e:
210
+ raise ToolError(f"Failed to get available tools: {e}")
211
+
212
+ def list_tools(self, session_id: str) -> List[Dict[str, Any]]:
213
+ """List tools for a session."""
214
+ return self.get_available_tools(session_id).get("tools", [])
215
+
216
+ def get_tool_by_id(self, session_id: str, tool_id: str) -> Dict[str, Any]:
217
+ """Get a tool by ID."""
218
+ try:
219
+ self.connection_manager.ensure_connected()
220
+ response = self.connection_manager.get_tool_by_id(session_id, tool_id)
221
+ payload = response.get("tool", response)
222
+ if not isinstance(payload, dict):
223
+ raise ToolError(f"Unexpected tool lookup payload for {tool_id}")
224
+ return self._normalize_tool(payload)
225
+ except Exception as e:
226
+ raise ToolError(f"Failed to get tool {tool_id}: {e}")
227
+
228
+ def get_tool_by_name(self, session_id: str, tool_name: str) -> Dict[str, Any]:
229
+ """Get a tool by name."""
230
+ try:
231
+ self.connection_manager.ensure_connected()
232
+ response = self.connection_manager.get_tool_by_name(session_id, tool_name)
233
+ payload = response.get("tool", response)
234
+ if not isinstance(payload, dict):
235
+ raise ToolError(f"Unexpected tool lookup payload for {tool_name}")
236
+ return self._normalize_tool(payload)
237
+ except Exception as e:
238
+ raise ToolError(f"Failed to get tool {tool_name}: {e}")
239
+
240
+ def delete_tool(self, session_id: str, tool_id: str) -> bool:
241
+ """Delete a tool by ID."""
242
+ try:
243
+ self.connection_manager.ensure_connected()
244
+
245
+ tool_name = None
246
+ try:
247
+ tool = self.get_tool_by_id(session_id, tool_id)
248
+ tool_name = tool.get("name")
249
+ except Exception:
250
+ tool_name = None
251
+
252
+ response = self.connection_manager.delete_tool(session_id, tool_id)
253
+ success = (
254
+ bool(response.get("success", False))
255
+ if isinstance(response, dict)
256
+ else bool(response)
257
+ )
258
+
259
+ if success:
260
+ self.get_available_tools.cache_clear()
261
+ self._tool_cache.pop(session_id, None)
262
+ with self._lock:
263
+ if tool_name and session_id in self.tools:
264
+ self.tools[session_id].pop(tool_name, None)
265
+ self.tool_schemas[session_id].pop(tool_name, None)
266
+ self.streaming_tools[session_id].discard(tool_name)
267
+
268
+ return success
269
+ except Exception as e:
270
+ raise ToolError(f"Failed to delete tool {tool_id}: {e}")
271
+
272
+ def _execute_tool_on_server(
273
+ self,
274
+ session_id: str,
275
+ tool_name: str,
276
+ params: Dict,
277
+ idempotency_key: str = "",
278
+ timeout_seconds: int = 0,
279
+ wait_timeout_seconds: int = 0,
280
+ ):
281
+ """Execute a tool and return ``(request_id, terminal_status, result)``.
282
+
283
+ ``terminal_status``/``result`` are populated only when the
284
+ server-side long-poll observed a terminal state.
285
+ """
286
+ try:
287
+ self.connection_manager.ensure_connected()
288
+
289
+ response = self.connection_manager.execute_tool(
290
+ session_id,
291
+ tool_name,
292
+ json.dumps(params),
293
+ idempotency_key,
294
+ timeout_seconds=timeout_seconds,
295
+ wait_timeout_seconds=wait_timeout_seconds,
296
+ )
297
+
298
+ # Classify the terminal status BEFORE the generic error check:
299
+ # server-side FAILED/CANCELLED long-poll responses carry an
300
+ # error, and callers need the typed tuple (with the request id)
301
+ # rather than a generic ToolError.
302
+ status_name = _RESPONSE_STATUS_NAMES.get(
303
+ str(response.get("status", "")).lower()
304
+ )
305
+ if wait_timeout_seconds > 0 and status_name in (
306
+ "done",
307
+ "failed",
308
+ "cancelled",
309
+ ):
310
+ result_value = None
311
+ if status_name == "done" and response.get("result") is not None:
312
+ raw = response["result"]
313
+ if isinstance(raw, str):
314
+ try:
315
+ result_value = json.loads(raw)
316
+ except ValueError:
317
+ result_value = raw
318
+ else:
319
+ result_value = raw
320
+ return response.get("requestId"), status_name, result_value
321
+
322
+ if response.get("error"):
323
+ raise ToolError(f"Tool execution failed: {response.get('error')}")
324
+ return response.get("requestId"), None, None
325
+
326
+ except ToolplaneAPIError:
327
+ raise
328
+ except Exception as e:
329
+ raise ToolError(f"Failed to execute tool {tool_name}: {e}")
330
+
331
+ def _stream_tool_on_server(
332
+ self,
333
+ session_id: str,
334
+ tool_name: str,
335
+ params: Dict,
336
+ idempotency_key: str = "",
337
+ timeout_seconds: int = 0,
338
+ ):
339
+ """Stream tool execution."""
340
+ try:
341
+ self.connection_manager.ensure_connected()
342
+
343
+ response = self.connection_manager.stream_execute_tool(
344
+ session_id, tool_name, json.dumps(params), idempotency_key
345
+ )
346
+
347
+ # Track buffer for backpressure
348
+ self.connection_manager.current_buffer_size = 0
349
+
350
+ # Parse newline-delimited JSON (NDJSON)
351
+ for line in response.iter_lines(decode_unicode=True):
352
+ if not line:
353
+ continue
354
+
355
+ # Check buffer size and apply backpressure if needed
356
+ line_size = len(line.encode("utf-8"))
357
+ self.connection_manager.current_buffer_size += line_size
358
+
359
+ if (
360
+ self.connection_manager.current_buffer_size
361
+ > self.connection_manager.config.max_buffer_size
362
+ ):
363
+ # Apply backpressure by pausing before processing
364
+ time.sleep(0.05)
365
+
366
+ try:
367
+ chunk = json.loads(line)
368
+ yield chunk
369
+
370
+ # Update buffer tracking as we process
371
+ self.connection_manager.current_buffer_size -= line_size
372
+
373
+ if chunk.get("isFinal"):
374
+ break
375
+
376
+ except ValueError:
377
+ continue
378
+
379
+ except Exception as e:
380
+ raise ToolError(f"Failed to stream tool {tool_name}: {e}")
381
+
382
+ def cleanup_session_tools(self, session_id: str):
383
+ """Cleanup all tools for a session."""
384
+ if session_id in self.tools:
385
+ for tool_name in list(self.tools[session_id].keys()):
386
+ try:
387
+ self.unregister_tool(session_id, tool_name)
388
+ except Exception:
389
+ # Ignore cleanup errors
390
+ pass
391
+
392
+ # Clear local storage
393
+ self.tools.pop(session_id, None)
394
+ self.tool_schemas.pop(session_id, None)
395
+ self.streaming_tools.pop(session_id, None)
396
+
397
+ def cleanup_all(self):
398
+ """Cleanup all tools."""
399
+ for session_id in list(self.tools.keys()):
400
+ self.cleanup_session_tools(session_id)
@@ -0,0 +1,27 @@
1
+ """Interface definitions for Toolplane client components."""
2
+
3
+ from .client_interface import ClientProtocol, IClientFactory, IToolplaneClient
4
+ from .connection_interface import IConnectionManager, IConnectionStrategy
5
+ from .event_interface import IEventEmitter, IEventHandler
6
+ from .request_interface import IRequestManager, IRequestProcessor
7
+ from .session_interface import ISessionContext, ISessionManager
8
+ from .tool_interface import IToolExecutor, IToolManager
9
+
10
+ __all__ = [
11
+ # Client interfaces
12
+ "IToolplaneClient",
13
+ "IClientFactory",
14
+ "ClientProtocol",
15
+ # Component interfaces
16
+ "IConnectionManager",
17
+ "IConnectionStrategy",
18
+ "ISessionManager",
19
+ "ISessionContext",
20
+ "IToolManager",
21
+ "IToolExecutor",
22
+ "IRequestManager",
23
+ "IRequestProcessor",
24
+ # Event interfaces
25
+ "IEventEmitter",
26
+ "IEventHandler",
27
+ ]
@@ -0,0 +1,122 @@
1
+ """Client interface definitions for protocol-agnostic Toolplane clients."""
2
+
3
+ from abc import ABC, abstractmethod
4
+ from enum import Enum
5
+ from typing import Any, Callable, Dict, List, Optional, Protocol, runtime_checkable
6
+
7
+ from .session_interface import ISessionContext
8
+
9
+
10
+ class ClientProtocol(Enum):
11
+ """Supported client protocols."""
12
+
13
+ HTTP = "http"
14
+ GRPC = "grpc"
15
+ WEBSOCKET = "websocket"
16
+
17
+
18
+ @runtime_checkable
19
+ class IToolplaneClient(Protocol):
20
+ """Protocol interface for Toolplane clients."""
21
+
22
+ def connect(self) -> bool:
23
+ """Connect to Toolplane server."""
24
+ ...
25
+
26
+ def disconnect(self) -> None:
27
+ """Disconnect from Toolplane server."""
28
+ ...
29
+
30
+ def create_session(
31
+ self,
32
+ session_id: Optional[str] = None,
33
+ user_id: Optional[str] = None,
34
+ name: Optional[str] = None,
35
+ description: Optional[str] = None,
36
+ namespace: Optional[str] = None,
37
+ register_machine: bool = False,
38
+ ) -> ISessionContext:
39
+ """Create a new session."""
40
+ ...
41
+
42
+ def get_session(self, session_id: str) -> Optional[ISessionContext]:
43
+ """Get session context by ID."""
44
+ ...
45
+
46
+ def list_sessions(self) -> List[ISessionContext]:
47
+ """List all session contexts."""
48
+ ...
49
+
50
+ def start(self) -> None:
51
+ """Start the client."""
52
+ ...
53
+
54
+ def stop(self) -> None:
55
+ """Stop the client."""
56
+ ...
57
+
58
+
59
+ class IClientFactory(ABC):
60
+ """Abstract factory for creating protocol-specific Toolplane clients."""
61
+
62
+ @abstractmethod
63
+ def create_client(
64
+ self, protocol: ClientProtocol, config: Dict[str, Any]
65
+ ) -> IToolplaneClient:
66
+ """Create a client for the specified protocol."""
67
+ pass
68
+
69
+ @abstractmethod
70
+ def get_supported_protocols(self) -> List[ClientProtocol]:
71
+ """Get list of supported protocols."""
72
+ pass
73
+
74
+ @abstractmethod
75
+ def validate_config(self, protocol: ClientProtocol, config: Dict[str, Any]) -> bool:
76
+ """Validate configuration for the specified protocol."""
77
+ pass
78
+
79
+
80
+ class BaseClientFactory(IClientFactory):
81
+ """Base implementation of client factory with registry pattern."""
82
+
83
+ def __init__(self):
84
+ self._client_classes: Dict[ClientProtocol, type] = {}
85
+ self._config_validators: Dict[ClientProtocol, Callable] = {}
86
+
87
+ def register_client(
88
+ self,
89
+ protocol: ClientProtocol,
90
+ client_class: type,
91
+ config_validator: Optional[Callable] = None,
92
+ ) -> None:
93
+ """Register a client implementation for a protocol."""
94
+ self._client_classes[protocol] = client_class
95
+ if config_validator:
96
+ self._config_validators[protocol] = config_validator
97
+
98
+ def create_client(
99
+ self, protocol: ClientProtocol, config: Dict[str, Any]
100
+ ) -> IToolplaneClient:
101
+ """Create a client for the specified protocol."""
102
+ if protocol not in self._client_classes:
103
+ raise ValueError(f"Unsupported protocol: {protocol}")
104
+
105
+ if not self.validate_config(protocol, config):
106
+ raise ValueError(f"Invalid configuration for protocol: {protocol}")
107
+
108
+ client_class = self._client_classes[protocol]
109
+ return client_class(**config)
110
+
111
+ def get_supported_protocols(self) -> List[ClientProtocol]:
112
+ """Get list of supported protocols."""
113
+ return list(self._client_classes.keys())
114
+
115
+ def validate_config(self, protocol: ClientProtocol, config: Dict[str, Any]) -> bool:
116
+ """Validate configuration for the specified protocol."""
117
+ if protocol in self._config_validators:
118
+ try:
119
+ return self._config_validators[protocol](config)
120
+ except Exception:
121
+ return False
122
+ return True # No validator means valid by default