universal-mcp 0.1.7rc1__py3-none-any.whl → 0.1.8__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 (61) hide show
  1. universal_mcp/__init__.py +0 -2
  2. universal_mcp/analytics.py +75 -0
  3. universal_mcp/applications/ahrefs/README.md +76 -0
  4. universal_mcp/applications/ahrefs/app.py +2291 -0
  5. universal_mcp/applications/application.py +95 -5
  6. universal_mcp/applications/calendly/README.md +78 -0
  7. universal_mcp/applications/calendly/__init__.py +0 -0
  8. universal_mcp/applications/calendly/app.py +1195 -0
  9. universal_mcp/applications/coda/README.md +133 -0
  10. universal_mcp/applications/coda/__init__.py +0 -0
  11. universal_mcp/applications/coda/app.py +3671 -0
  12. universal_mcp/applications/e2b/app.py +14 -28
  13. universal_mcp/applications/figma/README.md +74 -0
  14. universal_mcp/applications/figma/__init__.py +0 -0
  15. universal_mcp/applications/figma/app.py +1261 -0
  16. universal_mcp/applications/firecrawl/app.py +38 -35
  17. universal_mcp/applications/github/app.py +127 -85
  18. universal_mcp/applications/google_calendar/app.py +62 -138
  19. universal_mcp/applications/google_docs/app.py +47 -52
  20. universal_mcp/applications/google_drive/app.py +119 -113
  21. universal_mcp/applications/google_mail/app.py +124 -50
  22. universal_mcp/applications/google_sheet/app.py +89 -91
  23. universal_mcp/applications/markitdown/app.py +9 -8
  24. universal_mcp/applications/notion/app.py +254 -134
  25. universal_mcp/applications/perplexity/app.py +13 -41
  26. universal_mcp/applications/reddit/app.py +94 -85
  27. universal_mcp/applications/resend/app.py +12 -13
  28. universal_mcp/applications/{serp → serpapi}/app.py +14 -25
  29. universal_mcp/applications/tavily/app.py +11 -18
  30. universal_mcp/applications/wrike/README.md +71 -0
  31. universal_mcp/applications/wrike/__init__.py +0 -0
  32. universal_mcp/applications/wrike/app.py +1372 -0
  33. universal_mcp/applications/youtube/README.md +82 -0
  34. universal_mcp/applications/youtube/__init__.py +0 -0
  35. universal_mcp/applications/youtube/app.py +1428 -0
  36. universal_mcp/applications/zenquotes/app.py +12 -2
  37. universal_mcp/exceptions.py +9 -2
  38. universal_mcp/integrations/__init__.py +24 -1
  39. universal_mcp/integrations/agentr.py +27 -4
  40. universal_mcp/integrations/integration.py +146 -32
  41. universal_mcp/logger.py +3 -56
  42. universal_mcp/servers/__init__.py +6 -14
  43. universal_mcp/servers/server.py +201 -146
  44. universal_mcp/stores/__init__.py +7 -2
  45. universal_mcp/stores/store.py +103 -40
  46. universal_mcp/tools/__init__.py +3 -0
  47. universal_mcp/tools/adapters.py +43 -0
  48. universal_mcp/tools/func_metadata.py +213 -0
  49. universal_mcp/tools/tools.py +342 -0
  50. universal_mcp/utils/docgen.py +325 -119
  51. universal_mcp/utils/docstring_parser.py +179 -0
  52. universal_mcp/utils/dump_app_tools.py +33 -23
  53. universal_mcp/utils/installation.py +201 -10
  54. universal_mcp/utils/openapi.py +229 -46
  55. {universal_mcp-0.1.7rc1.dist-info → universal_mcp-0.1.8.dist-info}/METADATA +9 -5
  56. universal_mcp-0.1.8.dist-info/RECORD +81 -0
  57. universal_mcp-0.1.7rc1.dist-info/RECORD +0 -58
  58. /universal_mcp/{utils/bridge.py → applications/ahrefs/__init__.py} +0 -0
  59. /universal_mcp/applications/{serp → serpapi}/README.md +0 -0
  60. {universal_mcp-0.1.7rc1.dist-info → universal_mcp-0.1.8.dist-info}/WHEEL +0 -0
  61. {universal_mcp-0.1.7rc1.dist-info → universal_mcp-0.1.8.dist-info}/entry_points.txt +0 -0
