hud-python 0.4.1__py3-none-any.whl → 0.4.3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of hud-python might be problematic. Click here for more details.

Files changed (130) hide show
  1. hud/__init__.py +22 -22
  2. hud/agents/__init__.py +13 -15
  3. hud/agents/base.py +599 -599
  4. hud/agents/claude.py +373 -373
  5. hud/agents/langchain.py +261 -250
  6. hud/agents/misc/__init__.py +7 -7
  7. hud/agents/misc/response_agent.py +82 -80
  8. hud/agents/openai.py +352 -352
  9. hud/agents/openai_chat_generic.py +154 -154
  10. hud/agents/tests/__init__.py +1 -1
  11. hud/agents/tests/test_base.py +742 -742
  12. hud/agents/tests/test_claude.py +324 -324
  13. hud/agents/tests/test_client.py +363 -363
  14. hud/agents/tests/test_openai.py +237 -237
  15. hud/cli/__init__.py +617 -617
  16. hud/cli/__main__.py +8 -8
  17. hud/cli/analyze.py +371 -371
  18. hud/cli/analyze_metadata.py +230 -230
  19. hud/cli/build.py +498 -427
  20. hud/cli/clone.py +185 -185
  21. hud/cli/cursor.py +92 -92
  22. hud/cli/debug.py +392 -392
  23. hud/cli/docker_utils.py +83 -83
  24. hud/cli/init.py +280 -281
  25. hud/cli/interactive.py +353 -353
  26. hud/cli/mcp_server.py +764 -756
  27. hud/cli/pull.py +330 -336
  28. hud/cli/push.py +404 -370
  29. hud/cli/remote_runner.py +311 -311
  30. hud/cli/runner.py +160 -160
  31. hud/cli/tests/__init__.py +3 -3
  32. hud/cli/tests/test_analyze.py +284 -284
  33. hud/cli/tests/test_cli_init.py +265 -265
  34. hud/cli/tests/test_cli_main.py +27 -27
  35. hud/cli/tests/test_clone.py +142 -142
  36. hud/cli/tests/test_cursor.py +253 -253
  37. hud/cli/tests/test_debug.py +453 -453
  38. hud/cli/tests/test_mcp_server.py +139 -139
  39. hud/cli/tests/test_utils.py +388 -388
  40. hud/cli/utils.py +263 -263
  41. hud/clients/README.md +143 -143
  42. hud/clients/__init__.py +16 -16
  43. hud/clients/base.py +378 -379
  44. hud/clients/fastmcp.py +222 -222
  45. hud/clients/mcp_use.py +298 -278
  46. hud/clients/tests/__init__.py +1 -1
  47. hud/clients/tests/test_client_integration.py +111 -111
  48. hud/clients/tests/test_fastmcp.py +342 -342
  49. hud/clients/tests/test_protocol.py +188 -188
  50. hud/clients/utils/__init__.py +1 -1
  51. hud/clients/utils/retry_transport.py +160 -160
  52. hud/datasets.py +327 -322
  53. hud/misc/__init__.py +1 -1
  54. hud/misc/claude_plays_pokemon.py +292 -292
  55. hud/otel/__init__.py +35 -35
  56. hud/otel/collector.py +142 -142
  57. hud/otel/config.py +164 -164
  58. hud/otel/context.py +536 -536
  59. hud/otel/exporters.py +366 -366
  60. hud/otel/instrumentation.py +97 -97
  61. hud/otel/processors.py +118 -118
  62. hud/otel/tests/__init__.py +1 -1
  63. hud/otel/tests/test_processors.py +197 -197
  64. hud/server/__init__.py +5 -5
  65. hud/server/context.py +114 -114
  66. hud/server/helper/__init__.py +5 -5
  67. hud/server/low_level.py +132 -132
  68. hud/server/server.py +170 -166
  69. hud/server/tests/__init__.py +3 -3
  70. hud/settings.py +73 -73
  71. hud/shared/__init__.py +5 -5
  72. hud/shared/exceptions.py +180 -180
  73. hud/shared/requests.py +264 -264
  74. hud/shared/tests/test_exceptions.py +157 -157
  75. hud/shared/tests/test_requests.py +275 -275
  76. hud/telemetry/__init__.py +25 -25
  77. hud/telemetry/instrument.py +379 -379
  78. hud/telemetry/job.py +309 -309
  79. hud/telemetry/replay.py +74 -74
  80. hud/telemetry/trace.py +83 -83
  81. hud/tools/__init__.py +33 -33
  82. hud/tools/base.py +365 -365
  83. hud/tools/bash.py +161 -161
  84. hud/tools/computer/__init__.py +15 -15
  85. hud/tools/computer/anthropic.py +437 -437
  86. hud/tools/computer/hud.py +376 -376
  87. hud/tools/computer/openai.py +295 -295
  88. hud/tools/computer/settings.py +82 -82
  89. hud/tools/edit.py +314 -314
  90. hud/tools/executors/__init__.py +30 -30
  91. hud/tools/executors/base.py +539 -539
  92. hud/tools/executors/pyautogui.py +621 -621
  93. hud/tools/executors/tests/__init__.py +1 -1
  94. hud/tools/executors/tests/test_base_executor.py +338 -338
  95. hud/tools/executors/tests/test_pyautogui_executor.py +165 -165
  96. hud/tools/executors/xdo.py +511 -511
  97. hud/tools/playwright.py +412 -412
  98. hud/tools/tests/__init__.py +3 -3
  99. hud/tools/tests/test_base.py +282 -282
  100. hud/tools/tests/test_bash.py +158 -158
  101. hud/tools/tests/test_bash_extended.py +197 -197
  102. hud/tools/tests/test_computer.py +425 -425
  103. hud/tools/tests/test_computer_actions.py +34 -34
  104. hud/tools/tests/test_edit.py +259 -259
  105. hud/tools/tests/test_init.py +27 -27
  106. hud/tools/tests/test_playwright_tool.py +183 -183
  107. hud/tools/tests/test_tools.py +145 -145
  108. hud/tools/tests/test_utils.py +156 -156
  109. hud/tools/types.py +72 -72
  110. hud/tools/utils.py +50 -50
  111. hud/types.py +136 -136
  112. hud/utils/__init__.py +10 -10
  113. hud/utils/async_utils.py +65 -65
  114. hud/utils/design.py +236 -168
  115. hud/utils/mcp.py +55 -55
  116. hud/utils/progress.py +149 -149
  117. hud/utils/telemetry.py +66 -66
  118. hud/utils/tests/test_async_utils.py +173 -173
  119. hud/utils/tests/test_init.py +17 -17
  120. hud/utils/tests/test_progress.py +261 -261
  121. hud/utils/tests/test_telemetry.py +82 -82
  122. hud/utils/tests/test_version.py +8 -8
  123. hud/version.py +7 -7
  124. {hud_python-0.4.1.dist-info → hud_python-0.4.3.dist-info}/METADATA +10 -8
  125. hud_python-0.4.3.dist-info/RECORD +131 -0
  126. {hud_python-0.4.1.dist-info → hud_python-0.4.3.dist-info}/licenses/LICENSE +21 -21
  127. hud/agents/art.py +0 -101
  128. hud_python-0.4.1.dist-info/RECORD +0 -132
  129. {hud_python-0.4.1.dist-info → hud_python-0.4.3.dist-info}/WHEEL +0 -0
  130. {hud_python-0.4.1.dist-info → hud_python-0.4.3.dist-info}/entry_points.txt +0 -0
