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.
Files changed (132) hide show
  1. bench/__init__.py +5 -0
  2. bench/cli.py +69 -0
  3. bench/harness/__init__.py +66 -0
  4. bench/harness/client.py +692 -0
  5. bench/harness/config.py +397 -0
  6. bench/harness/csv_writer.py +109 -0
  7. bench/harness/evaluate.py +512 -0
  8. bench/harness/metrics.py +283 -0
  9. bench/harness/runner.py +899 -0
  10. bench/py.typed +0 -0
  11. bench/reporter.py +629 -0
  12. bench/run.py +487 -0
  13. bench/secrets.py +101 -0
  14. bench/utils.py +16 -0
  15. onetool/__init__.py +4 -0
  16. onetool/cli.py +391 -0
  17. onetool/py.typed +0 -0
  18. onetool_mcp-1.0.0b1.dist-info/METADATA +163 -0
  19. onetool_mcp-1.0.0b1.dist-info/RECORD +132 -0
  20. onetool_mcp-1.0.0b1.dist-info/WHEEL +4 -0
  21. onetool_mcp-1.0.0b1.dist-info/entry_points.txt +3 -0
  22. onetool_mcp-1.0.0b1.dist-info/licenses/LICENSE.txt +687 -0
  23. onetool_mcp-1.0.0b1.dist-info/licenses/NOTICE.txt +64 -0
  24. ot/__init__.py +37 -0
  25. ot/__main__.py +6 -0
  26. ot/_cli.py +107 -0
  27. ot/_tui.py +53 -0
  28. ot/config/__init__.py +46 -0
  29. ot/config/defaults/bench.yaml +4 -0
  30. ot/config/defaults/diagram-templates/api-flow.mmd +33 -0
  31. ot/config/defaults/diagram-templates/c4-context.puml +30 -0
  32. ot/config/defaults/diagram-templates/class-diagram.mmd +87 -0
  33. ot/config/defaults/diagram-templates/feature-mindmap.mmd +70 -0
  34. ot/config/defaults/diagram-templates/microservices.d2 +81 -0
  35. ot/config/defaults/diagram-templates/project-gantt.mmd +37 -0
  36. ot/config/defaults/diagram-templates/state-machine.mmd +42 -0
  37. ot/config/defaults/onetool.yaml +25 -0
  38. ot/config/defaults/prompts.yaml +97 -0
  39. ot/config/defaults/servers.yaml +7 -0
  40. ot/config/defaults/snippets.yaml +4 -0
  41. ot/config/defaults/tool_templates/__init__.py +7 -0
  42. ot/config/defaults/tool_templates/extension.py +52 -0
  43. ot/config/defaults/tool_templates/isolated.py +61 -0
  44. ot/config/dynamic.py +121 -0
  45. ot/config/global_templates/__init__.py +2 -0
  46. ot/config/global_templates/bench-secrets-template.yaml +6 -0
  47. ot/config/global_templates/bench.yaml +9 -0
  48. ot/config/global_templates/onetool.yaml +27 -0
  49. ot/config/global_templates/secrets-template.yaml +44 -0
  50. ot/config/global_templates/servers.yaml +18 -0
  51. ot/config/global_templates/snippets.yaml +235 -0
  52. ot/config/loader.py +1087 -0
  53. ot/config/mcp.py +145 -0
  54. ot/config/secrets.py +190 -0
  55. ot/config/tool_config.py +125 -0
  56. ot/decorators.py +116 -0
  57. ot/executor/__init__.py +35 -0
  58. ot/executor/base.py +16 -0
  59. ot/executor/fence_processor.py +83 -0
  60. ot/executor/linter.py +142 -0
  61. ot/executor/pack_proxy.py +260 -0
  62. ot/executor/param_resolver.py +140 -0
  63. ot/executor/pep723.py +288 -0
  64. ot/executor/result_store.py +369 -0
  65. ot/executor/runner.py +496 -0
  66. ot/executor/simple.py +163 -0
  67. ot/executor/tool_loader.py +396 -0
  68. ot/executor/validator.py +398 -0
  69. ot/executor/worker_pool.py +388 -0
  70. ot/executor/worker_proxy.py +189 -0
  71. ot/http_client.py +145 -0
  72. ot/logging/__init__.py +37 -0
  73. ot/logging/config.py +315 -0
  74. ot/logging/entry.py +213 -0
  75. ot/logging/format.py +188 -0
  76. ot/logging/span.py +349 -0
  77. ot/meta.py +1555 -0
  78. ot/paths.py +453 -0
  79. ot/prompts.py +218 -0
  80. ot/proxy/__init__.py +21 -0
  81. ot/proxy/manager.py +396 -0
  82. ot/py.typed +0 -0
  83. ot/registry/__init__.py +189 -0
  84. ot/registry/models.py +57 -0
  85. ot/registry/parser.py +269 -0
  86. ot/registry/registry.py +413 -0
  87. ot/server.py +315 -0
  88. ot/shortcuts/__init__.py +15 -0
  89. ot/shortcuts/aliases.py +87 -0
  90. ot/shortcuts/snippets.py +258 -0
  91. ot/stats/__init__.py +35 -0
  92. ot/stats/html.py +250 -0
  93. ot/stats/jsonl_writer.py +283 -0
  94. ot/stats/reader.py +354 -0
  95. ot/stats/timing.py +57 -0
  96. ot/support.py +63 -0
  97. ot/tools.py +114 -0
  98. ot/utils/__init__.py +81 -0
  99. ot/utils/batch.py +161 -0
  100. ot/utils/cache.py +120 -0
  101. ot/utils/deps.py +403 -0
  102. ot/utils/exceptions.py +23 -0
  103. ot/utils/factory.py +179 -0
  104. ot/utils/format.py +65 -0
  105. ot/utils/http.py +202 -0
  106. ot/utils/platform.py +45 -0
  107. ot/utils/sanitize.py +130 -0
  108. ot/utils/truncate.py +69 -0
  109. ot_tools/__init__.py +4 -0
  110. ot_tools/_convert/__init__.py +12 -0
  111. ot_tools/_convert/excel.py +279 -0
  112. ot_tools/_convert/pdf.py +254 -0
  113. ot_tools/_convert/powerpoint.py +268 -0
  114. ot_tools/_convert/utils.py +358 -0
  115. ot_tools/_convert/word.py +283 -0
  116. ot_tools/brave_search.py +604 -0
  117. ot_tools/code_search.py +736 -0
  118. ot_tools/context7.py +495 -0
  119. ot_tools/convert.py +614 -0
  120. ot_tools/db.py +415 -0
  121. ot_tools/diagram.py +1604 -0
  122. ot_tools/diagram.yaml +167 -0
  123. ot_tools/excel.py +1372 -0
  124. ot_tools/file.py +1348 -0
  125. ot_tools/firecrawl.py +732 -0
  126. ot_tools/grounding_search.py +646 -0
  127. ot_tools/package.py +604 -0
  128. ot_tools/py.typed +0 -0
  129. ot_tools/ripgrep.py +544 -0
  130. ot_tools/scaffold.py +471 -0
  131. ot_tools/transform.py +213 -0
  132. ot_tools/web_fetch.py +384 -0
