From 5962bd4a90eb521abfa691846b8801146e2aa454 Mon Sep 17 00:00:00 2001 From: Razvan Radulescu <43811028+h3xxit@users.noreply.github.com> Date: Sun, 27 Jul 2025 11:20:51 +0200 Subject: [PATCH 1/2] * v0.1.8 * Parse jsonschema of tools by hand * Missing async in search_tools definition * Remove unused load function from UtcpVariableConfig --- example/src/simple_example/server.py | 18 +- pyproject.toml | 2 +- src/utcp/client/utcp_client.py | 4 +- src/utcp/client/utcp_client_config.py | 9 +- src/utcp/shared/tool.py | 286 +++++++++++++++++++++----- src/utcp/version.py | 2 +- 6 files changed, 261 insertions(+), 60 deletions(-) diff --git a/example/src/simple_example/server.py b/example/src/simple_example/server.py index f89e1ec..0bcb682 100644 --- a/example/src/simple_example/server.py +++ b/example/src/simple_example/server.py @@ -1,12 +1,19 @@ +from typing import List, Optional from fastapi import FastAPI from pydantic import BaseModel from utcp.shared.provider import HttpProvider from utcp.shared.tool import utcp_tool from utcp.shared.utcp_manual import UtcpManual +class TestInput(BaseModel): + value: str class TestRequest(BaseModel): value: str + arr: List[TestInput] + +class TestResponse(BaseModel): + received: str __version__ = "1.0.0" BASE_PATH = "http://localhost:8080" @@ -23,5 +30,12 @@ def get_utcp(): http_method="POST" )) @app.post("/test") -def test_endpoint(data: TestRequest): - return {"received": data.value} +def test_endpoint(data: TestRequest) -> Optional[TestResponse]: + """Test endpoint to receive a string value. + + Args: + data (TestRequest): The input data containing a string value. + Returns: + TestResponse: A dictionary with the received value. + """ + return TestResponse(received=data.value) diff --git a/pyproject.toml b/pyproject.toml index bd71bab..f03f362 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "utcp" -version = "0.1.7" +version = "0.1.8" authors = [ { name = "Razvan-Ion Radulescu" }, { name = "Andrei-Stefan Ghiurtu" }, diff --git a/src/utcp/client/utcp_client.py b/src/utcp/client/utcp_client.py index a13f1a7..062fce3 100644 --- a/src/utcp/client/utcp_client.py +++ b/src/utcp/client/utcp_client.py @@ -331,5 +331,5 @@ async def call_tool(self, tool_name: str, arguments: Dict[str, Any]) -> Any: return await self.transports[tool_provider.provider_type].call_tool(tool_name, arguments, tool_provider) - def search_tools(self, query: str, limit: int = 10) -> List[Tool]: - return self.search_strategy.search_tools(query, limit) + async def search_tools(self, query: str, limit: int = 10) -> List[Tool]: + return await self.search_strategy.search_tools(query, limit) diff --git a/src/utcp/client/utcp_client_config.py b/src/utcp/client/utcp_client_config.py index fd96552..a74cafe 100644 --- a/src/utcp/client/utcp_client_config.py +++ b/src/utcp/client/utcp_client_config.py @@ -13,10 +13,6 @@ def __init__(self, variable_name: str): class UtcpVariablesConfig(BaseModel, ABC): type: Literal["dotenv"] = "dotenv" - @abstractmethod - def load(self) -> Dict[str, str]: - pass - @abstractmethod def get(self, key: str) -> Optional[str]: pass @@ -24,11 +20,8 @@ def get(self, key: str) -> Optional[str]: class UtcpDotEnv(UtcpVariablesConfig): env_file_path: str - def load(self) -> Dict[str, str]: - return dotenv_values(self.env_file_path) - def get(self, key: str) -> Optional[str]: - return self.load().get(key) + return dotenv_values(self.env_file_path).get(key) class UtcpClientConfig(BaseModel): variables: Optional[Dict[str, str]] = Field(default_factory=dict) diff --git a/src/utcp/shared/tool.py b/src/utcp/shared/tool.py index b1cc124..f1aed22 100644 --- a/src/utcp/shared/tool.py +++ b/src/utcp/shared/tool.py @@ -1,7 +1,10 @@ -from typing import Dict, Any, Optional, List, get_type_hints -from pydantic import BaseModel, Field, TypeAdapter +import inspect +from typing import Dict, Any, Optional, List, Set, Tuple, get_type_hints, get_origin, get_args, Union +from typing import get_origin, get_args, List, Dict, Optional, Union, Any +from pydantic import BaseModel, Field from utcp.shared.provider import ProviderUnion + class ToolInputOutputSchema(BaseModel): type: str = Field(default="object") properties: Dict[str, Any] = Field(default_factory=dict) @@ -38,6 +41,228 @@ def get_tools() -> List[Tool]: """Get the list of tools available in the UTCP server.""" return ToolContext.tools +########## UTCP Tool Decorator ########## +def python_type_to_json_type(py_type): + origin = get_origin(py_type) + args = get_args(py_type) + + if origin is Union: + # Handle Optional[X] = Union[X, NoneType] + non_none_args = [arg for arg in args if arg is not type(None)] + if len(non_none_args) == 1: + return python_type_to_json_type(non_none_args[0]) # Treat as Optional + else: + return "object" # Generic union + + if origin is list or origin is List: + return "array" + if origin is dict or origin is Dict: + return "object" + if origin is tuple or origin is Tuple: + return "array" + if origin is set or origin is Set: + return "array" + + # Handle concrete base types + mapping = { + str: "string", + int: "integer", + float: "number", + bool: "boolean", + bytes: "string", + type(None): "null", + Any: "object", + } + + return mapping.get(py_type, "object") + +def get_docstring_description_input(func) -> Dict[str, Optional[str]]: + """ + Extracts descriptions for parameters from the function docstring. + Returns a dict mapping param names to their descriptions. + """ + doc = func.__doc__ + if not doc: + return {} + descriptions = {} + for line in map(str.strip, doc.splitlines()): + for param in inspect.signature(func).parameters: + if param == "self": + continue + if line.startswith(param): + descriptions[param] = line.split(param, 1)[1].strip() + return descriptions + +def get_docstring_description_output(func) -> Dict[str, Optional[str]]: + """ + Extracts the return value description from the function docstring. + Returns a dict with key 'return' and its description. + """ + doc = func.__doc__ + if not doc: + return {} + for i, line in enumerate(map(str.strip, doc.splitlines())): + if line.lower().startswith("returns:") or line.lower().startswith("return:"): + desc = line.split(":", 1)[1].strip() + if desc: + return {"return": desc} + # If description is on the next line + if i + 1 < len(doc.splitlines()): + return {"return": doc.splitlines()[i + 1].strip()} + return {} + +def get_param_description(cls, param_name=None): + # Try to get description for a specific param if available + if param_name: + # Check if there's a class variable or annotation with description + doc = getattr(cls, "__doc__", "") or "" + for line in map(str.strip, doc.splitlines()): + if line.startswith(param_name): + return line.split(param_name, 1)[1].strip() + # Check if param has a 'description' attribute (for pydantic/BaseModel fields) + if hasattr(cls, "__fields__") and param_name in cls.__fields__: + return getattr(cls.__fields__[param_name], "field_info", {}).get("description", "") + # Fallback to class-level description + return getattr(cls, "description", "") or (getattr(cls, "__doc__", "") or "") + +def is_optional(t): + origin = get_origin(t) + args = get_args(t) + return origin is Union and type(None) in args + +def recurse_type(param_type): + json_type = python_type_to_json_type(param_type) + + # Handle array/list types + if json_type == "array": + # Try to get the element type if available + item_type = getattr(param_type, "__args__", [Any])[0] + return { + "type": "array", + "items": recurse_type(item_type), + "description": "An array of items" + } + + # Handle object types + if json_type == "object": + if hasattr(param_type, "__annotations__") or is_optional(param_type): + sub_properties = {} + sub_required = [] + + if is_optional(param_type): + # If it's Optional, we treat it as an object with no required fields + param_type = param_type.__args__[0] if param_type.__args__ else Any + for key, value_type in getattr(param_type, "__annotations__", {}).items(): + key_desc = get_param_description(param_type, key) + sub_properties[key] = recurse_type(value_type) + sub_properties[key]["description"] = key_desc or f"Auto-generated description for {key}" + if value_type is not None and value_type is not type(None) and value_type is not Optional and not is_optional(value_type): + sub_required.append(key) + return { + "type": "object", + "properties": sub_properties, + "required": sub_required, + "description": get_param_description(param_type) + } + + return { + "type": "object", + "properties": {}, + "description": "A generic dictionary object" + } + + # Fallback for primitive types + return { + "type": json_type, + "description": "" + } + +def type_to_json_schema(param_type, param_name=None, param_description=None): + json_type = python_type_to_json_type(param_type) + + # Recurse for object and dict types + if json_type == "object": + val = recurse_type(param_type) + val["description"] = get_param_description(param_type, param_name) or param_description.get(param_name, f"Auto-generated description for {param_name}") + elif json_type == "array" and hasattr(param_type, "__args__"): + # Handle list/array types with recursion for element type + item_type = param_type.__args__[0] if param_type.__args__ else Any + val = { + "type": "array", + "items": recurse_type(item_type), + "description": param_description.get(param_name, f"Auto-generated description for {param_name}") + } + else: + val = { + "type": json_type, + "description": param_description.get(param_name, f"Auto-generated description for {param_name}") + } + + return val + +def generate_input_schema(func, title, description): + sig = inspect.signature(func) + type_hints = get_type_hints(func) + + properties = {} + required = [] + + func_name = func.__name__ + func_description = description or func.__doc__ or "" + param_description = get_docstring_description_input(func) + + for param_name, param in sig.parameters.items(): + if param_name == "self": # skip methods' self + continue + + param_type = type_hints.get(param_name, str) + properties[param_name] = type_to_json_schema(param_type, param_name, param_description) + + if param.default is inspect.Parameter.empty: + required.append(param_name) + + input_desc = "\n".join([f"{name}: {desc}" for name, desc in param_description.items() if desc]) + schema = ToolInputOutputSchema( + type="object", + properties=properties, + required=required, + description=input_desc or func_description, + title=title or func_name + ) + + return schema + +def generate_output_schema(func, title, description): + type_hints = get_type_hints(func) + func_name = func.__name__ + func_description = description or func.__doc__ or "" + + properties = {} + required = [] + + return_type = type_hints.get('return', None) + output_desc = get_docstring_description_output(func).get('return', None) + if return_type: + properties["result"] = type_to_json_schema(return_type, "result", {"result": output_desc}) + if return_type is not None and return_type is not type(None) and return_type is not Optional and not is_optional(return_type): + required.append("result") + else: + properties["result"] = { + "type": "null", + "description": f"No return value for {func_name}" + } + + schema = ToolInputOutputSchema( + type="object", + properties=properties, + required=required, + description=output_desc or func_description, + title=title or func_name + ) + + return schema + + def utcp_tool( tool_provider: ProviderUnion, name: Optional[str] = None, @@ -48,61 +273,30 @@ def utcp_tool( ): def decorator(func): if tool_provider.name is None: - _provider_name = f"{func.__name__}_provider" - tool_provider.name = _provider_name - else: - _provider_name = tool_provider.name + tool_provider.name = f"{func.__name__}_provider" - func_name = func.__name__ + func_name = name or func.__name__ func_description = description or func.__doc__ or "" - - if not inputs: - # Extract input schema - input_tool_schema = TypeAdapter(func).json_schema() - input_tool_schema["title"] = func_name - input_tool_schema["description"] = func_description - - if not outputs: - # Extract output schema - hints = get_type_hints(func) - return_type = hints.pop("return", None) - if return_type is not None: - output_schema = TypeAdapter(return_type).json_schema() - output_tool_schema = ToolInputOutputSchema( - type=output_schema.get("type", "object") if output_schema.get("type") == "object" else "value", - properties=output_schema.get("properties", {}) if output_schema.get("type") == "object" else {}, - required=output_schema.get("required", []) if output_schema.get("type") == "object" else [], - title=func_name, - description=func_description - ) - else: - output_tool_schema = ToolInputOutputSchema( - type="null", - properties={}, - required=[], - title=func_name, - description=func_description - ) - - # Create the complete tool definition + + input_tool_schema = inputs or generate_input_schema(func, f"{func_name} Input", func_description) + output_tool_schema = outputs or generate_output_schema(func, f"{func_name} Output", func_description) + def get_tool_definition(): return Tool( - name=name or func_name, - description=description or func_description, + name=func_name, + description=func_description, tags=tags, - inputs=inputs or input_tool_schema, - outputs=outputs or output_tool_schema, + inputs=input_tool_schema, + outputs=output_tool_schema, tool_provider=tool_provider ) - - # Attach methods to function + func.input = lambda: input_tool_schema func.output = lambda: output_tool_schema func.tool_definition = get_tool_definition - # Add the tool to the UTCP manual context ToolContext.add_tool(get_tool_definition()) - + return func - + return decorator diff --git a/src/utcp/version.py b/src/utcp/version.py index 2a776a7..1ac5329 100644 --- a/src/utcp/version.py +++ b/src/utcp/version.py @@ -2,7 +2,7 @@ import tomli from pathlib import Path -__version__ = "0.1.7" +__version__ = "0.1.8" try: __version__ = version("utcp") except PackageNotFoundError: From b996697201ebf1ace42746a6f827f1043ced3972 Mon Sep 17 00:00:00 2001 From: "Juan V." <69489757+edujuan@users.noreply.github.com> Date: Fri, 8 Aug 2025 09:07:11 +0200 Subject: [PATCH 2/2] Update README.md --- README.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/README.md b/README.md index ccd67af..e86d369 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,11 @@ # Universal Tool Calling Protocol (UTCP) +[![Follow Org](https://img.shields.io/github/followers/universal-tool-calling-protocol?label=Follow%20Org&logo=github)](https://github.com/universal-tool-calling-protocol) +[![PyPI Downloads](https://static.pepy.tech/badge/utcp)](https://pepy.tech/projects/utcp) +[![License](https://img.shields.io/github/license/universal-tool-calling-protocol/python-utcp)](https://github.com/universal-tool-calling-protocol/python-utcp/blob/main/LICENSE) +[![CDTM S23](https://img.shields.io/badge/CDTM-S23-0b84f3)](https://cdtm.com/) + + ## Introduction The Universal Tool Calling Protocol (UTCP) is a modern, flexible, and scalable standard for defining and interacting with tools across a wide variety of communication protocols. It is designed to be easy to use, interoperable, and extensible, making it a powerful choice for building and consuming tool-based services.