diff --git a/src/openharness/tools/mcp_tool.py b/src/openharness/tools/mcp_tool.py index 095db7e..ed79dd9 100644 --- a/src/openharness/tools/mcp_tool.py +++ b/src/openharness/tools/mcp_tool.py @@ -29,13 +29,23 @@ class McpToolAdapter(BaseTool): output = await self._manager.call_tool( self._tool_info.server_name, self._tool_info.name, - arguments.model_dump(mode="json"), + arguments.model_dump(mode="json", exclude_none=True), ) except McpServerNotConnectedError as exc: return ToolResult(output=str(exc), is_error=True) return ToolResult(output=output) +_JSON_TYPE_MAP: dict[str, type] = { + "string": str, + "integer": int, + "number": float, + "boolean": bool, + "array": list, + "object": dict, +} + + def _input_model_from_schema(tool_name: str, schema: dict[str, object]) -> type[BaseModel]: properties = schema.get("properties", {}) if not isinstance(properties, dict): @@ -44,8 +54,12 @@ def _input_model_from_schema(tool_name: str, schema: dict[str, object]) -> type[ fields = {} required = set(schema.get("required", [])) if isinstance(schema.get("required", []), list) else set() for key in properties: - default = ... if key in required else None - fields[key] = (object | None, Field(default=default)) + prop = properties[key] if isinstance(properties[key], dict) else {} + py_type = _JSON_TYPE_MAP.get(str(prop.get("type", "")), object) + if key in required: + fields[key] = (py_type, Field(default=...)) + else: + fields[key] = (py_type | None, Field(default=None)) return create_model(f"{tool_name.title().replace('-', '_')}Input", **fields) diff --git a/tests/test_tools/test_mcp_tool.py b/tests/test_tools/test_mcp_tool.py new file mode 100644 index 0000000..655ed9f --- /dev/null +++ b/tests/test_tools/test_mcp_tool.py @@ -0,0 +1,94 @@ +"""Tests for MCP tool adapters — input model generation and argument serialization.""" + +import pytest +from pydantic import ValidationError + +from openharness.tools.mcp_tool import _input_model_from_schema + + +class TestInputModelFromSchema: + """Verify _input_model_from_schema maps JSON Schema types correctly.""" + + def test_required_string_rejects_none(self): + schema = { + "type": "object", + "properties": {"query": {"type": "string"}}, + "required": ["query"], + } + Model = _input_model_from_schema("search", schema) + with pytest.raises(ValidationError): + Model(query=None) + + def test_required_string_accepts_value(self): + schema = { + "type": "object", + "properties": {"query": {"type": "string"}}, + "required": ["query"], + } + Model = _input_model_from_schema("search", schema) + m = Model(query="zigzag") + assert m.query == "zigzag" + + def test_optional_string_defaults_to_none(self): + schema = { + "type": "object", + "properties": { + "query": {"type": "string"}, + "wing": {"type": "string"}, + }, + "required": ["query"], + } + Model = _input_model_from_schema("search", schema) + m = Model(query="test") + assert m.wing is None + + def test_exclude_none_omits_optional_keeps_required(self): + schema = { + "type": "object", + "properties": { + "query": {"type": "string"}, + "wing": {"type": "string"}, + "limit": {"type": "integer"}, + }, + "required": ["query"], + } + Model = _input_model_from_schema("search", schema) + m = Model(query="test") + dumped = m.model_dump(mode="json", exclude_none=True) + assert dumped == {"query": "test"} + + def test_all_json_types_mapped(self): + schema = { + "type": "object", + "properties": { + "name": {"type": "string"}, + "count": {"type": "integer"}, + "score": {"type": "number"}, + "active": {"type": "boolean"}, + "tags": {"type": "array"}, + "meta": {"type": "object"}, + }, + "required": ["name", "count", "score", "active", "tags", "meta"], + } + Model = _input_model_from_schema("full", schema) + m = Model(name="x", count=1, score=0.5, active=True, tags=["a"], meta={"k": "v"}) + dumped = m.model_dump(mode="json") + assert dumped == { + "name": "x", "count": 1, "score": 0.5, + "active": True, "tags": ["a"], "meta": {"k": "v"}, + } + + def test_empty_schema_creates_valid_model(self): + Model = _input_model_from_schema("empty", {"type": "object"}) + m = Model() + assert m.model_dump(mode="json") == {} + + def test_model_rejects_null_for_required_integer(self): + schema = { + "type": "object", + "properties": {"limit": {"type": "integer"}}, + "required": ["limit"], + } + Model = _input_model_from_schema("limited", schema) + with pytest.raises(ValidationError): + Model(limit=None)