fix: adapt mcp tools; sanitize pydantic json schemas

This commit is contained in:
Greyson LaLonde
2026-02-03 14:53:39 -05:00
parent c61bf62751
commit 6dbbc5724c
2 changed files with 168 additions and 41 deletions

View File

@@ -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

View File

@@ -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":