diff --git a/nanobot/agent/tools/mcp.py b/nanobot/agent/tools/mcp.py index cebfbd2..b64bc05 100644 --- a/nanobot/agent/tools/mcp.py +++ b/nanobot/agent/tools/mcp.py @@ -11,6 +11,105 @@ from nanobot.agent.tools.base import Tool from nanobot.agent.tools.registry import ToolRegistry +def _normalize_schema_for_openai(schema: dict[str, Any]) -> dict[str, Any]: + """Normalize JSON Schema for OpenAI-compatible providers. + + OpenAI's API (and many compatible providers) only supports a subset of JSON Schema: + - Top-level type must be 'object' + - No oneOf/anyOf/allOf/enum/not at the top level + - Properties should have simple types + """ + if not isinstance(schema, dict): + return {"type": "object", "properties": {}} + + # If schema has oneOf/anyOf/allOf at top level, try to extract the first option + for key in ["oneOf", "anyOf", "allOf"]: + if key in schema: + options = schema[key] + if isinstance(options, list) and len(options) > 0: + # Use the first option as the base schema + first_option = options[0] + if isinstance(first_option, dict): + # Merge with other schema properties, preferring the first option + normalized = dict(schema) + del normalized[key] + normalized.update(first_option) + return _normalize_schema_for_openai(normalized) + + # Ensure top-level type is object + if schema.get("type") != "object": + # If no type specified or different type, default to object + schema = {"type": "object", **{k: v for k, v in schema.items() if k != "type"}} + + # Clean up unsupported properties at top level + unsupported = ["enum", "not", "const"] + for key in unsupported: + schema.pop(key, None) + + # Ensure properties and required exist + if "properties" not in schema: + schema["properties"] = {} + if "required" not in schema: + schema["required"] = [] + + # Recursively normalize nested property schemas + if "properties" in schema and isinstance(schema["properties"], dict): + for prop_name, prop_schema in schema["properties"].items(): + if isinstance(prop_schema, dict): + schema["properties"][prop_name] = _normalize_property_schema(prop_schema) + + return schema + + +def _normalize_property_schema(schema: dict[str, Any]) -> dict[str, Any]: + """Normalize a property schema for OpenAI compatibility.""" + if not isinstance(schema, dict): + return {"type": "string"} + + # Handle oneOf/anyOf in properties + for key in ["oneOf", "anyOf"]: + if key in schema: + options = schema[key] + if isinstance(options, list) and len(options) > 0: + first_option = options[0] + if isinstance(first_option, dict): + # Replace the complex schema with the first option + result = {k: v for k, v in schema.items() if k not in [key, "allOf", "not"]} + result.update(first_option) + return _normalize_property_schema(result) + + # Handle allOf by merging all subschemas + if "allOf" in schema: + subschemas = schema["allOf"] + if isinstance(subschemas, list): + merged = {} + for sub in subschemas: + if isinstance(sub, dict): + merged.update(sub) + # Remove allOf and merge with other properties + result = {k: v for k, v in schema.items() if k != "allOf"} + result.update(merged) + return _normalize_property_schema(result) + + # Ensure type is simple + if "type" not in schema: + # Try to infer type from other properties + if "enum" in schema: + schema["type"] = "string" + elif "properties" in schema: + schema["type"] = "object" + elif "items" in schema: + schema["type"] = "array" + else: + schema["type"] = "string" + + # Clean up not/const + schema.pop("not", None) + schema.pop("const", None) + + return schema + + class MCPToolWrapper(Tool): """Wraps a single MCP server tool as a nanobot Tool.""" @@ -19,7 +118,8 @@ class MCPToolWrapper(Tool): self._original_name = tool_def.name self._name = f"mcp_{server_name}_{tool_def.name}" self._description = tool_def.description or tool_def.name - self._parameters = tool_def.inputSchema or {"type": "object", "properties": {}} + raw_schema = tool_def.inputSchema or {"type": "object", "properties": {}} + self._parameters = _normalize_schema_for_openai(raw_schema) self._tool_timeout = tool_timeout @property