fix: normalize MCP tool schema for OpenAI-compatible providers
This commit is contained in:
committed by
Xubin Ren
parent
9f10ce072f
commit
b6cf7020ac
@@ -11,6 +11,105 @@ from nanobot.agent.tools.base import Tool
|
|||||||
from nanobot.agent.tools.registry import ToolRegistry
|
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):
|
class MCPToolWrapper(Tool):
|
||||||
"""Wraps a single MCP server tool as a nanobot 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._original_name = tool_def.name
|
||||||
self._name = f"mcp_{server_name}_{tool_def.name}"
|
self._name = f"mcp_{server_name}_{tool_def.name}"
|
||||||
self._description = tool_def.description or 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
|
self._tool_timeout = tool_timeout
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
|||||||
Reference in New Issue
Block a user