@@ -0,0 +1,83 @@
1
+ """Fence processing for command execution.
2
+
3
+ Handles stripping of:
4
+ - Execution trigger prefixes (__ot, __onetool, mcp__onetool__run)
5
+ - Markdown code fences (triple backticks with/without language)
6
+ - Inline backticks (single and double)
7
+
8
+ Used by the runner to clean commands before execution.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import re
14
+
15
+
16
+ def strip_fences(command: str) -> tuple[str, bool]:
17
+ """Strip execution prefixes, markdown code fences, and inline backticks.
18
+
19
+ Execution trigger prefixes (stripped first):
20
+ __ot - short name, default tool
21
+ __ot__run - short name, explicit tool call
22
+ __onetool - full name, default tool
23
+ __onetool__run - full name, explicit tool call
24
+ mcp__onetool__run - explicit MCP call
25
+
26
+ Each prefix supports three invocation styles:
27
+ <prefix> func(arg="value") - simple call
28
+ <prefix> `code` - inline backticks
29
+ <prefix> + code fence - multi-line code fence
30
+
31
+ Note: mcp__ot__run is NOT a valid prefix.
32
+
33
+ Markdown fences (stripped after prefix):
34
+ ```python
35
+ code here
36
+ ```
37
+
38
+ `code here`
39
+
40
+ `` `code here` ``
41
+
42
+ Args:
43
+ command: Raw command string that may contain prefixes and fences
44
+
45
+ Returns:
46
+ Tuple of (stripped command, whether anything was stripped)
47
+ """
48
+ stripped = command.strip()
49
+ anything_stripped = False
50
+
51
+ # Strip execution trigger prefixes:
52
+ # - __ot, __ot__run (short name)
53
+ # - __onetool, __onetool__run (full name)
54
+ # - mcp__onetool__run (explicit MCP call)
55
+ # Note: mcp__ot__run is NOT valid
56
+ prefix_pattern = r"^(?:mcp__onetool__run|__onetool(?:__run)?|__ot(?:__run)?)\s*"
57
+ match = re.match(prefix_pattern, stripped)
58
+ if match:
59
+ stripped = stripped[match.end() :].strip()
60
+ anything_stripped = True
61
+
62
+ # Handle triple backtick fenced blocks
63
+ if stripped.startswith("```"):
64
+ first_newline = stripped.find("\n")
65
+ if first_newline != -1 and stripped.endswith("```"):
66
+ content = stripped[first_newline + 1 : -3].strip()
67
+ return content, True
68
+
69
+ # Handle double backtick fenced blocks: `` `code` ``
70
+ if stripped.startswith("`` `") and stripped.endswith("` ``"):
71
+ content = stripped[4:-4].strip()
72
+ return content, True
73
+
74
+ if stripped.startswith("``") and stripped.endswith("``"):
75
+ content = stripped[2:-2].strip()
76
+ return content, True
77
+
78
+ # Handle inline single backticks: `code`
79
+ if stripped.startswith("`") and stripped.endswith("`") and stripped.count("`") == 2:
80
+ content = stripped[1:-1].strip()
81
+ return content, True
82
+
83
+ return stripped, anything_stripped
ot/executor/linter.py ADDED
@@ -0,0 +1,142 @@
1
+ """Optional Ruff linting integration for OneTool.
2
+
3
+ Provides style warnings (non-blocking) using Ruff linter if installed.
4
+ Falls back gracefully if Ruff is not available.
5
+
6
+ Example:
7
+ result = lint_code(code)
8
+ if result.available:
9
+ for warning in result.warnings:
10
+ print(f"Style warning: {warning}")
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import contextlib
16
+ import subprocess
17
+ import tempfile
18
+ from dataclasses import dataclass, field
19
+ from pathlib import Path
20
+
21
+ from loguru import logger
22
+
23
+
24
+ @dataclass
25
+ class LintResult:
26
+ """Result of linting operation."""
27
+
28
+ available: bool = False # Whether Ruff is available
29
+ warnings: list[str] = field(default_factory=list)
30
+ error: str | None = None # Error message if linting failed
31
+
32
+
33
+ def _check_ruff_available() -> bool:
34
+ """Check if Ruff is available on the system."""
35
+ try:
36
+ result = subprocess.run(
37
+ ["ruff", "--version"],
38
+ capture_output=True,
39
+ text=True,
40
+ timeout=5,
41
+ )
42
+ return result.returncode == 0
43
+ except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
44
+ return False
45
+
46
+
47
+ # Cache Ruff availability check
48
+ _ruff_available: bool | None = None
49
+
50
+
51
+ def is_ruff_available() -> bool:
52
+ """Check if Ruff linter is available (cached)."""
53
+ global _ruff_available
54
+ if _ruff_available is None:
55
+ _ruff_available = _check_ruff_available()
56
+ return _ruff_available
57
+
58
+
59
+ def lint_code(
60
+ code: str,
61
+ select: list[str] | None = None,
62
+ ignore: list[str] | None = None,
63
+ ) -> LintResult:
64
+ """Lint Python code using Ruff.
65
+
66
+ Args:
67
+ code: Python code to lint
68
+ select: Rule codes to enable (e.g., ["E", "F", "W"])
69
+ ignore: Rule codes to ignore (e.g., ["E501"])
70
+
71
+ Returns:
72
+ LintResult with warnings if Ruff is available
73
+ """
74
+ result = LintResult()
75
+
76
+ if not is_ruff_available():
77
+ result.available = False
78
+ return result
79
+
80
+ result.available = True
81
+
82
+ # Write code to temp file for Ruff
83
+ try:
84
+ with tempfile.NamedTemporaryFile(
85
+ mode="w",
86
+ suffix=".py",
87
+ delete=False,
88
+ ) as f:
89
+ f.write(code)
90
+ temp_path = Path(f.name)
91
+
92
+ # Build Ruff command
93
+ cmd = ["ruff", "check", str(temp_path), "--output-format=text"]
94
+
95
+ if select:
96
+ cmd.extend(["--select", ",".join(select)])
97
+ if ignore:
98
+ cmd.extend(["--ignore", ",".join(ignore)])
99
+
100
+ # Run Ruff
101
+ proc = subprocess.run(
102
+ cmd,
103
+ capture_output=True,
104
+ text=True,
105
+ timeout=30,
106
+ )
107
+
108
+ # Parse output - each line is a warning
109
+ if proc.stdout:
110
+ for line in proc.stdout.strip().split("\n"):
111
+ if line.strip():
112
+ # Remove temp file path from output
113
+ warning = line.replace(str(temp_path), "<code>")
114
+ result.warnings.append(warning)
115
+
116
+ except subprocess.TimeoutExpired:
117
+ result.error = "Ruff linting timed out"
118
+ logger.warning("Ruff linting timed out")
119
+ except OSError as e:
120
+ result.error = f"Failed to run Ruff: {e}"
121
+ logger.warning(f"Failed to run Ruff: {e}")
122
+ finally:
123
+ # Clean up temp file
124
+ with contextlib.suppress(NameError, OSError):
125
+ temp_path.unlink()
126
+
127
+ return result
128
+
129
+
130
+ def lint_code_quick(code: str) -> list[str]:
131
+ """Quick lint that returns just the warnings list.
132
+
133
+ Convenience function for simple use cases.
134
+
135
+ Args:
136
+ code: Python code to lint
137
+
138
+ Returns:
139
+ List of warning strings (empty if Ruff unavailable)
140
+ """
141
+ result = lint_code(code)
142
+ return result.warnings
@@ -0,0 +1,260 @@
1
+ """Pack proxy creation for dot notation access.
2
+
3
+ Creates proxy objects that allow:
4
+ - brave.web_search(query="test") - pack access to tool functions
5
+ - context7.resolve_library_id() - MCP proxy access
6
+ - proxy.list_servers() - introspection of MCP servers
7
+
8
+ Used by the runner to build the execution namespace.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from collections import OrderedDict
14
+ from functools import wraps
15
+ from typing import TYPE_CHECKING, Any
16
+
17
+ from ot.executor.param_resolver import (
18
+ get_mcp_tool_param_names,
19
+ get_tool_param_names,
20
+ resolve_kwargs,
21
+ )
22
+ from ot.stats import timed_tool_call
23
+
24
+ if TYPE_CHECKING:
25
+ from collections.abc import Callable
26
+
27
+ from ot.executor.tool_loader import LoadedTools
28
+
29
+
30
+ def _wrap_with_stats(
31
+ pack_name: str, func_name: str, func: Callable[..., Any]
32
+ ) -> Callable[..., Any]:
33
+ """Wrap a function to record execution-level stats, track calls, and resolve param prefixes."""
34
+ tool_name = f"{pack_name}.{func_name}"
35
+
36
+ @wraps(func)
37
+ def wrapper(*args: Any, **kwargs: Any) -> Any:
38
+ # Resolve abbreviated parameter names (cached lookup)
39
+ if kwargs:
40
+ param_names = get_tool_param_names(tool_name)
41
+ if param_names:
42
+ kwargs = resolve_kwargs(kwargs, param_names)
43
+
44
+ with timed_tool_call(tool_name):
45
+ return func(*args, **kwargs)
46
+
47
+ return wrapper
48
+
49
+
50
+ def _create_pack_proxy(pack_name: str, pack_funcs: dict[str, Any]) -> Any:
51
+ """Create a pack proxy instance for dot notation access.
52
+
53
+ Returns an object that allows pack.func() syntax where func is looked up
54
+ from pack_funcs dict. Each function call is tracked for execution-level stats.
55
+ """
56
+
57
+ class PackProxy:
58
+ """Proxy object that provides dot notation access to pack functions."""
59
+
60
+ def __init__(self) -> None:
61
+ # Cache wrapped functions to avoid recreating on each access
62
+ self._function_cache: dict[str, Callable[..., Any]] = {}
63
+
64
+ def __getattr__(self, name: str) -> Any:
65
+ if name.startswith("_"):
66
+ raise AttributeError(f"Cannot access private attribute '{name}'")
67
+
68
+ if name in pack_funcs:
69
+ # Return cached wrapper or create and cache new one
70
+ if name not in self._function_cache:
71
+ self._function_cache[name] = _wrap_with_stats(
72
+ pack_name, name, pack_funcs[name]
73
+ )
74
+ return self._function_cache[name]
75
+
76
+ available = ", ".join(sorted(pack_funcs.keys()))
77
+ raise AttributeError(
78
+ f"Function '{name}' not found in pack '{pack_name}'. "
79
+ f"Available: {available}"
80
+ )
81
+
82
+ return PackProxy()
83
+
84
+
85
+ def _create_mcp_proxy_pack(server_name: str) -> Any:
86
+ """Create a pack proxy for an MCP server.
87
+
88
+ Allows calling proxied MCP tools using dot notation:
89
+ - context7.resolve_library_id(library_name="next.js")
90
+
91
+ Each call is tracked for execution-level stats.
92
+
93
+ Args:
94
+ server_name: Name of the MCP server.
95
+
96
+ Returns:
97
+ Object with __getattr__ that routes to proxy manager.
98
+ """
99
+ from ot.proxy import get_proxy_manager
100
+
101
+ class McpProxyPack:
102
+ """Proxy object that routes tool calls to an MCP server."""
103
+
104
+ def __init__(self) -> None:
105
+ # Cache callable proxies to avoid recreating on each access
106
+ self._function_cache: dict[str, Callable[..., str]] = {}
107
+
108
+ def __getattr__(self, tool_name: str) -> Any:
109
+ if tool_name.startswith("_"):
110
+ raise AttributeError(f"Cannot access private attribute '{tool_name}'")
111
+
112
+ if tool_name in self._function_cache:
113
+ return self._function_cache[tool_name]
114
+
115
+ def call_proxy_tool(**kwargs: Any) -> str:
116
+ tool_full_name = f"{server_name}.{tool_name}"
117
+
118
+ # Resolve abbreviated parameter names (cached lookup)
119
+ if kwargs:
120
+ param_names = get_mcp_tool_param_names(server_name, tool_name)
121
+ if param_names:
122
+ kwargs = resolve_kwargs(kwargs, param_names)
123
+
124
+ with timed_tool_call(tool_full_name):
125
+ proxy = get_proxy_manager()
126
+ return proxy.call_tool_sync(server_name, tool_name, kwargs)
127
+
128
+ self._function_cache[tool_name] = call_proxy_tool
129
+ return call_proxy_tool
130
+
131
+ return McpProxyPack()
132
+
133
+
134
+ def _create_proxy_introspection_pack() -> Any:
135
+ """Create the 'proxy' pack for introspection.
136
+
137
+ Provides:
138
+ - proxy.list_servers() - List all configured MCP servers with status
139
+ - proxy.list_tools(server="name") - List tools available on a server
140
+
141
+ Returns:
142
+ Object with introspection methods.
143
+ """
144
+ from ot.proxy import get_proxy_manager
145
+
146
+ class ProxyIntrospectionPack:
147
+ """Provides introspection methods for proxied MCP servers."""
148
+
149
+ def list_servers(self) -> list[dict[str, Any]]:
150
+ """List all configured MCP servers with connection status.
151
+
152
+ Returns:
153
+ List of dicts with server name, type, enabled, and connected status.
154
+ """
155
+ from ot.config import get_config
156
+
157
+ config = get_config()
158
+ proxy = get_proxy_manager()
159
+
160
+ servers = []
161
+ for name, cfg in (config.servers or {}).items():
162
+ servers.append(
163
+ {
164
+ "name": name,
165
+ "type": cfg.type,
166
+ "enabled": cfg.enabled,
167
+ "connected": name in proxy.servers,
168
+ }
169
+ )
170
+ return servers
171
+
172
+ def list_tools(self, server: str) -> list[dict[str, str]]:
173
+ """List tools available on a proxied MCP server.
174
+
175
+ Args:
176
+ server: Name of the MCP server.
177
+
178
+ Returns:
179
+ List of dicts with tool name and description.
180
+
181
+ Raises:
182
+ ValueError: If server is not connected.
183
+ """
184
+ proxy = get_proxy_manager()
185
+
186
+ if server not in proxy.servers:
187
+ available = ", ".join(proxy.servers) or "none"
188
+ raise ValueError(
189
+ f"Server '{server}' not connected. Available: {available}"
190
+ )
191
+
192
+ tools = proxy.list_tools(server)
193
+ return [{"name": t.name, "description": t.description} for t in tools]
194
+
195
+ return ProxyIntrospectionPack()
196
+
197
+
198
+ # Cache for execution namespace: key=(registry_id, frozenset of proxy servers)
199
+ # Uses OrderedDict for proper LRU eviction
200
+ _NAMESPACE_CACHE_MAXSIZE = 10
201
+ _namespace_cache: OrderedDict[tuple[int, frozenset[str]], dict[str, Any]] = (
202
+ OrderedDict()
203
+ )
204
+
205
+
206
+ def build_execution_namespace(
207
+ registry: LoadedTools,
208
+ ) -> dict[str, Any]:
209
+ """Build execution namespace with pack proxies for dot notation access.
210
+
211
+ Results are cached based on registry identity and proxy server configuration.
212
+ Cache is invalidated when registry changes or proxy servers are added/removed.
213
+
214
+ Provides dot notation access to tools:
215
+ - brave.web_search(query="test") # pack access
216
+ - context7.resolve_library_id() # MCP proxy access
217
+
218
+ Args:
219
+ registry: LoadedTools registry with functions and packs
220
+
221
+ Returns:
222
+ Dict suitable for use as exec() globals
223
+ """
224
+ from ot.executor.worker_proxy import WorkerPackProxy
225
+ from ot.proxy import get_proxy_manager
226
+
227
+ # Check cache - key is registry identity + current proxy servers
228
+ proxy_mgr = get_proxy_manager()
229
+ cache_key = (id(registry), frozenset(proxy_mgr.servers))
230
+
231
+ if cache_key in _namespace_cache:
232
+ # LRU: move to end on access
233
+ _namespace_cache.move_to_end(cache_key)
234
+ return _namespace_cache[cache_key]
235
+
236
+ namespace: dict[str, Any] = {}
237
+
238
+ # Add pack proxies for dot notation
239
+ for pack_name, pack_funcs in registry.packs.items():
240
+ if isinstance(pack_funcs, WorkerPackProxy):
241
+ # Extension tools already have a proxy - use directly
242
+ namespace[pack_name] = pack_funcs
243
+ else:
244
+ namespace[pack_name] = _create_pack_proxy(pack_name, pack_funcs)
245
+
246
+ # Add MCP proxy packs (only if not already defined locally)
247
+ for server_name in proxy_mgr.servers:
248
+ if server_name not in namespace:
249
+ namespace[server_name] = _create_mcp_proxy_pack(server_name)
250
+
251
+ # Add proxy introspection pack (always available)
252
+ if "proxy" not in namespace:
253
+ namespace["proxy"] = _create_proxy_introspection_pack()
254
+
255
+ # Cache result with LRU eviction
256
+ _namespace_cache[cache_key] = namespace
257
+ while len(_namespace_cache) > _NAMESPACE_CACHE_MAXSIZE:
258
+ _namespace_cache.popitem(last=False)
259
+
260
+ return namespace
@@ -0,0 +1,140 @@
1
+ """Parameter name prefix matching for tool calls.
2
+
3
+ Resolves abbreviated parameter names to full parameter names using prefix matching.
4
+ For example: q= -> query=, c= -> count=
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from collections import OrderedDict
10
+ from collections.abc import Sequence
11
+ from functools import lru_cache
12
+
13
+
14
+ @lru_cache(maxsize=256)
15
+ def get_tool_param_names(tool_name: str) -> tuple[str, ...]:
16
+ """Get parameter names for a tool from the registry (cached).
17
+
18
+ Args:
19
+ tool_name: Full tool name (e.g., "brave.web_search").
20
+
21
+ Returns:
22
+ Tuple of parameter names in signature order, or empty tuple if not found.
23
+ """
24
+ from ot.registry import get_registry
25
+
26
+ registry = get_registry()
27
+ tool_info = registry.get_tool(tool_name)
28
+ if tool_info:
29
+ return tuple(arg.name for arg in tool_info.args)
30
+ return ()
31
+
32
+
33
+ # Cache for MCP tool param names: (server_name, tool_name) -> param_names
34
+ # Uses OrderedDict for LRU eviction with bounded size
35
+ _MCP_PARAM_CACHE_MAXSIZE = 256
36
+ _mcp_param_cache: OrderedDict[tuple[str, str], tuple[str, ...]] = OrderedDict()
37
+
38
+
39
+ def get_mcp_tool_param_names(server_name: str, tool_name: str) -> tuple[str, ...]:
40
+ """Get parameter names for an MCP tool from its input schema (cached).
41
+
42
+ Args:
43
+ server_name: Name of the MCP server.
44
+ tool_name: Name of the tool.
45
+
46
+ Returns:
47
+ Tuple of parameter names, or empty tuple if not found.
48
+ """
49
+ cache_key = (server_name, tool_name)
50
+ if cache_key in _mcp_param_cache:
51
+ _mcp_param_cache.move_to_end(cache_key)
52
+ return _mcp_param_cache[cache_key]
53
+
54
+ from ot.proxy import get_proxy_manager
55
+
56
+ proxy = get_proxy_manager()
57
+ tools = proxy.list_tools(server_name)
58
+ result: tuple[str, ...] = ()
59
+ for tool in tools:
60
+ if tool.name == tool_name:
61
+ result = tuple(get_param_names_from_schema(tool.input_schema))
62
+ break
63
+
64
+ _mcp_param_cache[cache_key] = result
65
+ while len(_mcp_param_cache) > _MCP_PARAM_CACHE_MAXSIZE:
66
+ _mcp_param_cache.popitem(last=False)
67
+ return result
68
+
69
+
70
+ def resolve_kwargs(
71
+ kwargs: dict[str, object], param_names: Sequence[str]
72
+ ) -> dict[str, object]:
73
+ """Resolve abbreviated parameter names to full parameter names.
74
+
75
+ Matching rules:
76
+ 1. Exact match wins - if param name matches exactly, use it
77
+ 2. Prefix match - find all params that start with the abbreviated name
78
+ 3. First match wins - if multiple params match, use first in param_names order
79
+
80
+ Args:
81
+ kwargs: Dictionary of parameter names to values.
82
+ param_names: Sequence of valid parameter names in signature order.
83
+
84
+ Returns:
85
+ New dictionary with resolved parameter names.
86
+
87
+ Examples:
88
+ >>> resolve_kwargs({"q": "test"}, ["query", "count"])
89
+ {"query": "test"}
90
+
91
+ >>> resolve_kwargs({"query": "test"}, ["query", "count"])
92
+ {"query": "test"} # exact match
93
+
94
+ >>> resolve_kwargs({"q": "x"}, ["query_info", "query", "quality"])
95
+ {"query_info": "x"} # first prefix match
96
+
97
+ >>> resolve_kwargs({"xyz": "test"}, ["query"])
98
+ {"xyz": "test"} # no match, passthrough
99
+ """
100
+ if not kwargs or not param_names:
101
+ return kwargs
102
+
103
+ param_set = set(param_names)
104
+ resolved: dict[str, object] = {}
105
+
106
+ for key, value in kwargs.items():
107
+ # Exact match - use as-is
108
+ if key in param_set:
109
+ resolved[key] = value
110
+ continue
111
+
112
+ # Find prefix matches (preserve signature order)
113
+ matches = [p for p in param_names if p.startswith(key)]
114
+
115
+ if len(matches) == 1:
116
+ # Single match - use it
117
+ resolved[matches[0]] = value
118
+ elif len(matches) > 1:
119
+ # Multiple matches - use first in signature order
120
+ resolved[matches[0]] = value
121
+ else:
122
+ # No match - passthrough (let function raise its own error)
123
+ resolved[key] = value
124
+
125
+ return resolved
126
+
127
+
128
+ def get_param_names_from_schema(input_schema: dict[str, object]) -> list[str]:
129
+ """Extract parameter names from a JSON schema.
130
+
131
+ Args:
132
+ input_schema: JSON schema dict with "properties" key.
133
+
134
+ Returns:
135
+ List of parameter names in schema order.
136
+ """
137
+ properties = input_schema.get("properties", {})
138
+ if isinstance(properties, dict):
139
+ return list(properties.keys())
140
+ return []