diff --git a/lib/crewai-tools/src/crewai_tools/adapters/mcp_adapter.py b/lib/crewai-tools/src/crewai_tools/adapters/mcp_adapter.py index e2424aa72..b8ad34b95 100644 --- a/lib/crewai-tools/src/crewai_tools/adapters/mcp_adapter.py +++ b/lib/crewai-tools/src/crewai_tools/adapters/mcp_adapter.py @@ -9,56 +9,111 @@ from typing import TYPE_CHECKING, Any from crewai.tools import BaseTool from crewai.utilities.pydantic_schema_utils import ( create_model_from_schema, - generate_model_description, ) from crewai.utilities.string_utils import sanitize_tool_name +from pydantic import BaseModel from crewai_tools.adapters.tool_collection import ToolCollection if TYPE_CHECKING: from mcp import StdioServerParameters - from mcp.types import CallToolResult, Tool - from mcpadapt.core import MCPAdapt # type: ignore[import-not-found] - from mcpadapt.crewai_adapter import CrewAIAdapter # type: ignore[import-not-found] - - -try: - from mcp import StdioServerParameters - from mcp.types import CallToolResult, Tool - from mcpadapt.core import MCPAdapt - from mcpadapt.crewai_adapter import CrewAIAdapter - - MCP_AVAILABLE = True -except ImportError: - MCP_AVAILABLE = False + from mcp.types import CallToolResult, TextContent, Tool + from mcpadapt.core import MCPAdapt, ToolAdapter logger = logging.getLogger(__name__) -class CrewAIToolAdapter(CrewAIAdapter): # type: ignore[misc,no-any-unimported] - """Adapter that normalizes JSON Schema before processing.""" +try: + from mcp import StdioServerParameters + from mcp.types import CallToolResult, TextContent, Tool + from mcpadapt.core import MCPAdapt, ToolAdapter - def adapt( - self, - func: Callable[[dict[str, Any] | None], CallToolResult], - mcp_tool: Tool, - ) -> BaseTool: - """Adapt a MCP tool to a CrewAI tool. + class CrewAIToolAdapter(ToolAdapter): + """Adapter that creates CrewAI tools with properly normalized JSON schemas. - Args: - func: The function to call when the tool is invoked. - mcp_tool: The MCP tool definition to adapt. - - Returns: - A CrewAI BaseTool instance. + This adapter bypasses mcpadapt's model creation which adds invalid null values + to field schemas, instead using CrewAI's own schema utilities. """ - mcp_tool.name = sanitize_tool_name(mcp_tool.name) - model = create_model_from_schema(mcp_tool.inputSchema) - normalized = generate_model_description(model) - mcp_tool.inputSchema = normalized["json_schema"]["schema"] - return super().adapt(func, mcp_tool) # type: ignore[no-any-return] + + def adapt( + self, + func: Callable[[dict[str, Any] | None], CallToolResult], + mcp_tool: Tool, + ) -> BaseTool: + """Adapt a MCP tool to a CrewAI tool. + + Args: + func: The function to call when the tool is invoked. + mcp_tool: The MCP tool definition to adapt. + + Returns: + A CrewAI BaseTool instance. + """ + tool_name = sanitize_tool_name(mcp_tool.name) + tool_description = mcp_tool.description or "" + input_schema = mcp_tool.inputSchema + + args_model = create_model_from_schema(input_schema) + + class CrewAIMCPTool(BaseTool): + name: str = tool_name + description: str = tool_description + args_schema: type[BaseModel] = args_model + + def _run(self, **kwargs: Any) -> Any: + filtered_kwargs: dict[str, Any] = {} + schema_properties = input_schema.get("properties", {}) + + for key, value in kwargs.items(): + if value is None and key in schema_properties: + prop_schema = schema_properties[key] + if isinstance(prop_schema.get("type"), list): + if "null" in prop_schema["type"]: + filtered_kwargs[key] = value + elif "anyOf" in prop_schema: + if any( + opt.get("type") == "null" + for opt in prop_schema["anyOf"] + ): + filtered_kwargs[key] = value + else: + filtered_kwargs[key] = value + + result = func(filtered_kwargs) + if len(result.content) == 1: + first_content = result.content[0] + if isinstance(first_content, TextContent): + return first_content.text + return str(first_content) + return str( + [ + content.text + for content in result.content + if isinstance(content, TextContent) + ] + ) + + def _generate_description(self) -> None: + schema = self.args_schema.model_json_schema() + schema.pop("$defs", None) + self.description = ( + f"Tool Name: {self.name}\n" + f"Tool Arguments: {schema}\n" + f"Tool Description: {self.description}" + ) + + return CrewAIMCPTool() + + async def async_adapt(self, afunc: Any, mcp_tool: Tool) -> Any: + """Async adaptation is not supported by CrewAI.""" + raise NotImplementedError("async is not supported by the CrewAI framework.") + + MCP_AVAILABLE = True +except ImportError as e: + logger.debug(f"MCP packages not available: {e}") + MCP_AVAILABLE = False class MCPServerAdapter: @@ -132,7 +187,7 @@ class MCPServerAdapter: import subprocess try: - subprocess.run(["uv", "add", "mcp crewai-tools[mcp]"], check=True) # noqa: S607 + subprocess.run(["uv", "add", "mcp crewai-tools'[mcp]'"], check=True) # noqa: S607 except subprocess.CalledProcessError as e: raise ImportError("Failed to install mcp package") from e diff --git a/lib/crewai/src/crewai/utilities/pydantic_schema_utils.py b/lib/crewai/src/crewai/utilities/pydantic_schema_utils.py index a462c8ea2..882cc8a43 100644 --- a/lib/crewai/src/crewai/utilities/pydantic_schema_utils.py +++ b/lib/crewai/src/crewai/utilities/pydantic_schema_utils.py @@ -19,7 +19,7 @@ from collections.abc import Callable from copy import deepcopy import datetime import logging -from typing import TYPE_CHECKING, Annotated, Any, Literal, Union +from typing import TYPE_CHECKING, Annotated, Any, Final, Literal, Union import uuid import jsonref # type: ignore[import-untyped] @@ -158,6 +158,72 @@ def force_additional_properties_false(d: Any) -> Any: return d +OPENAI_SUPPORTED_FORMATS: Final[ + set[Literal["date-time", "date", "time", "duration"]] +] = { + "date-time", + "date", + "time", + "duration", +} + + +def strip_unsupported_formats(d: Any) -> Any: + """Remove format annotations that OpenAI strict mode doesn't support. + + OpenAI only supports: date-time, date, time, duration. + Other formats like uri, email, uuid etc. cause validation errors. + + Args: + d: The dictionary/list to modify. + + Returns: + The modified dictionary/list. + """ + if isinstance(d, dict): + format_value = d.get("format") + if ( + isinstance(format_value, str) + and format_value not in OPENAI_SUPPORTED_FORMATS + ): + del d["format"] + for v in d.values(): + strip_unsupported_formats(v) + elif isinstance(d, list): + for i in d: + strip_unsupported_formats(i) + return d + + +def ensure_type_in_schemas(d: Any) -> Any: + """Ensure all schema objects in anyOf/oneOf have a 'type' key. + + OpenAI strict mode requires every schema to have a 'type' key. + Empty schemas {} in anyOf/oneOf are converted to {"type": "object"}. + + Args: + d: The dictionary/list to modify. + + Returns: + The modified dictionary/list. + """ + if isinstance(d, dict): + for key in ("anyOf", "oneOf"): + if key in d: + schema_list = d[key] + for i, schema in enumerate(schema_list): + if isinstance(schema, dict) and schema == {}: + schema_list[i] = {"type": "object"} + else: + ensure_type_in_schemas(schema) + for v in d.values(): + ensure_type_in_schemas(v) + elif isinstance(d, list): + for item in d: + ensure_type_in_schemas(item) + return d + + def fix_discriminator_mappings(schema: dict[str, Any]) -> dict[str, Any]: """Replace '#/$defs/...' references in discriminator.mapping with just the model name. @@ -310,6 +376,8 @@ def generate_model_description(model: type[BaseModel]) -> dict[str, Any]: json_schema = model.model_json_schema(ref_template="#/$defs/{model}") json_schema = force_additional_properties_false(json_schema) + json_schema = strip_unsupported_formats(json_schema) + json_schema = ensure_type_in_schemas(json_schema) json_schema = resolve_refs(json_schema) @@ -413,7 +481,7 @@ def create_model_from_schema( # type: ignore[no-any-unimported] if "title" not in json_schema and "title" in (root_schema or {}): json_schema["title"] = (root_schema or {}).get("title") - model_name = json_schema.get("title", "DynamicModel") + model_name = json_schema.get("title") or "DynamicModel" field_definitions = { name: _json_schema_to_pydantic_field( name, prop, json_schema.get("required", []), effective_root @@ -421,9 +489,11 @@ def create_model_from_schema( # type: ignore[no-any-unimported] for name, prop in (json_schema.get("properties", {}) or {}).items() } + effective_config = __config__ or ConfigDict(extra="forbid") + return create_model_base( model_name, - __config__=__config__, + __config__=effective_config, __base__=__base__, __module__=__module__, __validators__=__validators__, @@ -602,8 +672,10 @@ def _json_schema_to_pydantic_type( any_of_schemas = json_schema.get("anyOf", []) + json_schema.get("oneOf", []) if any_of_schemas: any_of_types = [ - _json_schema_to_pydantic_type(schema, root_schema) - for schema in any_of_schemas + _json_schema_to_pydantic_type( + schema, root_schema, name_=f"{name_ or 'Union'}Option{i}" + ) + for i, schema in enumerate(any_of_schemas) ] return Union[tuple(any_of_types)] # noqa: UP007 @@ -639,7 +711,7 @@ def _json_schema_to_pydantic_type( if properties: json_schema_ = json_schema.copy() if json_schema_.get("title") is None: - json_schema_["title"] = name_ + json_schema_["title"] = name_ or "DynamicModel" return create_model_from_schema(json_schema_, root_schema=root_schema) return dict if type_ == "null":