@@ -0,0 +1,3 @@
1
+ from .tools import Tool, ToolManager
2
+
3
+ __all__ = ["Tool", "ToolManager"]
@@ -0,0 +1,43 @@
1
+ from universal_mcp.tools.tools import Tool
2
+
3
+
4
+ def convert_tool_to_mcp_tool(
5
+ tool: Tool,
6
+ ):
7
+ from mcp.server.fastmcp.server import MCPTool
8
+
9
+ return MCPTool(
10
+ name=tool.name,
11
+ description=tool.description or "",
12
+ inputSchema=tool.parameters,
13
+ )
14
+
15
+
16
+ def convert_tool_to_langchain_tool(
17
+ tool: Tool,
18
+ ):
19
+ from langchain_core.tools import StructuredTool
20
+
21
+ """Convert an tool to a LangChain tool.
22
+
23
+ NOTE: this tool can be executed only in a context of an active MCP client session.
24
+
25
+ Args:
26
+ tool: Tool to convert
27
+
28
+ Returns:
29
+ a LangChain tool
30
+ """
31
+
32
+ async def call_tool(
33
+ **arguments: dict[str, any],
34
+ ):
35
+ call_tool_result = await tool.run(arguments)
36
+ return call_tool_result
37
+
38
+ return StructuredTool(
39
+ name=tool.name,
40
+ description=tool.description or "",
41
+ coroutine=call_tool,
42
+ response_format="content",
43
+ )
@@ -0,0 +1,213 @@
1
+ import inspect
2
+ import json
3
+ from collections.abc import Awaitable, Callable, Sequence
4
+ from typing import (
5
+ Annotated,
6
+ Any,
7
+ ForwardRef,
8
+ )
9
+
10
+ from mcp.server.fastmcp.exceptions import InvalidSignature
11
+ from pydantic import BaseModel, ConfigDict, Field, WithJsonSchema, create_model
12
+ from pydantic._internal._typing_extra import eval_type_backport
13
+ from pydantic.fields import FieldInfo
14
+ from pydantic_core import PydanticUndefined
15
+
16
+
17
+ def _get_typed_annotation(annotation: Any, globalns: dict[str, Any]) -> Any:
18
+ def try_eval_type(
19
+ value: Any, globalns: dict[str, Any], localns: dict[str, Any]
20
+ ) -> tuple[Any, bool]:
21
+ try:
22
+ return eval_type_backport(value, globalns, localns), True
23
+ except NameError:
24
+ return value, False
25
+
26
+ if isinstance(annotation, str):
27
+ annotation = ForwardRef(annotation)
28
+ annotation, status = try_eval_type(annotation, globalns, globalns)
29
+
30
+ # This check and raise could perhaps be skipped, and we (FastMCP) just call
31
+ # model_rebuild right before using it 🤷
32
+ if status is False:
33
+ raise InvalidSignature(f"Unable to evaluate type annotation {annotation}")
34
+
35
+ return annotation
36
+
37
+
38
+ def _get_typed_signature(call: Callable[..., Any]) -> inspect.Signature:
39
+ """Get function signature while evaluating forward references"""
40
+ signature = inspect.signature(call)
41
+ globalns = getattr(call, "__globals__", {})
42
+ typed_params = [
43
+ inspect.Parameter(
44
+ name=param.name,
45
+ kind=param.kind,
46
+ default=param.default,
47
+ annotation=_get_typed_annotation(param.annotation, globalns),
48
+ )
49
+ for param in signature.parameters.values()
50
+ ]
51
+ typed_signature = inspect.Signature(typed_params)
52
+ return typed_signature
53
+
54
+
55
+ class ArgModelBase(BaseModel):
56
+ """A model representing the arguments to a function."""
57
+
58
+ def model_dump_one_level(self) -> dict[str, Any]:
59
+ """Return a dict of the model's fields, one level deep.
60
+
61
+ That is, sub-models etc are not dumped - they are kept as pydantic models.
62
+ """
63
+ kwargs: dict[str, Any] = {}
64
+ for field_name in self.model_fields:
65
+ kwargs[field_name] = getattr(self, field_name)
66
+ return kwargs
67
+
68
+ model_config = ConfigDict(
69
+ arbitrary_types_allowed=True,
70
+ )
71
+
72
+
73
+ class FuncMetadata(BaseModel):
74
+ arg_model: Annotated[type[ArgModelBase], WithJsonSchema(None)]
75
+ # We can add things in the future like
76
+ # - Maybe some args are excluded from attempting to parse from JSON
77
+ # - Maybe some args are special (like context) for dependency injection
78
+
79
+ async def call_fn_with_arg_validation(
80
+ self,
81
+ fn: Callable[..., Any] | Awaitable[Any],
82
+ fn_is_async: bool,
83
+ arguments_to_validate: dict[str, Any],
84
+ arguments_to_pass_directly: dict[str, Any] | None,
85
+ ) -> Any:
86
+ """Call the given function with arguments validated and injected.
87
+
88
+ Arguments are first attempted to be parsed from JSON, then validated against
89
+ the argument model, before being passed to the function.
90
+ """
91
+ arguments_pre_parsed = self.pre_parse_json(arguments_to_validate)
92
+ arguments_parsed_model = self.arg_model.model_validate(arguments_pre_parsed)
93
+ arguments_parsed_dict = arguments_parsed_model.model_dump_one_level()
94
+
95
+ arguments_parsed_dict |= arguments_to_pass_directly or {}
96
+
97
+ if fn_is_async:
98
+ if isinstance(fn, Awaitable):
99
+ return await fn
100
+ return await fn(**arguments_parsed_dict)
101
+ if isinstance(fn, Callable):
102
+ return fn(**arguments_parsed_dict)
103
+ raise TypeError("fn must be either Callable or Awaitable")
104
+
105
+ def pre_parse_json(self, data: dict[str, Any]) -> dict[str, Any]:
106
+ """Pre-parse data from JSON.
107
+
108
+ Return a dict with same keys as input but with values parsed from JSON
109
+ if appropriate.
110
+
111
+ This is to handle cases like `["a", "b", "c"]` being passed in as JSON inside
112
+ a string rather than an actual list. Claude desktop is prone to this - in fact
113
+ it seems incapable of NOT doing this. For sub-models, it tends to pass
114
+ dicts (JSON objects) as JSON strings, which can be pre-parsed here.
115
+ """
116
+ new_data = data.copy() # Shallow copy
117
+ for field_name, _field_info in self.arg_model.model_fields.items():
118
+ if field_name not in data:
119
+ continue
120
+ if isinstance(data[field_name], str):
121
+ try:
122
+ pre_parsed = json.loads(data[field_name])
123
+ except json.JSONDecodeError:
124
+ continue # Not JSON - skip
125
+ if isinstance(pre_parsed, str | int | float):
126
+ # This is likely that the raw value is e.g. `"hello"` which we
127
+ # Should really be parsed as '"hello"' in Python - but if we parse
128
+ # it as JSON it'll turn into just 'hello'. So we skip it.
129
+ continue
130
+ new_data[field_name] = pre_parsed
131
+ assert new_data.keys() == data.keys()
132
+ return new_data
133
+
134
+ model_config = ConfigDict(
135
+ arbitrary_types_allowed=True,
136
+ )
137
+
138
+ @classmethod
139
+ def func_metadata(
140
+ cls, func: Callable[..., Any], skip_names: Sequence[str] = ()
141
+ ) -> "FuncMetadata":
142
+ """Given a function, return metadata including a pydantic model representing its
143
+ signature.
144
+
145
+ The use case for this is
146
+ ```
147
+ meta = func_to_pyd(func)
148
+ validated_args = meta.arg_model.model_validate(some_raw_data_dict)
149
+ return func(**validated_args.model_dump_one_level())
150
+ ```
151
+
152
+ **critically** it also provides pre-parse helper to attempt to parse things from
153
+ JSON.
154
+
155
+ Args:
156
+ func: The function to convert to a pydantic model
157
+ skip_names: A list of parameter names to skip. These will not be included in
158
+ the model.
159
+ Returns:
160
+ A pydantic model representing the function's signature.
161
+ """
162
+ sig = _get_typed_signature(func)
163
+ params = sig.parameters
164
+ dynamic_pydantic_model_params: dict[str, Any] = {}
165
+ globalns = getattr(func, "__globals__", {})
166
+ for param in params.values():
167
+ if param.name.startswith("_"):
168
+ raise InvalidSignature(
169
+ f"Parameter {param.name} of {func.__name__} cannot start with '_'"
170
+ )
171
+ if param.name in skip_names:
172
+ continue
173
+ annotation = param.annotation
174
+
175
+ # `x: None` / `x: None = None`
176
+ if annotation is None:
177
+ annotation = Annotated[
178
+ None,
179
+ Field(
180
+ default=param.default
181
+ if param.default is not inspect.Parameter.empty
182
+ else PydanticUndefined
183
+ ),
184
+ ]
185
+
186
+ # Untyped field
187
+ if annotation is inspect.Parameter.empty:
188
+ annotation = Annotated[
189
+ Any,
190
+ Field(),
191
+ # 🤷
192
+ WithJsonSchema({"title": param.name, "type": "string"}),
193
+ ]
194
+
195
+ field_info = FieldInfo.from_annotated_attribute(
196
+ _get_typed_annotation(annotation, globalns),
197
+ param.default
198
+ if param.default is not inspect.Parameter.empty
199
+ else PydanticUndefined,
200
+ )
201
+ dynamic_pydantic_model_params[param.name] = (
202
+ field_info.annotation,
203
+ field_info,
204
+ )
205
+ continue
206
+
207
+ arguments_model = create_model(
208
+ f"{func.__name__}Arguments",
209
+ **dynamic_pydantic_model_params,
210
+ __base__=ArgModelBase,
211
+ )
212
+ resp = FuncMetadata(arg_model=arguments_model)
213
+ return resp
@@ -0,0 +1,342 @@
1
+ from __future__ import annotations as _annotations
2
+
3
+ import inspect
4
+ from collections.abc import Callable
5
+ from typing import Any, Literal
6
+
7
+ import httpx
8
+ from loguru import logger
9
+ from pydantic import BaseModel, Field
10
+
11
+ from universal_mcp.analytics import analytics
12
+ from universal_mcp.applications.application import Application
13
+ from universal_mcp.exceptions import NotAuthorizedError, ToolError
14
+ from universal_mcp.utils.docstring_parser import parse_docstring
15
+
16
+ from .func_metadata import FuncMetadata
17
+
18
+
19
+ def convert_tool_to_openai_tool(
20
+ tool: Tool,
21
+ ):
22
+ """Convert a Tool object to an OpenAI function."""
23
+ return {
24
+ "type": "function",
25
+ "function": {
26
+ "name": tool.name,
27
+ "description": tool.description,
28
+ "parameters": tool.parameters,
29
+ },
30
+ }
31
+
32
+
33
+ def convert_tool_to_mcp_tool(
34
+ tool: Tool,
35
+ ):
36
+ from mcp.server.fastmcp.server import MCPTool
37
+
38
+ return MCPTool(
39
+ name=tool.name,
40
+ description=tool.description or "",
41
+ inputSchema=tool.parameters,
42
+ )
43
+
44
+
45
+ def convert_tool_to_langchain_tool(
46
+ tool: Tool,
47
+ ):
48
+ """Convert a Tool object to a LangChain StructuredTool.
49
+
50
+ NOTE: this tool can be executed only in a context of an active MCP client session.
51
+
52
+ Args:
53
+ tool: Tool object to convert
54
+
55
+ Returns:
56
+ a LangChain StructuredTool
57
+ """
58
+ from langchain_core.tools import ( # Keep import inside if preferred, or move top
59
+ StructuredTool,
60
+ ToolException,
61
+ )
62
+
63
+ async def call_tool(
64
+ **arguments: dict[
65
+ str, any
66
+ ], # arguments received here are validated by StructuredTool
67
+ ):
68
+ # tool.run already handles validation via fn_metadata.call_fn_with_arg_validation
69
+ # It should be able to handle the validated/coerced types from StructuredTool
70
+ try:
71
+ call_tool_result = await tool.run(arguments)
72
+ return call_tool_result
73
+ except ToolError as e:
74
+ # Langchain expects ToolException for controlled errors
75
+ raise ToolException(f"Error running tool '{tool.name}': {e}") from e
76
+ except Exception as e:
77
+ # Catch unexpected errors
78
+ raise ToolException(f"Unexpected error in tool '{tool.name}': {e}") from e
79
+
80
+ return StructuredTool(
81
+ name=tool.name,
82
+ description=tool.description
83
+ or f"Tool named {tool.name}.", # Provide fallback description
84
+ coroutine=call_tool,
85
+ args_schema=tool.fn_metadata.arg_model, # <<< --- ADD THIS LINE
86
+ # handle_tool_error=True, # Optional: Consider adding error handling config
87
+ # return_direct=False, # Optional: Default is usually fine
88
+ # response_format="content", # This field might not be valid for StructuredTool, check LangChain docs if needed. Let's remove for now.
89
+ )
90
+
91
+
92
+ class Tool(BaseModel):
93
+ """Internal tool registration info."""
94
+
95
+ fn: Callable[..., Any] = Field(exclude=True)
96
+ name: str = Field(description="Name of the tool")
97
+ description: str = Field(description="Summary line from the tool's docstring")
98
+ args_description: dict[str, str] = Field(
99
+ default_factory=dict, description="Descriptions of arguments from the docstring"
100
+ )
101
+ returns_description: str = Field(
102
+ default="", description="Description of the return value from the docstring"
103
+ )
104
+ raises_description: dict[str, str] = Field(
105
+ default_factory=dict,
106
+ description="Descriptions of exceptions raised from the docstring",
107
+ )
108
+ tags: list[str] = Field(
109
+ default_factory=list, description="Tags for categorizing the tool"
110
+ )
111
+ parameters: dict[str, Any] = Field(description="JSON schema for tool parameters")
112
+ fn_metadata: FuncMetadata = Field(
113
+ description="Metadata about the function including a pydantic model for tool"
114
+ " arguments"
115
+ )
116
+ is_async: bool = Field(description="Whether the tool is async")
117
+
118
+ @classmethod
119
+ def from_function(
120
+ cls,
121
+ fn: Callable[..., Any],
122
+ name: str | None = None,
123
+ ) -> Tool:
124
+ """Create a Tool from a function."""
125
+
126
+ func_name = name or fn.__name__
127
+
128
+ if func_name == "<lambda>":
129
+ raise ValueError("You must provide a name for lambda functions")
130
+
131
+ raw_doc = inspect.getdoc(fn)
132
+ parsed_doc = parse_docstring(raw_doc)
133
+
134
+ is_async = inspect.iscoroutinefunction(fn)
135
+
136
+ func_arg_metadata = FuncMetadata.func_metadata(
137
+ fn,
138
+ )
139
+ parameters = func_arg_metadata.arg_model.model_json_schema()
140
+
141
+ return cls(
142
+ fn=fn,
143
+ name=func_name,
144
+ description=parsed_doc["summary"],
145
+ args_description=parsed_doc["args"],
146
+ returns_description=parsed_doc["returns"],
147
+ raises_description=parsed_doc["raises"],
148
+ tags=parsed_doc["tags"],
149
+ parameters=parameters,
150
+ fn_metadata=func_arg_metadata,
151
+ is_async=is_async,
152
+ )
153
+
154
+ async def run(
155
+ self,
156
+ arguments: dict[str, Any],
157
+ context=None,
158
+ ) -> Any:
159
+ """Run the tool with arguments."""
160
+ try:
161
+ return await self.fn_metadata.call_fn_with_arg_validation(
162
+ self.fn, self.is_async, arguments, None
163
+ )
164
+ except NotAuthorizedError as e:
165
+ message = f"Not authorized to call tool {self.name}: {e.message}"
166
+ return message
167
+ except httpx.HTTPError as e:
168
+ message = f"HTTP error calling tool {self.name}: {str(e)}"
169
+ raise ToolError(message) from e
170
+ except ValueError as e:
171
+ message = f"Invalid arguments for tool {self.name}: {e}"
172
+ raise ToolError(message) from e
173
+ except Exception as e:
174
+ raise ToolError(f"Error executing tool {self.name}: {e}") from e
175
+
176
+
177
+ class ToolManager:
178
+ """Manages FastMCP tools."""
179
+
180
+ def __init__(self, warn_on_duplicate_tools: bool = True):
181
+ self._tools: dict[str, Tool] = {}
182
+ self.warn_on_duplicate_tools = warn_on_duplicate_tools
183
+
184
+ def get_tool(self, name: str) -> Tool | None:
185
+ """Get tool by name."""
186
+ return self._tools.get(name)
187
+
188
+ def list_tools(
189
+ self, format: Literal["mcp", "langchain", "openai"] = "mcp"
190
+ ) -> list[Tool]:
191
+ """List all registered tools."""
192
+ if format == "mcp":
193
+ return [convert_tool_to_mcp_tool(tool) for tool in self._tools.values()]
194
+ elif format == "langchain":
195
+ return [
196
+ convert_tool_to_langchain_tool(tool) for tool in self._tools.values()
197
+ ]
198
+ elif format == "openai":
199
+ return [convert_tool_to_openai_tool(tool) for tool in self._tools.values()]
200
+ else:
201
+ raise ValueError(f"Invalid format: {format}")
202
+
203
+ # Modified add_tool to accept name override explicitly
204
+ def add_tool(
205
+ self, fn: Callable[..., Any] | Tool, name: str | None = None
206
+ ) -> Tool: # Changed any to Any
207
+ """Add a tool to the server, allowing name override."""
208
+ # Create the Tool object using the provided name if available
209
+ tool = fn if isinstance(fn, Tool) else Tool.from_function(fn, name=name)
210
+ existing = self._tools.get(tool.name)
211
+ if existing:
212
+ if self.warn_on_duplicate_tools:
213
+ # Check if it's the *exact* same function object being added again
214
+ if existing.fn is not tool.fn:
215
+ logger.warning(
216
+ f"Tool name '{tool.name}' conflicts with an existing tool. Skipping addition of new function."
217
+ )
218
+ else:
219
+ logger.debug(
220
+ f"Tool '{tool.name}' with the same function already exists."
221
+ )
222
+ return existing # Return the existing tool if name conflicts
223
+
224
+ logger.debug(f"Adding tool: {tool.name}")
225
+ self._tools[tool.name] = tool
226
+ return tool
227
+
228
+ async def call_tool(
229
+ self,
230
+ name: str,
231
+ arguments: dict[str, Any],
232
+ context=None,
233
+ ) -> Any:
234
+ """Call a tool by name with arguments."""
235
+ tool = self.get_tool(name)
236
+ if not tool:
237
+ raise ToolError(f"Unknown tool: {name}")
238
+ try:
239
+ result = await tool.run(arguments)
240
+ analytics.track_tool_called(name, "success")
241
+ return result
242
+ except Exception as e:
243
+ analytics.track_tool_called(name, "error", str(e))
244
+ raise
245
+
246
+ def get_tools_by_tags(self, tags: list[str]) -> list[Tool]:
247
+ """Get tools by tags."""
248
+ return [
249
+ tool
250
+ for tool in self._tools.values()
251
+ if any(tag in tool.tags for tag in tags)
252
+ ]
253
+
254
+ def register_tools_from_app(
255
+ self,
256
+ app: Application,
257
+ tools: list[str] | None = None,
258
+ tags: list[str] | None = None,
259
+ ) -> None:
260
+ try:
261
+ available_tool_functions = app.list_tools()
262
+ except TypeError as e:
263
+ logger.error(
264
+ f"Error calling list_tools for app '{app.name}'. Does its list_tools method accept arguments? It shouldn't. Error: {e}"
265
+ )
266
+ return
267
+ except Exception as e:
268
+ logger.error(f"Failed to get tool list from app '{app.name}': {e}")
269
+ return
270
+
271
+ if not isinstance(available_tool_functions, list):
272
+ logger.error(
273
+ f"App '{app.name}' list_tools() did not return a list. Skipping registration."
274
+ )
275
+ return
276
+
277
+ # Determine the effective filter lists *before* the loop for efficiency
278
+ # Use an empty list if None is passed, simplifies checks later
279
+ tools_name_filter = tools or []
280
+
281
+ # For tags, determine the filter list based on priority: passed 'tags' or default 'important'
282
+ # This list is only used if tools_name_filter is empty.
283
+ active_tags_filter = tags if tags else ["important"] # Default filter
284
+
285
+ logger.debug(
286
+ f"Registering tools for '{app.name}'. Name filter: {tools_name_filter or 'None'}. Tag filter (if name filter empty): {active_tags_filter}"
287
+ )
288
+
289
+ for tool_func in available_tool_functions:
290
+ if not callable(tool_func):
291
+ logger.warning(
292
+ f"Item returned by {app.name}.list_tools() is not callable: {tool_func}. Skipping."
293
+ )
294
+ continue
295
+
296
+ try:
297
+ # Create the Tool metadata object from the function.
298
+ # This parses docstring (including tags), gets signature etc.
299
+ tool_instance = Tool.from_function(tool_func)
300
+ except Exception as e:
301
+ logger.error(
302
+ f"Failed to create Tool object from function '{getattr(tool_func, '__name__', 'unknown')}' in app '{app.name}': {e}"
303
+ )
304
+ continue # Skip this tool if metadata creation fails
305
+
306
+ # --- Modify the Tool instance before filtering/registration ---
307
+ original_name = tool_instance.name
308
+ prefixed_name = f"{app.name}_{original_name}"
309
+ tool_instance.name = prefixed_name # Update the name
310
+
311
+ # Add the app name itself as a tag for categorization
312
+ if app.name not in tool_instance.tags:
313
+ tool_instance.tags.append(app.name)
314
+
315
+ # --- Filtering Logic ---
316
+ should_register = False # Default to not registering
317
+
318
+ if tools_name_filter:
319
+ # --- Primary Filter: Check against specific tool names ---
320
+ if tool_instance.name in tools_name_filter:
321
+ should_register = True
322
+ logger.debug(f"Tool '{tool_instance.name}' matched name filter.")
323
+ # If not in the name filter, it's skipped (should_register remains False)
324
+
325
+ else:
326
+ # --- Secondary Filter: Check against tags (since tools_name_filter is empty) ---
327
+ # Check if *any* tag in active_tags_filter exists in the tool's tags
328
+ # tool_instance.tags includes tags parsed from the docstring + app.name
329
+ if any(tag in tool_instance.tags for tag in active_tags_filter):
330
+ should_register = True
331
+ logger.debug(
332
+ f"Tool '{tool_instance.name}' matched tag filter {active_tags_filter}."
333
+ )
334
+ # else:
335
+ # logger.debug(f"Tool '{tool_instance.name}' did NOT match tag filter {active_tags_filter}. Tool tags: {tool_instance.tags}")
336
+
337
+ # --- Add the tool if it passed the filters ---
338
+ if should_register:
339
+ # Pass the fully configured Tool *instance* to add_tool
340
+ self.add_tool(tool_instance)
341
+ # else: If not registered, optionally log it for debugging:
342
+ # logger.trace(f"Tool '{tool_instance.name}' skipped due to filters.") # Use trace level