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.
- universal_mcp/__init__.py +0 -2
- universal_mcp/analytics.py +75 -0
- universal_mcp/applications/ahrefs/README.md +76 -0
- universal_mcp/applications/ahrefs/app.py +2291 -0
- universal_mcp/applications/application.py +95 -5
- universal_mcp/applications/calendly/README.md +78 -0
- universal_mcp/applications/calendly/__init__.py +0 -0
- universal_mcp/applications/calendly/app.py +1195 -0
- universal_mcp/applications/coda/README.md +133 -0
- universal_mcp/applications/coda/__init__.py +0 -0
- universal_mcp/applications/coda/app.py +3671 -0
- universal_mcp/applications/e2b/app.py +14 -28
- universal_mcp/applications/figma/README.md +74 -0
- universal_mcp/applications/figma/__init__.py +0 -0
- universal_mcp/applications/figma/app.py +1261 -0
- universal_mcp/applications/firecrawl/app.py +38 -35
- universal_mcp/applications/github/app.py +127 -85
- universal_mcp/applications/google_calendar/app.py +62 -138
- universal_mcp/applications/google_docs/app.py +47 -52
- universal_mcp/applications/google_drive/app.py +119 -113
- universal_mcp/applications/google_mail/app.py +124 -50
- universal_mcp/applications/google_sheet/app.py +89 -91
- universal_mcp/applications/markitdown/app.py +9 -8
- universal_mcp/applications/notion/app.py +254 -134
- universal_mcp/applications/perplexity/app.py +13 -41
- universal_mcp/applications/reddit/app.py +94 -85
- universal_mcp/applications/resend/app.py +12 -13
- universal_mcp/applications/{serp → serpapi}/app.py +14 -25
- universal_mcp/applications/tavily/app.py +11 -18
- universal_mcp/applications/wrike/README.md +71 -0
- universal_mcp/applications/wrike/__init__.py +0 -0
- universal_mcp/applications/wrike/app.py +1372 -0
- universal_mcp/applications/youtube/README.md +82 -0
- universal_mcp/applications/youtube/__init__.py +0 -0
- universal_mcp/applications/youtube/app.py +1428 -0
- universal_mcp/applications/zenquotes/app.py +12 -2
- universal_mcp/exceptions.py +9 -2
- universal_mcp/integrations/__init__.py +24 -1
- universal_mcp/integrations/agentr.py +27 -4
- universal_mcp/integrations/integration.py +146 -32
- universal_mcp/logger.py +3 -56
- universal_mcp/servers/__init__.py +6 -14
- universal_mcp/servers/server.py +201 -146
- universal_mcp/stores/__init__.py +7 -2
- universal_mcp/stores/store.py +103 -40
- universal_mcp/tools/__init__.py +3 -0
- universal_mcp/tools/adapters.py +43 -0
- universal_mcp/tools/func_metadata.py +213 -0
- universal_mcp/tools/tools.py +342 -0
- universal_mcp/utils/docgen.py +325 -119
- universal_mcp/utils/docstring_parser.py +179 -0
- universal_mcp/utils/dump_app_tools.py +33 -23
- universal_mcp/utils/installation.py +201 -10
- universal_mcp/utils/openapi.py +229 -46
- {universal_mcp-0.1.7rc1.dist-info → universal_mcp-0.1.8.dist-info}/METADATA +9 -5
- universal_mcp-0.1.8.dist-info/RECORD +81 -0
- universal_mcp-0.1.7rc1.dist-info/RECORD +0 -58
- /universal_mcp/{utils/bridge.py → applications/ahrefs/__init__.py} +0 -0
- /universal_mcp/applications/{serp → serpapi}/README.md +0 -0
- {universal_mcp-0.1.7rc1.dist-info → universal_mcp-0.1.8.dist-info}/WHEEL +0 -0
- {universal_mcp-0.1.7rc1.dist-info → universal_mcp-0.1.8.dist-info}/entry_points.txt +0 -0
@@ -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
|