hud/tools/base.py CHANGED
@@ -1,365 +1,365 @@
1
- from __future__ import annotations
2
-
3
- from abc import ABC, abstractmethod
4
- from typing import TYPE_CHECKING, Any, cast
5
-
6
- from fastmcp import FastMCP
7
-
8
- from hud.tools.types import ContentBlock, EvaluationResult
9
-
10
- if TYPE_CHECKING:
11
- from collections.abc import Callable
12
-
13
- from fastmcp.tools import FunctionTool
14
- from fastmcp.tools.tool import Tool, ToolResult
15
-
16
- # Basic result types for tools
17
- BaseResult = list[ContentBlock] | EvaluationResult
18
-
19
-
20
- class BaseTool(ABC):
21
- """
22
- Base helper class for all MCP tools to constrain their output.
23
-
24
- USAGE:
25
- All tools should inherit from this class and implement the __call__ method.
26
- Tools are registered with FastMCP using add_tool.
27
-
28
- FORMAT:
29
- Tools that return messages should return a list[ContentBlock].
30
- Tools that return miscallaneous content should return a pydantic model such as EvaluationResult.
31
- Both of these types of tools are processed via structuredContent.
32
- Any other type of tool will not be processed well by the client.
33
- """
34
-
35
- def __init__(
36
- self,
37
- env: Any = None,
38
- name: str | None = None,
39
- title: str | None = None,
40
- description: str | None = None,
41
- ) -> None:
42
- """Initialize the tool.
43
-
44
- Args:
45
- env: Optional, often stateful, context object that the tool operates on. Could be:
46
- - A game instance (e.g., Chess Board)
47
- - An executor (e.g., PyAutoGUIExecutor for computer control)
48
- - A browser/page instance (e.g., Playwright Page)
49
- - Any stateful resource the tool needs to interact with
50
- name: Tool name for MCP registration (auto-generated from class name if not provided)
51
- title: Human-readable display name for the tool (auto-generated from class name)
52
- description: Tool description (auto-generated from docstring if not provided)
53
- """
54
- self.env = env
55
- self.name = name or self.__class__.__name__.lower().replace("tool", "")
56
- self.title = title or self.__class__.__name__.replace("Tool", "").replace("_", " ").title()
57
- self.description = description or (self.__doc__.strip() if self.__doc__ else None)
58
-
59
- # Expose attributes FastMCP expects when registering an instance directly
60
- self.__name__ = self.name # FastMCP uses fn.__name__ if name param omitted
61
- if self.description:
62
- self.__doc__ = self.description
63
-
64
- @abstractmethod
65
- async def __call__(self, **kwargs: Any) -> ToolResult:
66
- """Execute the tool. Often uses the context to perform an action.
67
-
68
- Args:
69
- **kwargs: Tool-specific arguments
70
-
71
- Returns:
72
- List of ContentBlock (TextContent, ImageContent, etc.) with the tool's output
73
- """
74
- raise NotImplementedError("Subclasses must implement __call__")
75
-
76
- def register(self, server: FastMCP, **meta: Any) -> BaseTool:
77
- """Register this tool on a FastMCP server and return self for chaining."""
78
- server.add_tool(self.mcp, **meta)
79
- return self
80
-
81
- @property
82
- def mcp(self) -> FunctionTool:
83
- """Get this tool as a FastMCP FunctionTool (cached).
84
-
85
- This allows clean registration:
86
- server.add_tool(my_tool.mcp)
87
- """
88
- if not hasattr(self, "_mcp_tool"):
89
- from fastmcp.tools import FunctionTool
90
-
91
- self._mcp_tool = FunctionTool.from_function(
92
- self,
93
- name=self.name,
94
- title=self.title,
95
- description=self.description,
96
- )
97
- return self._mcp_tool
98
-
99
-
100
- # Prefix for internal tool names
101
- _INTERNAL_PREFIX = "int_"
102
-
103
-
104
- class BaseHub(FastMCP):
105
- """A composition-friendly FastMCP server that holds an internal tool dispatcher."""
106
-
107
- env: Any
108
-
109
- def __init__(
110
- self,
111
- name: str,
112
- *,
113
- env: Any | None = None,
114
- title: str | None = None,
115
- description: str | None = None,
116
- ) -> None:
117
- """Create a new BaseHub.
118
-
119
- Parameters
120
- ----------
121
- name:
122
- Public name. Also becomes the *dispatcher tool* name.
123
- env:
124
- Optional long-lived environment object. Stored on the server
125
- instance (``layer.env``) and therefore available to every request
126
- via ``ctx.fastmcp.env``.
127
- """
128
-
129
- # Naming scheme for hidden objects
130
- self._prefix_fn: Callable[[str], str] = lambda n: f"{_INTERNAL_PREFIX}{n}"
131
-
132
- super().__init__(name=name)
133
-
134
- if env is not None:
135
- self.env = env
136
-
137
- dispatcher_title = title or f"{name.title()} Dispatcher"
138
- dispatcher_desc = description or f"Call internal '{name}' functions"
139
-
140
- # Register dispatcher manually with FunctionTool
141
- async def _dispatch( # noqa: ANN202
142
- name: str,
143
- arguments: dict | str | None = None,
144
- ctx=None, # noqa: ANN001
145
- ):
146
- """Gateway to hidden tools.
147
-
148
- Parameters
149
- ----------
150
- name : str
151
- Internal function name *without* prefix.
152
- arguments : dict | str | None
153
- Arguments forwarded to the internal tool. Can be dict or JSON string.
154
- ctx : Context
155
- Injected by FastMCP; can be the custom subclass.
156
- """
157
-
158
- # Handle JSON string inputs
159
- if isinstance(arguments, str):
160
- import json
161
-
162
- try:
163
- arguments = json.loads(arguments)
164
- except json.JSONDecodeError:
165
- # If it's not valid JSON, treat as empty dict
166
- arguments = {}
167
-
168
- # Use the tool manager to call internal tools
169
- return await self._tool_manager.call_tool(self._prefix_fn(name), arguments or {}) # type: ignore
170
-
171
- from fastmcp.tools.tool import FunctionTool
172
-
173
- dispatcher_tool = FunctionTool.from_function(
174
- _dispatch,
175
- name=name,
176
- title=dispatcher_title,
177
- description=dispatcher_desc,
178
- tags=set(),
179
- )
180
- self._tool_manager.add_tool(dispatcher_tool)
181
-
182
- # Expose list of internal functions via read-only resource
183
- async def _functions_catalogue() -> list[str]:
184
- # List all internal function names without prefix
185
- return [
186
- key.removeprefix(_INTERNAL_PREFIX)
187
- for key in self._tool_manager._tools
188
- if key.startswith(_INTERNAL_PREFIX)
189
- ]
190
-
191
- from fastmcp.resources import Resource
192
-
193
- catalogue_resource = Resource.from_function(
194
- _functions_catalogue,
195
- uri=f"file:///{name}/functions",
196
- name=f"{name} Functions Catalogue",
197
- description=f"List of internal functions available in {name}",
198
- mime_type="application/json",
199
- tags=set(),
200
- )
201
- self._resource_manager.add_resource(catalogue_resource)
202
-
203
- def tool(self, name_or_fn: Any = None, /, **kwargs: Any) -> Callable[..., Any]:
204
- """Register an *internal* tool (hidden from clients)."""
205
- # Handle when decorator's partial calls us back with the function
206
- if callable(name_or_fn):
207
- # This only happens in phase 2 of decorator application
208
- # The name was already prefixed in phase 1, just pass through
209
- result = super().tool(name_or_fn, **kwargs)
210
-
211
- # Update dispatcher description after registering tool
212
- self._update_dispatcher_description()
213
-
214
- return cast("Callable[..., Any]", result)
215
-
216
- # Handle the name from either positional or keyword argument
217
- if isinstance(name_or_fn, str):
218
- # Called as @hub.tool("name")
219
- name = name_or_fn
220
- elif name_or_fn is None and "name" in kwargs:
221
- # Called as @hub.tool(name="name")
222
- name = kwargs.pop("name")
223
- else:
224
- # Called as @hub.tool or @hub.tool()
225
- name = None
226
-
227
- new_name = self._prefix_fn(name) if name is not None else None
228
- tags = kwargs.pop("tags", None) or set()
229
-
230
- # Pass through correctly to parent
231
- if new_name is not None:
232
- return super().tool(new_name, **kwargs, tags=tags)
233
- else:
234
- return super().tool(**kwargs, tags=tags)
235
-
236
- def _update_dispatcher_description(self) -> None:
237
- """Update the dispatcher tool's description and schema with available tools."""
238
- # Get list of internal tools with their details
239
- internal_tools = []
240
- for key, tool in self._tool_manager._tools.items():
241
- if key.startswith(_INTERNAL_PREFIX):
242
- tool_name = key.removeprefix(_INTERNAL_PREFIX)
243
- internal_tools.append((tool_name, tool))
244
-
245
- if internal_tools:
246
- # Update the dispatcher tool's description
247
- dispatcher_name = self.name
248
- if dispatcher_name in self._tool_manager._tools:
249
- dispatcher_tool = self._tool_manager._tools[dispatcher_name]
250
-
251
- # Build detailed description
252
- desc_lines = [f"Call internal '{self.name}' functions. Available tools:"]
253
- desc_lines.append("") # Empty line for readability
254
-
255
- # Build tool schemas for oneOf
256
- tool_schemas = []
257
-
258
- for tool_name, tool in sorted(internal_tools):
259
- # Add tool name and description
260
- tool_desc = tool.description or "No description"
261
- desc_lines.append(f"• Name: {tool_name} ({tool_desc})")
262
-
263
- # Build schema for this specific tool call
264
- tool_schema = {
265
- "type": "object",
266
- "properties": {
267
- "name": {
268
- "type": "string",
269
- "const": tool_name,
270
- "description": f"Must be '{tool_name}'",
271
- },
272
- "arguments": tool.parameters
273
- if hasattr(tool, "parameters") and tool.parameters
274
- else {"type": "object"},
275
- },
276
- "required": ["name", "arguments"],
277
- "additionalProperties": False,
278
- }
279
- tool_schemas.append(tool_schema)
280
-
281
- # Add parameters from the tool's parameters field (JSON schema)
282
- if hasattr(tool, "parameters") and tool.parameters:
283
- schema = tool.parameters
284
- if isinstance(schema, dict) and "properties" in schema:
285
- params = []
286
- required = schema.get("required", [])
287
- for prop_name, prop_info in schema["properties"].items():
288
- prop_type = prop_info.get("type", "any")
289
- # Check for more detailed type info
290
- if "anyOf" in prop_info:
291
- types = [
292
- t.get("type", "unknown")
293
- for t in prop_info["anyOf"]
294
- if isinstance(t, dict)
295
- ]
296
- prop_type = " | ".join(types) if types else "any"
297
-
298
- param_str = f"{prop_name} ({prop_type})"
299
- if prop_name not in required:
300
- param_str += " (optional)"
301
- params.append(param_str)
302
-
303
- if params:
304
- desc_lines.append(f" Arguments: {', '.join(params)}")
305
- else:
306
- desc_lines.append(" Arguments: none")
307
- else:
308
- desc_lines.append(" Arguments: none")
309
-
310
- desc_lines.append("") # Empty line between tools
311
-
312
- dispatcher_tool.description = "\n".join(desc_lines).strip()
313
-
314
- # Update the input schema to better document available tools
315
- # Build examples of tool calls
316
- examples = []
317
- for tool_name, tool in sorted(internal_tools)[:3]: # Show first 3 as examples
318
- if hasattr(tool, "parameters") and tool.parameters:
319
- schema = tool.parameters
320
- if isinstance(schema, dict) and "properties" in schema:
321
- example_args = {}
322
- for prop_name, prop_info in schema["properties"].items():
323
- # Generate example value based on type
324
- prop_type = prop_info.get("type", "any")
325
- if prop_type == "string":
326
- example_args[prop_name] = f"<{prop_name}>"
327
- elif prop_type == "integer" or prop_type == "number":
328
- example_args[prop_name] = 0
329
- elif prop_type == "boolean":
330
- example_args[prop_name] = True
331
- else:
332
- example_args[prop_name] = None
333
- examples.append({"name": tool_name, "arguments": example_args})
334
- else:
335
- examples.append({"name": tool_name, "arguments": {}})
336
-
337
- # Enhanced schema with better documentation
338
- dispatcher_tool.parameters = {
339
- "type": "object",
340
- "properties": {
341
- "name": {
342
- "type": "string",
343
- "description": f"Name of the internal tool to call. Must be one of: {', '.join(t[0] for t in sorted(internal_tools))}", # noqa: E501
344
- "enum": [t[0] for t in sorted(internal_tools)],
345
- },
346
- "arguments": {
347
- "type": "object",
348
- "description": "Arguments to pass to the internal tool. See description for details on each tool's parameters.", # noqa: E501
349
- },
350
- },
351
- "required": ["name", "arguments"],
352
- "examples": examples if examples else None,
353
- }
354
-
355
- # Override _list_tools to hide internal tools when mounted
356
- async def _list_tools(self) -> list[Tool]:
357
- """Override _list_tools to hide internal tools when mounted."""
358
- return [
359
- tool
360
- for key, tool in self._tool_manager._tools.items()
361
- if not key.startswith(_INTERNAL_PREFIX)
362
- ]
363
-
364
- resource = FastMCP.resource
365
- prompt = FastMCP.prompt
1
+ from __future__ import annotations
2
+
3
+ from abc import ABC, abstractmethod
4
+ from typing import TYPE_CHECKING, Any, cast
5
+
6
+ from fastmcp import FastMCP
7
+
8
+ from hud.tools.types import ContentBlock, EvaluationResult
9
+
10
+ if TYPE_CHECKING:
11
+ from collections.abc import Callable
12
+
13
+ from fastmcp.tools import FunctionTool
14
+ from fastmcp.tools.tool import Tool, ToolResult
15
+
16
+ # Basic result types for tools
17
+ BaseResult = list[ContentBlock] | EvaluationResult
18
+
19
+
20
+ class BaseTool(ABC):
21
+ """
22
+ Base helper class for all MCP tools to constrain their output.
23
+
24
+ USAGE:
25
+ All tools should inherit from this class and implement the __call__ method.
26
+ Tools are registered with FastMCP using add_tool.
27
+
28
+ FORMAT:
29
+ Tools that return messages should return a list[ContentBlock].
30
+ Tools that return miscallaneous content should return a pydantic model such as EvaluationResult.
31
+ Both of these types of tools are processed via structuredContent.
32
+ Any other type of tool will not be processed well by the client.
33
+ """
34
+
35
+ def __init__(
36
+ self,
37
+ env: Any = None,
38
+ name: str | None = None,
39
+ title: str | None = None,
40
+ description: str | None = None,
41
+ ) -> None:
42
+ """Initialize the tool.
43
+
44
+ Args:
45
+ env: Optional, often stateful, context object that the tool operates on. Could be:
46
+ - A game instance (e.g., Chess Board)
47
+ - An executor (e.g., PyAutoGUIExecutor for computer control)
48
+ - A browser/page instance (e.g., Playwright Page)
49
+ - Any stateful resource the tool needs to interact with
50
+ name: Tool name for MCP registration (auto-generated from class name if not provided)
51
+ title: Human-readable display name for the tool (auto-generated from class name)
52
+ description: Tool description (auto-generated from docstring if not provided)
53
+ """
54
+ self.env = env
55
+ self.name = name or self.__class__.__name__.lower().replace("tool", "")
56
+ self.title = title or self.__class__.__name__.replace("Tool", "").replace("_", " ").title()
57
+ self.description = description or (self.__doc__.strip() if self.__doc__ else None)
58
+
59
+ # Expose attributes FastMCP expects when registering an instance directly
60
+ self.__name__ = self.name # FastMCP uses fn.__name__ if name param omitted
61
+ if self.description:
62
+ self.__doc__ = self.description
63
+
64
+ @abstractmethod
65
+ async def __call__(self, **kwargs: Any) -> ToolResult:
66
+ """Execute the tool. Often uses the context to perform an action.
67
+
68
+ Args:
69
+ **kwargs: Tool-specific arguments
70
+
71
+ Returns:
72
+ List of ContentBlock (TextContent, ImageContent, etc.) with the tool's output
73
+ """
74
+ raise NotImplementedError("Subclasses must implement __call__")
75
+
76
+ def register(self, server: FastMCP, **meta: Any) -> BaseTool:
77
+ """Register this tool on a FastMCP server and return self for chaining."""
78
+ server.add_tool(self.mcp, **meta)
79
+ return self
80
+
81
+ @property
82
+ def mcp(self) -> FunctionTool:
83
+ """Get this tool as a FastMCP FunctionTool (cached).
84
+
85
+ This allows clean registration:
86
+ server.add_tool(my_tool.mcp)
87
+ """
88
+ if not hasattr(self, "_mcp_tool"):
89
+ from fastmcp.tools import FunctionTool
90
+
91
+ self._mcp_tool = FunctionTool.from_function(
92
+ self,
93
+ name=self.name,
94
+ title=self.title,
95
+ description=self.description,
96
+ )
97
+ return self._mcp_tool
98
+
99
+
100
+ # Prefix for internal tool names
101
+ _INTERNAL_PREFIX = "int_"
102
+
103
+
104
+ class BaseHub(FastMCP):
105
+ """A composition-friendly FastMCP server that holds an internal tool dispatcher."""
106
+
107
+ env: Any
108
+
109
+ def __init__(
110
+ self,
111
+ name: str,
112
+ *,
113
+ env: Any | None = None,
114
+ title: str | None = None,
115
+ description: str | None = None,
116
+ ) -> None:
117
+ """Create a new BaseHub.
118
+
119
+ Parameters
120
+ ----------
121
+ name:
122
+ Public name. Also becomes the *dispatcher tool* name.
123
+ env:
124
+ Optional long-lived environment object. Stored on the server
125
+ instance (``layer.env``) and therefore available to every request
126
+ via ``ctx.fastmcp.env``.
127
+ """
128
+
129
+ # Naming scheme for hidden objects
130
+ self._prefix_fn: Callable[[str], str] = lambda n: f"{_INTERNAL_PREFIX}{n}"
131
+
132
+ super().__init__(name=name)
133
+
134
+ if env is not None:
135
+ self.env = env
136
+
137
+ dispatcher_title = title or f"{name.title()} Dispatcher"
138
+ dispatcher_desc = description or f"Call internal '{name}' functions"
139
+
140
+ # Register dispatcher manually with FunctionTool
141
+ async def _dispatch( # noqa: ANN202
142
+ name: str,
143
+ arguments: dict | str | None = None,
144
+ ctx=None, # noqa: ANN001
145
+ ):
146
+ """Gateway to hidden tools.
147
+
148
+ Parameters
149
+ ----------
150
+ name : str
151
+ Internal function name *without* prefix.
152
+ arguments : dict | str | None
153
+ Arguments forwarded to the internal tool. Can be dict or JSON string.
154
+ ctx : Context
155
+ Injected by FastMCP; can be the custom subclass.
156
+ """
157
+
158
+ # Handle JSON string inputs
159
+ if isinstance(arguments, str):
160
+ import json
161
+
162
+ try:
163
+ arguments = json.loads(arguments)
164
+ except json.JSONDecodeError:
165
+ # If it's not valid JSON, treat as empty dict
166
+ arguments = {}
167
+
168
+ # Use the tool manager to call internal tools
169
+ return await self._tool_manager.call_tool(self._prefix_fn(name), arguments or {}) # type: ignore
170
+
171
+ from fastmcp.tools.tool import FunctionTool
172
+
173
+ dispatcher_tool = FunctionTool.from_function(
174
+ _dispatch,
175
+ name=name,
176
+ title=dispatcher_title,
177
+ description=dispatcher_desc,
178
+ tags=set(),
179
+ )
180
+ self._tool_manager.add_tool(dispatcher_tool)
181
+
182
+ # Expose list of internal functions via read-only resource
183
+ async def _functions_catalogue() -> list[str]:
184
+ # List all internal function names without prefix
185
+ return [
186
+ key.removeprefix(_INTERNAL_PREFIX)
187
+ for key in self._tool_manager._tools
188
+ if key.startswith(_INTERNAL_PREFIX)
189
+ ]
190
+
191
+ from fastmcp.resources import Resource
192
+
193
+ catalogue_resource = Resource.from_function(
194
+ _functions_catalogue,
195
+ uri=f"file:///{name}/functions",
196
+ name=f"{name} Functions Catalogue",
197
+ description=f"List of internal functions available in {name}",
198
+ mime_type="application/json",
199
+ tags=set(),
200
+ )
201
+ self._resource_manager.add_resource(catalogue_resource)
202
+
203
+ def tool(self, name_or_fn: Any = None, /, **kwargs: Any) -> Callable[..., Any]:
204
+ """Register an *internal* tool (hidden from clients)."""
205
+ # Handle when decorator's partial calls us back with the function
206
+ if callable(name_or_fn):
207
+ # This only happens in phase 2 of decorator application
208
+ # The name was already prefixed in phase 1, just pass through
209
+ result = super().tool(name_or_fn, **kwargs)
210
+
211
+ # Update dispatcher description after registering tool
212
+ self._update_dispatcher_description()
213
+
214
+ return cast("Callable[..., Any]", result)
215
+
216
+ # Handle the name from either positional or keyword argument
217
+ if isinstance(name_or_fn, str):
218
+ # Called as @hub.tool("name")
219
+ name = name_or_fn
220
+ elif name_or_fn is None and "name" in kwargs:
221
+ # Called as @hub.tool(name="name")
222
+ name = kwargs.pop("name")
223
+ else:
224
+ # Called as @hub.tool or @hub.tool()
225
+ name = None
226
+
227
+ new_name = self._prefix_fn(name) if name is not None else None
228
+ tags = kwargs.pop("tags", None) or set()
229
+
230
+ # Pass through correctly to parent
231
+ if new_name is not None:
232
+ return super().tool(new_name, **kwargs, tags=tags)
233
+ else:
234
+ return super().tool(**kwargs, tags=tags)
235
+
236
+ def _update_dispatcher_description(self) -> None:
237
+ """Update the dispatcher tool's description and schema with available tools."""
238
+ # Get list of internal tools with their details
239
+ internal_tools = []
240
+ for key, tool in self._tool_manager._tools.items():
241
+ if key.startswith(_INTERNAL_PREFIX):
242
+ tool_name = key.removeprefix(_INTERNAL_PREFIX)
243
+ internal_tools.append((tool_name, tool))
244
+
245
+ if internal_tools:
246
+ # Update the dispatcher tool's description
247
+ dispatcher_name = self.name
248
+ if dispatcher_name in self._tool_manager._tools:
249
+ dispatcher_tool = self._tool_manager._tools[dispatcher_name]
250
+
251
+ # Build detailed description
252
+ desc_lines = [f"Call internal '{self.name}' functions. Available tools:"]
253
+ desc_lines.append("") # Empty line for readability
254
+
255
+ # Build tool schemas for oneOf
256
+ tool_schemas = []
257
+
258
+ for tool_name, tool in sorted(internal_tools):
259
+ # Add tool name and description
260
+ tool_desc = tool.description or "No description"
261
+ desc_lines.append(f"• Name: {tool_name} ({tool_desc})")
262
+
263
+ # Build schema for this specific tool call
264
+ tool_schema = {
265
+ "type": "object",
266
+ "properties": {
267
+ "name": {
268
+ "type": "string",
269
+ "const": tool_name,
270
+ "description": f"Must be '{tool_name}'",
271
+ },
272
+ "arguments": tool.parameters
273
+ if hasattr(tool, "parameters") and tool.parameters
274
+ else {"type": "object"},
275
+ },
276
+ "required": ["name", "arguments"],
277
+ "additionalProperties": False,
278
+ }
279
+ tool_schemas.append(tool_schema)
280
+
281
+ # Add parameters from the tool's parameters field (JSON schema)
282
+ if hasattr(tool, "parameters") and tool.parameters:
283
+ schema = tool.parameters
284
+ if isinstance(schema, dict) and "properties" in schema:
285
+ params = []
286
+ required = schema.get("required", [])
287
+ for prop_name, prop_info in schema["properties"].items():
288
+ prop_type = prop_info.get("type", "any")
289
+ # Check for more detailed type info
290
+ if "anyOf" in prop_info:
291
+ types = [
292
+ t.get("type", "unknown")
293
+ for t in prop_info["anyOf"]
294
+ if isinstance(t, dict)
295
+ ]
296
+ prop_type = " | ".join(types) if types else "any"
297
+
298
+ param_str = f"{prop_name} ({prop_type})"
299
+ if prop_name not in required:
300
+ param_str += " (optional)"
301
+ params.append(param_str)
302
+
303
+ if params:
304
+ desc_lines.append(f" Arguments: {', '.join(params)}")
305
+ else:
306
+ desc_lines.append(" Arguments: none")
307
+ else:
308
+ desc_lines.append(" Arguments: none")
309
+
310
+ desc_lines.append("") # Empty line between tools
311
+
312
+ dispatcher_tool.description = "\n".join(desc_lines).strip()
313
+
314
+ # Update the input schema to better document available tools
315
+ # Build examples of tool calls
316
+ examples = []
317
+ for tool_name, tool in sorted(internal_tools)[:3]: # Show first 3 as examples
318
+ if hasattr(tool, "parameters") and tool.parameters:
319
+ schema = tool.parameters
320
+ if isinstance(schema, dict) and "properties" in schema:
321
+ example_args = {}
322
+ for prop_name, prop_info in schema["properties"].items():
323
+ # Generate example value based on type
324
+ prop_type = prop_info.get("type", "any")
325
+ if prop_type == "string":
326
+ example_args[prop_name] = f"<{prop_name}>"
327
+ elif prop_type == "integer" or prop_type == "number":
328
+ example_args[prop_name] = 0
329
+ elif prop_type == "boolean":
330
+ example_args[prop_name] = True
331
+ else:
332
+ example_args[prop_name] = None
333
+ examples.append({"name": tool_name, "arguments": example_args})
334
+ else:
335
+ examples.append({"name": tool_name, "arguments": {}})
336
+
337
+ # Enhanced schema with better documentation
338
+ dispatcher_tool.parameters = {
339
+ "type": "object",
340
+ "properties": {
341
+ "name": {
342
+ "type": "string",
343
+ "description": f"Name of the internal tool to call. Must be one of: {', '.join(t[0] for t in sorted(internal_tools))}", # noqa: E501
344
+ "enum": [t[0] for t in sorted(internal_tools)],
345
+ },
346
+ "arguments": {
347
+ "type": "object",
348
+ "description": "Arguments to pass to the internal tool. See description for details on each tool's parameters.", # noqa: E501
349
+ },
350
+ },
351
+ "required": ["name", "arguments"],
352
+ "examples": examples if examples else None,
353
+ }
354
+
355
+ # Override _list_tools to hide internal tools when mounted
356
+ async def _list_tools(self) -> list[Tool]:
357
+ """Override _list_tools to hide internal tools when mounted."""
358
+ return [
359
+ tool
360
+ for key, tool in self._tool_manager._tools.items()
361
+ if not key.startswith(_INTERNAL_PREFIX)
362
+ ]
363
+
364
+ resource = FastMCP.resource
365
+ prompt = FastMCP.prompt