Skip to content

Commit 4c287c2

Browse files
giles17CopilotCopilot
authored
Python: Fix MCP tool schema normalization for zero-argument tools missing 'properties' key (#4771)
* Fix zero-argument MCP tool schema missing 'properties' key (#4540) MCP servers for zero-argument tools (e.g. matlab-mcp-core-server's detect_matlab_toolboxes) declare inputSchema as {"type": "object"} without a "properties" key. OpenAI's API requires "properties" to be present on object schemas, causing a 400 invalid_request_error. Normalize inputSchema at MCP ingestion in load_tools() to inject an empty "properties": {} when it is missing from object-type schemas. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Address review feedback for #4540: improve test robustness and add defensive guard - Look up loaded functions by name instead of index to avoid brittle ordering assumptions - Add negative-path test cases: non-object schema (type: string) and empty schema ({}) to verify guard clause skips them correctly - Assert original inputSchema dicts are not mutated by load_tools() - Add defensive guard for tool.inputSchema being None Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Address review feedback for #4540: Python: [Bug]: Local stdio MCP works for calculator but fails for official matlab-mcp-core-server on LM Studio /v1/responses --------- Co-authored-by: Copilot <copilot@github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 100086a commit 4c287c2

2 files changed

Lines changed: 103 additions & 1 deletion

File tree

python/packages/core/agent_framework/_mcp.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -902,13 +902,21 @@ async def load_tools(self) -> None:
902902
continue
903903

904904
approval_mode = self._determine_approval_mode(local_name, normalized_name, tool.name)
905+
# Normalize inputSchema: ensure "properties" exists for object schemas.
906+
# Some MCP servers (e.g. zero-argument tools) omit "properties",
907+
# which causes OpenAI API to reject the schema with a 400 error.
908+
# Guard against non-conforming MCP servers that send inputSchema=None
909+
# despite the MCP spec typing it as dict[str, Any].
910+
input_schema = dict(tool.inputSchema or {})
911+
if input_schema.get("type") == "object" and "properties" not in input_schema:
912+
input_schema["properties"] = {}
905913
# Create FunctionTools out of each tool
906914
func: FunctionTool = FunctionTool(
907915
func=partial(self.call_tool, tool.name),
908916
name=local_name,
909917
description=tool.description or "",
910918
approval_mode=approval_mode,
911-
input_model=tool.inputSchema,
919+
input_model=input_schema,
912920
additional_properties={
913921
_MCP_REMOTE_NAME_KEY: tool.name,
914922
_MCP_NORMALIZED_NAME_KEY: normalized_name,

python/packages/core/tests/core/test_mcp.py

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2042,6 +2042,100 @@ async def mock_list_tools(params=None):
20422042
assert [f.name for f in tool._functions] == ["tool_1", "tool_2", "tool_3", "tool_4"]
20432043

20442044

2045+
async def test_load_tools_adds_properties_to_zero_arg_tool_schema():
2046+
"""Test that load_tools normalizes inputSchema for zero-argument MCP tools.
2047+
2048+
Some MCP servers (e.g. matlab-mcp-core-server) declare zero-argument tools
2049+
with inputSchema={"type": "object"} and no "properties" key. OpenAI's API
2050+
requires "properties" to be present on object schemas, so load_tools must
2051+
inject an empty "properties" dict when it is missing.
2052+
"""
2053+
from unittest.mock import AsyncMock, MagicMock
2054+
2055+
from agent_framework._mcp import MCPTool
2056+
2057+
tool = MCPTool(name="test_tool")
2058+
2059+
mock_session = AsyncMock()
2060+
tool.session = mock_session
2061+
tool.load_tools_flag = True
2062+
2063+
original_zero_arg_schema = {"type": "object"}
2064+
original_string_schema = {"type": "string"}
2065+
original_empty_schema: dict[str, object] = {}
2066+
2067+
page = MagicMock()
2068+
page.tools = [
2069+
types.Tool(
2070+
name="zero_arg_tool",
2071+
description="A tool with no parameters",
2072+
inputSchema=original_zero_arg_schema,
2073+
),
2074+
types.Tool(
2075+
name="normal_tool",
2076+
description="A tool with parameters",
2077+
inputSchema={"type": "object", "properties": {"x": {"type": "string"}}, "required": ["x"]},
2078+
),
2079+
types.Tool(
2080+
name="string_schema_tool",
2081+
description="A tool with a non-object schema",
2082+
inputSchema=original_string_schema,
2083+
),
2084+
types.Tool(
2085+
name="empty_schema_tool",
2086+
description="A tool with an empty schema",
2087+
inputSchema=original_empty_schema,
2088+
),
2089+
]
2090+
2091+
# Simulate a non-conforming MCP server that sends inputSchema=None.
2092+
# types.Tool requires inputSchema to be a dict, so we use a MagicMock.
2093+
none_schema_tool = MagicMock()
2094+
none_schema_tool.name = "none_schema_tool"
2095+
none_schema_tool.description = "A tool with None inputSchema"
2096+
none_schema_tool.inputSchema = None
2097+
page.tools.append(none_schema_tool)
2098+
page.nextCursor = None
2099+
2100+
mock_session.list_tools = AsyncMock(return_value=page)
2101+
2102+
await tool.load_tools()
2103+
2104+
assert len(tool._functions) == 5
2105+
2106+
funcs_by_name = {f.name: f for f in tool._functions}
2107+
2108+
# Zero-arg tool must have "properties" injected
2109+
zero_params = funcs_by_name["zero_arg_tool"].parameters()
2110+
assert "properties" in zero_params
2111+
assert zero_params["properties"] == {}
2112+
assert zero_params["type"] == "object"
2113+
2114+
# Normal tool must retain its existing properties
2115+
normal_params = funcs_by_name["normal_tool"].parameters()
2116+
assert "properties" in normal_params
2117+
assert "x" in normal_params["properties"]
2118+
assert normal_params["required"] == ["x"]
2119+
2120+
# Non-object schema must NOT have "properties" injected
2121+
string_params = funcs_by_name["string_schema_tool"].parameters()
2122+
assert "properties" not in string_params
2123+
assert string_params["type"] == "string"
2124+
2125+
# Empty schema (no "type" key) must NOT have "properties" injected
2126+
empty_params = funcs_by_name["empty_schema_tool"].parameters()
2127+
assert "properties" not in empty_params
2128+
2129+
# None inputSchema must produce an empty dict (guard against non-conforming servers)
2130+
none_params = funcs_by_name["none_schema_tool"].parameters()
2131+
assert none_params == {}
2132+
2133+
# Original inputSchema dicts must not be mutated
2134+
assert "properties" not in original_zero_arg_schema
2135+
assert "properties" not in original_string_schema
2136+
assert "properties" not in original_empty_schema
2137+
2138+
20452139
async def test_load_prompts_with_pagination():
20462140
"""Test that load_prompts handles pagination correctly."""
20472141
from unittest.mock import AsyncMock, MagicMock

0 commit comments

Comments
 (0)