onetool-mcp 1.0.0b1__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.
- bench/__init__.py +5 -0
- bench/cli.py +69 -0
- bench/harness/__init__.py +66 -0
- bench/harness/client.py +692 -0
- bench/harness/config.py +397 -0
- bench/harness/csv_writer.py +109 -0
- bench/harness/evaluate.py +512 -0
- bench/harness/metrics.py +283 -0
- bench/harness/runner.py +899 -0
- bench/py.typed +0 -0
- bench/reporter.py +629 -0
- bench/run.py +487 -0
- bench/secrets.py +101 -0
- bench/utils.py +16 -0
- onetool/__init__.py +4 -0
- onetool/cli.py +391 -0
- onetool/py.typed +0 -0
- onetool_mcp-1.0.0b1.dist-info/METADATA +163 -0
- onetool_mcp-1.0.0b1.dist-info/RECORD +132 -0
- onetool_mcp-1.0.0b1.dist-info/WHEEL +4 -0
- onetool_mcp-1.0.0b1.dist-info/entry_points.txt +3 -0
- onetool_mcp-1.0.0b1.dist-info/licenses/LICENSE.txt +687 -0
- onetool_mcp-1.0.0b1.dist-info/licenses/NOTICE.txt +64 -0
- ot/__init__.py +37 -0
- ot/__main__.py +6 -0
- ot/_cli.py +107 -0
- ot/_tui.py +53 -0
- ot/config/__init__.py +46 -0
- ot/config/defaults/bench.yaml +4 -0
- ot/config/defaults/diagram-templates/api-flow.mmd +33 -0
- ot/config/defaults/diagram-templates/c4-context.puml +30 -0
- ot/config/defaults/diagram-templates/class-diagram.mmd +87 -0
- ot/config/defaults/diagram-templates/feature-mindmap.mmd +70 -0
- ot/config/defaults/diagram-templates/microservices.d2 +81 -0
- ot/config/defaults/diagram-templates/project-gantt.mmd +37 -0
- ot/config/defaults/diagram-templates/state-machine.mmd +42 -0
- ot/config/defaults/onetool.yaml +25 -0
- ot/config/defaults/prompts.yaml +97 -0
- ot/config/defaults/servers.yaml +7 -0
- ot/config/defaults/snippets.yaml +4 -0
- ot/config/defaults/tool_templates/__init__.py +7 -0
- ot/config/defaults/tool_templates/extension.py +52 -0
- ot/config/defaults/tool_templates/isolated.py +61 -0
- ot/config/dynamic.py +121 -0
- ot/config/global_templates/__init__.py +2 -0
- ot/config/global_templates/bench-secrets-template.yaml +6 -0
- ot/config/global_templates/bench.yaml +9 -0
- ot/config/global_templates/onetool.yaml +27 -0
- ot/config/global_templates/secrets-template.yaml +44 -0
- ot/config/global_templates/servers.yaml +18 -0
- ot/config/global_templates/snippets.yaml +235 -0
- ot/config/loader.py +1087 -0
- ot/config/mcp.py +145 -0
- ot/config/secrets.py +190 -0
- ot/config/tool_config.py +125 -0
- ot/decorators.py +116 -0
- ot/executor/__init__.py +35 -0
- ot/executor/base.py +16 -0
- ot/executor/fence_processor.py +83 -0
- ot/executor/linter.py +142 -0
- ot/executor/pack_proxy.py +260 -0
- ot/executor/param_resolver.py +140 -0
- ot/executor/pep723.py +288 -0
- ot/executor/result_store.py +369 -0
- ot/executor/runner.py +496 -0
- ot/executor/simple.py +163 -0
- ot/executor/tool_loader.py +396 -0
- ot/executor/validator.py +398 -0
- ot/executor/worker_pool.py +388 -0
- ot/executor/worker_proxy.py +189 -0
- ot/http_client.py +145 -0
- ot/logging/__init__.py +37 -0
- ot/logging/config.py +315 -0
- ot/logging/entry.py +213 -0
- ot/logging/format.py +188 -0
- ot/logging/span.py +349 -0
- ot/meta.py +1555 -0
- ot/paths.py +453 -0
- ot/prompts.py +218 -0
- ot/proxy/__init__.py +21 -0
- ot/proxy/manager.py +396 -0
- ot/py.typed +0 -0
- ot/registry/__init__.py +189 -0
- ot/registry/models.py +57 -0
- ot/registry/parser.py +269 -0
- ot/registry/registry.py +413 -0
- ot/server.py +315 -0
- ot/shortcuts/__init__.py +15 -0
- ot/shortcuts/aliases.py +87 -0
- ot/shortcuts/snippets.py +258 -0
- ot/stats/__init__.py +35 -0
- ot/stats/html.py +250 -0
- ot/stats/jsonl_writer.py +283 -0
- ot/stats/reader.py +354 -0
- ot/stats/timing.py +57 -0
- ot/support.py +63 -0
- ot/tools.py +114 -0
- ot/utils/__init__.py +81 -0
- ot/utils/batch.py +161 -0
- ot/utils/cache.py +120 -0
- ot/utils/deps.py +403 -0
- ot/utils/exceptions.py +23 -0
- ot/utils/factory.py +179 -0
- ot/utils/format.py +65 -0
- ot/utils/http.py +202 -0
- ot/utils/platform.py +45 -0
- ot/utils/sanitize.py +130 -0
- ot/utils/truncate.py +69 -0
- ot_tools/__init__.py +4 -0
- ot_tools/_convert/__init__.py +12 -0
- ot_tools/_convert/excel.py +279 -0
- ot_tools/_convert/pdf.py +254 -0
- ot_tools/_convert/powerpoint.py +268 -0
- ot_tools/_convert/utils.py +358 -0
- ot_tools/_convert/word.py +283 -0
- ot_tools/brave_search.py +604 -0
- ot_tools/code_search.py +736 -0
- ot_tools/context7.py +495 -0
- ot_tools/convert.py +614 -0
- ot_tools/db.py +415 -0
- ot_tools/diagram.py +1604 -0
- ot_tools/diagram.yaml +167 -0
- ot_tools/excel.py +1372 -0
- ot_tools/file.py +1348 -0
- ot_tools/firecrawl.py +732 -0
- ot_tools/grounding_search.py +646 -0
- ot_tools/package.py +604 -0
- ot_tools/py.typed +0 -0
- ot_tools/ripgrep.py +544 -0
- ot_tools/scaffold.py +471 -0
- ot_tools/transform.py +213 -0
- ot_tools/web_fetch.py +384 -0
ot/proxy/manager.py
ADDED
|
@@ -0,0 +1,396 @@
|
|
|
1
|
+
"""ProxyManager for connecting to external MCP servers using FastMCP Client.
|
|
2
|
+
|
|
3
|
+
Manages connections to external MCP servers and routes tool calls
|
|
4
|
+
through OneTool's single `run` tool interface.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
import contextlib
|
|
11
|
+
import os
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from fastmcp import Client
|
|
16
|
+
from fastmcp.client.transports import StdioTransport
|
|
17
|
+
from loguru import logger
|
|
18
|
+
from mcp import types
|
|
19
|
+
|
|
20
|
+
from ot.config.mcp import McpServerConfig, expand_secrets, expand_subprocess_env
|
|
21
|
+
from ot.logging import LogSpan
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class ProxyToolInfo:
|
|
26
|
+
"""Information about a proxied tool."""
|
|
27
|
+
|
|
28
|
+
server: str
|
|
29
|
+
name: str
|
|
30
|
+
description: str
|
|
31
|
+
input_schema: dict[str, Any]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class ProxyManager:
|
|
35
|
+
"""Manages connections to external MCP servers using FastMCP Client.
|
|
36
|
+
|
|
37
|
+
Connects to configured MCP servers at startup and provides
|
|
38
|
+
a unified interface for calling their tools.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
def __init__(self) -> None:
|
|
42
|
+
"""Initialize the proxy manager."""
|
|
43
|
+
self._clients: dict[str, Client] = {} # type: ignore[type-arg]
|
|
44
|
+
self._tools_by_server: dict[str, list[types.Tool]] = {}
|
|
45
|
+
self._initialized = False
|
|
46
|
+
self._loop: asyncio.AbstractEventLoop | None = None
|
|
47
|
+
|
|
48
|
+
@property
|
|
49
|
+
def servers(self) -> list[str]:
|
|
50
|
+
"""List of connected server names."""
|
|
51
|
+
return list(self._clients.keys())
|
|
52
|
+
|
|
53
|
+
@property
|
|
54
|
+
def tool_count(self) -> int:
|
|
55
|
+
"""Total number of proxied tools across all servers."""
|
|
56
|
+
return sum(len(tools) for tools in self._tools_by_server.values())
|
|
57
|
+
|
|
58
|
+
def get_connection(self, server: str) -> Client | None: # type: ignore[type-arg]
|
|
59
|
+
"""Get a client by server name."""
|
|
60
|
+
return self._clients.get(server)
|
|
61
|
+
|
|
62
|
+
def list_tools(self, server: str | None = None) -> list[ProxyToolInfo]:
|
|
63
|
+
"""List available tools from proxied servers.
|
|
64
|
+
|
|
65
|
+
Args:
|
|
66
|
+
server: Optional server name to filter by. If None, returns all tools.
|
|
67
|
+
|
|
68
|
+
Returns:
|
|
69
|
+
List of ProxyToolInfo for available tools.
|
|
70
|
+
"""
|
|
71
|
+
tools: list[ProxyToolInfo] = []
|
|
72
|
+
|
|
73
|
+
if server:
|
|
74
|
+
if server in self._tools_by_server:
|
|
75
|
+
for tool in self._tools_by_server[server]:
|
|
76
|
+
tools.append(
|
|
77
|
+
ProxyToolInfo(
|
|
78
|
+
server=server,
|
|
79
|
+
name=tool.name,
|
|
80
|
+
description=tool.description or "",
|
|
81
|
+
input_schema=tool.inputSchema,
|
|
82
|
+
)
|
|
83
|
+
)
|
|
84
|
+
else:
|
|
85
|
+
for srv_name, srv_tools in self._tools_by_server.items():
|
|
86
|
+
for tool in srv_tools:
|
|
87
|
+
tools.append(
|
|
88
|
+
ProxyToolInfo(
|
|
89
|
+
server=srv_name,
|
|
90
|
+
name=tool.name,
|
|
91
|
+
description=tool.description or "",
|
|
92
|
+
input_schema=tool.inputSchema,
|
|
93
|
+
)
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
return tools
|
|
97
|
+
|
|
98
|
+
async def call_tool(
|
|
99
|
+
self,
|
|
100
|
+
server: str,
|
|
101
|
+
tool: str,
|
|
102
|
+
arguments: dict[str, Any] | None = None,
|
|
103
|
+
timeout: float = 30.0,
|
|
104
|
+
) -> str:
|
|
105
|
+
"""Call a tool on a proxied MCP server.
|
|
106
|
+
|
|
107
|
+
Args:
|
|
108
|
+
server: Name of the server to call.
|
|
109
|
+
tool: Name of the tool to call.
|
|
110
|
+
arguments: Arguments to pass to the tool.
|
|
111
|
+
timeout: Timeout for the call in seconds.
|
|
112
|
+
|
|
113
|
+
Returns:
|
|
114
|
+
Text result from the tool.
|
|
115
|
+
|
|
116
|
+
Raises:
|
|
117
|
+
ValueError: If server is not connected.
|
|
118
|
+
RuntimeError: If the tool returns an error.
|
|
119
|
+
TimeoutError: If the call times out.
|
|
120
|
+
"""
|
|
121
|
+
client = self._clients.get(server)
|
|
122
|
+
if not client:
|
|
123
|
+
available = ", ".join(self._clients.keys()) or "none"
|
|
124
|
+
raise ValueError(f"Server '{server}' not connected. Available: {available}")
|
|
125
|
+
|
|
126
|
+
arguments = arguments or {}
|
|
127
|
+
|
|
128
|
+
with LogSpan(span="proxy.tool.call", server=server, tool=tool) as span:
|
|
129
|
+
try:
|
|
130
|
+
result = await asyncio.wait_for(
|
|
131
|
+
client.call_tool(tool, arguments),
|
|
132
|
+
timeout=timeout,
|
|
133
|
+
)
|
|
134
|
+
except TimeoutError:
|
|
135
|
+
logger.error(
|
|
136
|
+
f"Proxy tool timeout | server={server} | tool={tool} | timeout={timeout}s"
|
|
137
|
+
)
|
|
138
|
+
raise TimeoutError(
|
|
139
|
+
f"Tool {server}.{tool} timed out after {timeout}s"
|
|
140
|
+
) from None
|
|
141
|
+
|
|
142
|
+
# Extract text from result
|
|
143
|
+
text_parts: list[str] = []
|
|
144
|
+
for content in result.content:
|
|
145
|
+
if isinstance(content, types.TextContent):
|
|
146
|
+
text_parts.append(content.text)
|
|
147
|
+
elif hasattr(content, "data"):
|
|
148
|
+
text_parts.append(f"[Binary content: {type(content).__name__}]")
|
|
149
|
+
|
|
150
|
+
result_text = (
|
|
151
|
+
"\n".join(text_parts) if text_parts else "Tool returned empty response."
|
|
152
|
+
)
|
|
153
|
+
span.add("resultLength", len(result_text))
|
|
154
|
+
return result_text
|
|
155
|
+
|
|
156
|
+
def call_tool_sync(
|
|
157
|
+
self,
|
|
158
|
+
server: str,
|
|
159
|
+
tool: str,
|
|
160
|
+
arguments: dict[str, Any] | None = None,
|
|
161
|
+
timeout: float = 30.0,
|
|
162
|
+
) -> str:
|
|
163
|
+
"""Synchronously call a tool on a proxied MCP server.
|
|
164
|
+
|
|
165
|
+
This is a blocking wrapper around the async call_tool method,
|
|
166
|
+
suitable for use from sync code (like executed Python code).
|
|
167
|
+
|
|
168
|
+
Args:
|
|
169
|
+
server: Name of the server to call.
|
|
170
|
+
tool: Name of the tool to call.
|
|
171
|
+
arguments: Arguments to pass to the tool.
|
|
172
|
+
timeout: Timeout for the call in seconds.
|
|
173
|
+
|
|
174
|
+
Returns:
|
|
175
|
+
Text result from the tool.
|
|
176
|
+
"""
|
|
177
|
+
if self._loop is None:
|
|
178
|
+
raise RuntimeError(
|
|
179
|
+
"Proxy manager not initialized - no event loop available"
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
future = asyncio.run_coroutine_threadsafe(
|
|
183
|
+
self.call_tool(server, tool, arguments, timeout),
|
|
184
|
+
self._loop,
|
|
185
|
+
)
|
|
186
|
+
return future.result(timeout=timeout + 5)
|
|
187
|
+
|
|
188
|
+
async def connect(self, configs: dict[str, McpServerConfig]) -> None:
|
|
189
|
+
"""Connect to all enabled MCP servers.
|
|
190
|
+
|
|
191
|
+
Args:
|
|
192
|
+
configs: Dictionary of server name -> configuration.
|
|
193
|
+
"""
|
|
194
|
+
if self._initialized:
|
|
195
|
+
return
|
|
196
|
+
|
|
197
|
+
self._loop = asyncio.get_running_loop()
|
|
198
|
+
|
|
199
|
+
enabled_configs = {name: cfg for name, cfg in configs.items() if cfg.enabled}
|
|
200
|
+
|
|
201
|
+
if not enabled_configs:
|
|
202
|
+
logger.debug("No MCP servers configured for proxying")
|
|
203
|
+
self._initialized = True
|
|
204
|
+
return
|
|
205
|
+
|
|
206
|
+
with LogSpan(span="proxy.init", serverCount=len(enabled_configs)) as span:
|
|
207
|
+
connected = 0
|
|
208
|
+
failed = 0
|
|
209
|
+
|
|
210
|
+
for name, config in enabled_configs.items():
|
|
211
|
+
try:
|
|
212
|
+
await self._connect_server(name, config)
|
|
213
|
+
connected += 1
|
|
214
|
+
except Exception as e:
|
|
215
|
+
failed += 1
|
|
216
|
+
logger.warning(f"Failed to connect to MCP server '{name}': {e}")
|
|
217
|
+
|
|
218
|
+
span.add("connected", connected)
|
|
219
|
+
span.add("failed", failed)
|
|
220
|
+
span.add("toolCount", self.tool_count)
|
|
221
|
+
|
|
222
|
+
self._initialized = True
|
|
223
|
+
|
|
224
|
+
async def _connect_server(self, name: str, config: McpServerConfig) -> None:
|
|
225
|
+
"""Connect to a single MCP server using FastMCP Client."""
|
|
226
|
+
with LogSpan(span="proxy.connect", server=name, type=config.type) as span:
|
|
227
|
+
client = self._create_client(name, config)
|
|
228
|
+
|
|
229
|
+
# Enter the client context manager for persistent connection
|
|
230
|
+
await client.__aenter__() # type: ignore[no-untyped-call]
|
|
231
|
+
|
|
232
|
+
try:
|
|
233
|
+
# List tools to verify connection and cache tool info
|
|
234
|
+
tools = await client.list_tools()
|
|
235
|
+
|
|
236
|
+
self._clients[name] = client
|
|
237
|
+
self._tools_by_server[name] = tools
|
|
238
|
+
|
|
239
|
+
span.add("toolCount", len(tools))
|
|
240
|
+
logger.info(
|
|
241
|
+
f"Connected to {config.type} MCP server '{name}' with {len(tools)} tools"
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
except Exception:
|
|
245
|
+
# Clean up on failure
|
|
246
|
+
await client.__aexit__(None, None, None) # type: ignore[no-untyped-call]
|
|
247
|
+
raise
|
|
248
|
+
|
|
249
|
+
def _create_client(self, name: str, config: McpServerConfig) -> Client: # type: ignore[type-arg]
|
|
250
|
+
"""Create a FastMCP Client for the given configuration."""
|
|
251
|
+
if config.type == "http":
|
|
252
|
+
return self._create_http_client(name, config)
|
|
253
|
+
elif config.type == "stdio":
|
|
254
|
+
return self._create_stdio_client(name, config)
|
|
255
|
+
else:
|
|
256
|
+
raise ValueError(f"Unknown server type: {config.type}")
|
|
257
|
+
|
|
258
|
+
def _create_http_client(self, name: str, config: McpServerConfig) -> Client: # type: ignore[type-arg]
|
|
259
|
+
"""Create an HTTP/SSE client."""
|
|
260
|
+
if not config.url:
|
|
261
|
+
raise RuntimeError(f"Server {name}: HTTP server requires url")
|
|
262
|
+
|
|
263
|
+
# Auto-upgrade http:// to https://
|
|
264
|
+
url = config.url
|
|
265
|
+
if url.startswith("http://"):
|
|
266
|
+
url = "https://" + url[7:]
|
|
267
|
+
logger.debug(f"Upgraded {name} URL to HTTPS: {url}")
|
|
268
|
+
|
|
269
|
+
# Expand secrets in headers
|
|
270
|
+
headers = {}
|
|
271
|
+
for key, value in config.headers.items():
|
|
272
|
+
if "${" in value:
|
|
273
|
+
headers[key] = expand_secrets(value)
|
|
274
|
+
else:
|
|
275
|
+
headers[key] = value
|
|
276
|
+
|
|
277
|
+
return Client(url, headers=headers, timeout=float(config.timeout))
|
|
278
|
+
|
|
279
|
+
def _create_stdio_client(self, name: str, config: McpServerConfig) -> Client: # type: ignore[type-arg]
|
|
280
|
+
"""Create a stdio client."""
|
|
281
|
+
if not config.command:
|
|
282
|
+
raise RuntimeError(f"Server {name}: stdio server requires command")
|
|
283
|
+
|
|
284
|
+
# Build environment: PATH only + explicit config.env
|
|
285
|
+
env = {"PATH": os.environ.get("PATH", "")}
|
|
286
|
+
for key, value in config.env.items():
|
|
287
|
+
env[key] = expand_subprocess_env(value)
|
|
288
|
+
|
|
289
|
+
transport = StdioTransport(
|
|
290
|
+
command=config.command,
|
|
291
|
+
args=config.args,
|
|
292
|
+
env=env,
|
|
293
|
+
)
|
|
294
|
+
|
|
295
|
+
return Client(transport, timeout=float(config.timeout))
|
|
296
|
+
|
|
297
|
+
async def shutdown(self) -> None:
|
|
298
|
+
"""Disconnect from all MCP servers."""
|
|
299
|
+
if not self._clients:
|
|
300
|
+
return
|
|
301
|
+
|
|
302
|
+
with LogSpan(span="proxy.shutdown", serverCount=len(self._clients)):
|
|
303
|
+
for name, client in list(self._clients.items()):
|
|
304
|
+
try:
|
|
305
|
+
await client.__aexit__(None, None, None) # type: ignore[no-untyped-call]
|
|
306
|
+
logger.debug(f"Disconnected from MCP server '{name}'")
|
|
307
|
+
except (Exception, asyncio.CancelledError) as e:
|
|
308
|
+
logger.debug(f"Error disconnecting from '{name}': {e}")
|
|
309
|
+
|
|
310
|
+
self._clients.clear()
|
|
311
|
+
self._tools_by_server.clear()
|
|
312
|
+
self._initialized = False
|
|
313
|
+
|
|
314
|
+
async def reconnect(self, configs: dict[str, McpServerConfig]) -> None:
|
|
315
|
+
"""Reconnect to all MCP servers.
|
|
316
|
+
|
|
317
|
+
Shuts down existing connections and reconnects with fresh config.
|
|
318
|
+
|
|
319
|
+
Args:
|
|
320
|
+
configs: Dictionary of server name -> configuration.
|
|
321
|
+
"""
|
|
322
|
+
await self.shutdown()
|
|
323
|
+
await self.connect(configs)
|
|
324
|
+
|
|
325
|
+
def reconnect_sync(self, configs: dict[str, McpServerConfig]) -> None:
|
|
326
|
+
"""Synchronously reconnect to all MCP servers.
|
|
327
|
+
|
|
328
|
+
Blocking wrapper for reconnect, suitable for calling from sync code.
|
|
329
|
+
|
|
330
|
+
Args:
|
|
331
|
+
configs: Dictionary of server name -> configuration.
|
|
332
|
+
"""
|
|
333
|
+
loop = self._loop
|
|
334
|
+
|
|
335
|
+
# Try to get running loop if we don't have one stored
|
|
336
|
+
if loop is None:
|
|
337
|
+
with contextlib.suppress(RuntimeError):
|
|
338
|
+
loop = asyncio.get_running_loop()
|
|
339
|
+
|
|
340
|
+
if loop is None:
|
|
341
|
+
# No event loop available - just reset state, connect will happen on next use
|
|
342
|
+
self._clients.clear()
|
|
343
|
+
self._tools_by_server.clear()
|
|
344
|
+
self._initialized = False
|
|
345
|
+
return
|
|
346
|
+
|
|
347
|
+
future = asyncio.run_coroutine_threadsafe(
|
|
348
|
+
self.reconnect(configs),
|
|
349
|
+
loop,
|
|
350
|
+
)
|
|
351
|
+
try:
|
|
352
|
+
future.result(timeout=60)
|
|
353
|
+
except Exception as e:
|
|
354
|
+
logger.warning(f"Error during proxy reconnect: {e}")
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
# Global proxy manager instance
|
|
358
|
+
_proxy_manager: ProxyManager | None = None
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def get_proxy_manager() -> ProxyManager:
|
|
362
|
+
"""Get or create the global proxy manager instance.
|
|
363
|
+
|
|
364
|
+
Returns:
|
|
365
|
+
ProxyManager instance.
|
|
366
|
+
"""
|
|
367
|
+
global _proxy_manager
|
|
368
|
+
if _proxy_manager is None:
|
|
369
|
+
_proxy_manager = ProxyManager()
|
|
370
|
+
return _proxy_manager
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def reset_proxy_manager() -> None:
|
|
374
|
+
"""Reset the global proxy manager (for testing)."""
|
|
375
|
+
global _proxy_manager
|
|
376
|
+
_proxy_manager = None
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def reconnect_proxy_manager() -> None:
|
|
380
|
+
"""Reconnect the global proxy manager with fresh config.
|
|
381
|
+
|
|
382
|
+
Loads server configs from the current configuration and reconnects
|
|
383
|
+
all MCP proxy servers. Call this after modifying server config.
|
|
384
|
+
"""
|
|
385
|
+
from ot.config.loader import get_config
|
|
386
|
+
|
|
387
|
+
proxy = get_proxy_manager()
|
|
388
|
+
cfg = get_config()
|
|
389
|
+
|
|
390
|
+
if cfg.servers:
|
|
391
|
+
proxy.reconnect_sync(cfg.servers)
|
|
392
|
+
else:
|
|
393
|
+
# No servers configured - just reset state
|
|
394
|
+
proxy._clients.clear()
|
|
395
|
+
proxy._tools_by_server.clear()
|
|
396
|
+
proxy._initialized = False
|
ot/py.typed
ADDED
|
File without changes
|
ot/registry/__init__.py
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
"""Tool registry package with auto-discovery for user-defined Python tools.
|
|
2
|
+
|
|
3
|
+
The registry scans the `src/ot_tools/` directory, extracts function signatures and
|
|
4
|
+
docstrings using AST parsing, and provides formatted context for LLM code generation.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import inspect
|
|
10
|
+
from typing import TYPE_CHECKING, Any
|
|
11
|
+
|
|
12
|
+
from docstring_parser import parse as parse_docstring
|
|
13
|
+
|
|
14
|
+
from .models import ArgInfo, ToolInfo
|
|
15
|
+
from .registry import ToolRegistry
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
from collections.abc import Callable
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
__all__ = [
|
|
22
|
+
"ArgInfo",
|
|
23
|
+
"ToolInfo",
|
|
24
|
+
"ToolRegistry",
|
|
25
|
+
"describe_tool",
|
|
26
|
+
"get_registry",
|
|
27
|
+
"list_tools",
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
# Global registry instance
|
|
31
|
+
_registry: ToolRegistry | None = None
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _build_tool_info_from_callable(
|
|
35
|
+
name: str,
|
|
36
|
+
func: Callable[..., Any],
|
|
37
|
+
pack: str | None = None,
|
|
38
|
+
) -> ToolInfo:
|
|
39
|
+
"""Build ToolInfo from a callable using inspect.
|
|
40
|
+
|
|
41
|
+
Args:
|
|
42
|
+
name: Full tool name (e.g., "ot.tools").
|
|
43
|
+
func: The function object.
|
|
44
|
+
pack: Pack name if applicable.
|
|
45
|
+
|
|
46
|
+
Returns:
|
|
47
|
+
ToolInfo with extracted signature and docstring info.
|
|
48
|
+
"""
|
|
49
|
+
# Get signature
|
|
50
|
+
try:
|
|
51
|
+
sig = inspect.signature(func)
|
|
52
|
+
signature = f"{name}{sig}"
|
|
53
|
+
except (ValueError, TypeError):
|
|
54
|
+
signature = f"{name}(...)"
|
|
55
|
+
|
|
56
|
+
# Parse docstring
|
|
57
|
+
doc = func.__doc__ or ""
|
|
58
|
+
parsed = parse_docstring(doc)
|
|
59
|
+
|
|
60
|
+
# Build args list
|
|
61
|
+
args: list[ArgInfo] = []
|
|
62
|
+
for param_name, param in sig.parameters.items():
|
|
63
|
+
if param_name in ("self", "cls"):
|
|
64
|
+
continue
|
|
65
|
+
|
|
66
|
+
# Get type annotation
|
|
67
|
+
if param.annotation != inspect.Parameter.empty:
|
|
68
|
+
param_type = (
|
|
69
|
+
param.annotation.__name__
|
|
70
|
+
if hasattr(param.annotation, "__name__")
|
|
71
|
+
else str(param.annotation)
|
|
72
|
+
)
|
|
73
|
+
else:
|
|
74
|
+
param_type = "Any"
|
|
75
|
+
|
|
76
|
+
# Get default value
|
|
77
|
+
default = None
|
|
78
|
+
if param.default != inspect.Parameter.empty:
|
|
79
|
+
default = repr(param.default)
|
|
80
|
+
|
|
81
|
+
# Get description from parsed docstring
|
|
82
|
+
description = ""
|
|
83
|
+
for doc_param in parsed.params:
|
|
84
|
+
if doc_param.arg_name == param_name:
|
|
85
|
+
description = doc_param.description or ""
|
|
86
|
+
break
|
|
87
|
+
|
|
88
|
+
args.append(
|
|
89
|
+
ArgInfo(
|
|
90
|
+
name=param_name,
|
|
91
|
+
type=param_type,
|
|
92
|
+
default=default,
|
|
93
|
+
description=description,
|
|
94
|
+
)
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
# Get return description
|
|
98
|
+
returns = (parsed.returns.description or "") if parsed.returns else ""
|
|
99
|
+
|
|
100
|
+
return ToolInfo(
|
|
101
|
+
name=name,
|
|
102
|
+
pack=pack,
|
|
103
|
+
module=func.__module__,
|
|
104
|
+
signature=signature,
|
|
105
|
+
description=parsed.short_description or "",
|
|
106
|
+
args=args,
|
|
107
|
+
returns=returns,
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _register_ot_pack(registry: ToolRegistry) -> None:
|
|
112
|
+
"""Register the ot pack tools in the registry.
|
|
113
|
+
|
|
114
|
+
The ot pack provides introspection functions that need parameter
|
|
115
|
+
shorthand support like other tools.
|
|
116
|
+
"""
|
|
117
|
+
from ot.meta import PACK_NAME, get_ot_pack_functions
|
|
118
|
+
|
|
119
|
+
ot_functions = get_ot_pack_functions()
|
|
120
|
+
|
|
121
|
+
for func_name, func in ot_functions.items():
|
|
122
|
+
full_name = f"{PACK_NAME}.{func_name}"
|
|
123
|
+
tool_info = _build_tool_info_from_callable(full_name, func, pack=PACK_NAME)
|
|
124
|
+
registry.register_tool(tool_info)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def get_registry(tools_path: Path | None = None, rescan: bool = False) -> ToolRegistry:
|
|
128
|
+
"""Get or create the global tool registry.
|
|
129
|
+
|
|
130
|
+
Uses config's tools_dir glob patterns if available, otherwise falls back
|
|
131
|
+
to the provided tools_path or default 'src/ot_tools/' directory.
|
|
132
|
+
|
|
133
|
+
Args:
|
|
134
|
+
tools_path: Path to tools directory (fallback if no config).
|
|
135
|
+
rescan: If True, rescan even if registry exists.
|
|
136
|
+
|
|
137
|
+
Returns:
|
|
138
|
+
ToolRegistry instance with discovered tools.
|
|
139
|
+
"""
|
|
140
|
+
from ot.config.loader import get_config
|
|
141
|
+
|
|
142
|
+
global _registry
|
|
143
|
+
|
|
144
|
+
if _registry is None:
|
|
145
|
+
_registry = ToolRegistry(tools_path)
|
|
146
|
+
# Use config's tool files if available
|
|
147
|
+
config = get_config()
|
|
148
|
+
tool_files = config.get_tool_files()
|
|
149
|
+
if tool_files:
|
|
150
|
+
_registry.scan_files(tool_files)
|
|
151
|
+
else:
|
|
152
|
+
_registry.scan_directory()
|
|
153
|
+
# Register ot pack tools for param shorthand support
|
|
154
|
+
_register_ot_pack(_registry)
|
|
155
|
+
elif rescan:
|
|
156
|
+
# Rescan using config's tool files
|
|
157
|
+
config = get_config()
|
|
158
|
+
tool_files = config.get_tool_files()
|
|
159
|
+
if tool_files:
|
|
160
|
+
_registry.scan_files(tool_files)
|
|
161
|
+
else:
|
|
162
|
+
_registry.scan_directory()
|
|
163
|
+
# Re-register ot pack tools after rescan
|
|
164
|
+
_register_ot_pack(_registry)
|
|
165
|
+
|
|
166
|
+
return _registry
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def list_tools() -> str:
|
|
170
|
+
"""List all registered tools.
|
|
171
|
+
|
|
172
|
+
Returns:
|
|
173
|
+
Summary of all registered tools.
|
|
174
|
+
"""
|
|
175
|
+
registry = get_registry(rescan=True)
|
|
176
|
+
return registry.format_summary()
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def describe_tool(name: str) -> str:
|
|
180
|
+
"""Describe a specific tool.
|
|
181
|
+
|
|
182
|
+
Args:
|
|
183
|
+
name: Tool function name.
|
|
184
|
+
|
|
185
|
+
Returns:
|
|
186
|
+
Detailed tool description.
|
|
187
|
+
"""
|
|
188
|
+
registry = get_registry()
|
|
189
|
+
return registry.describe_tool(name)
|
ot/registry/models.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Registry models - Pydantic models for tool and argument information."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel, Field
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ArgInfo(BaseModel):
|
|
9
|
+
"""Information about a function argument."""
|
|
10
|
+
|
|
11
|
+
name: str = Field(description="Argument name")
|
|
12
|
+
type: str = Field(default="Any", description="Type annotation")
|
|
13
|
+
default: str | None = Field(default=None, description="Default value if any")
|
|
14
|
+
description: str = Field(
|
|
15
|
+
default="", description="Argument description from docstring"
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class ToolInfo(BaseModel):
|
|
20
|
+
"""Information about a registered tool function."""
|
|
21
|
+
|
|
22
|
+
name: str = Field(
|
|
23
|
+
description="Qualified tool name (e.g., 'code.search' or 'search')"
|
|
24
|
+
)
|
|
25
|
+
pack: str | None = Field(
|
|
26
|
+
default=None, description="Pack name if tool belongs to a pack"
|
|
27
|
+
)
|
|
28
|
+
module: str = Field(description="Module path (e.g., 'tools.gold_prices')")
|
|
29
|
+
signature: str = Field(description="Full function signature")
|
|
30
|
+
description: str = Field(
|
|
31
|
+
default="", description="Function description from docstring"
|
|
32
|
+
)
|
|
33
|
+
args: list[ArgInfo] = Field(default_factory=list, description="Function arguments")
|
|
34
|
+
returns: str = Field(
|
|
35
|
+
default="", description="Return type/description from docstring"
|
|
36
|
+
)
|
|
37
|
+
examples: list[str] = Field(
|
|
38
|
+
default_factory=list, description="Usage examples from @tool decorator"
|
|
39
|
+
)
|
|
40
|
+
tags: list[str] = Field(
|
|
41
|
+
default_factory=list, description="Categorization tags from @tool decorator"
|
|
42
|
+
)
|
|
43
|
+
enabled: bool = Field(default=True, description="Whether the tool is enabled")
|
|
44
|
+
deprecated: bool = Field(
|
|
45
|
+
default=False, description="Whether the tool is deprecated"
|
|
46
|
+
)
|
|
47
|
+
deprecated_message: str | None = Field(
|
|
48
|
+
default=None, description="Deprecation message"
|
|
49
|
+
)
|
|
50
|
+
config_schema: str | None = Field(
|
|
51
|
+
default=None,
|
|
52
|
+
description="Config class source code extracted via AST (class Config(BaseModel))",
|
|
53
|
+
)
|
|
54
|
+
requires: dict[str, list[tuple[str, ...] | dict[str, str] | str]] | None = Field(
|
|
55
|
+
default=None,
|
|
56
|
+
description="Dependencies from __ot_requires__ (cli and lib lists)",
|
|
57
|
+
)
|