From 1a2cc0476dd9a65f031a55ffac1ad2870331de65 Mon Sep 17 00:00:00 2001 From: Stable Genius <259448942+stablegenius49@users.noreply.github.com> Date: Wed, 11 Mar 2026 13:06:41 -0700 Subject: [PATCH 1/5] fix: normalize invalid MCP required flags --- astrbot/core/agent/mcp_client.py | 54 ++++++++++++++++++++- tests/unit/test_mcp_client_schema.py | 70 ++++++++++++++++++++++++++++ 2 files changed, 122 insertions(+), 2 deletions(-) create mode 100644 tests/unit/test_mcp_client_schema.py diff --git a/astrbot/core/agent/mcp_client.py b/astrbot/core/agent/mcp_client.py index a8ff0fdb90..e514410a2b 100644 --- a/astrbot/core/agent/mcp_client.py +++ b/astrbot/core/agent/mcp_client.py @@ -1,8 +1,9 @@ import asyncio +import copy import logging from contextlib import AsyncExitStack from datetime import timedelta -from typing import Generic +from typing import Any, Generic from tenacity import ( before_sleep_log, @@ -107,6 +108,55 @@ async def _quick_test_mcp_connection(config: dict) -> tuple[bool, str]: return False, f"{e!s}" +def _normalize_mcp_input_schema(schema: dict[str, Any]) -> dict[str, Any]: + """Normalize common non-standard MCP JSON Schema variants. + + Some MCP servers incorrectly mark required properties with a boolean + `required: true` on the property schema itself. Draft 2020-12 requires the + parent object to declare `required` as an array of property names instead. + We lift those booleans to the parent object so the schema remains usable + without disabling validation entirely. + """ + + def _normalize(node: Any) -> Any: + if isinstance(node, list): + return [_normalize(item) for item in node] + + if not isinstance(node, dict): + return node + + normalized = {key: _normalize(value) for key, value in node.items()} + + properties = normalized.get("properties") + if isinstance(properties, dict): + required = normalized.get("required") + required_list = required[:] if isinstance(required, list) else [] + + for prop_name, prop_schema in properties.items(): + if not isinstance(prop_schema, dict): + continue + + prop_required = prop_schema.get("required") + if isinstance(prop_required, bool): + prop_schema.pop("required", None) + if prop_required: + required_list.append(prop_name) + + if required_list: + seen: set[str] = set() + normalized["required"] = [ + name + for name in required_list + if not (name in seen or seen.add(name)) + ] + elif isinstance(required, list): + normalized.pop("required", None) + + return normalized + + return _normalize(copy.deepcopy(schema)) + + class MCPClient: def __init__(self) -> None: # Initialize session and client objects @@ -382,7 +432,7 @@ def __init__( super().__init__( name=mcp_tool.name, description=mcp_tool.description or "", - parameters=mcp_tool.inputSchema, + parameters=_normalize_mcp_input_schema(mcp_tool.inputSchema), ) self.mcp_tool = mcp_tool self.mcp_client = mcp_client diff --git a/tests/unit/test_mcp_client_schema.py b/tests/unit/test_mcp_client_schema.py new file mode 100644 index 0000000000..d8d0246349 --- /dev/null +++ b/tests/unit/test_mcp_client_schema.py @@ -0,0 +1,70 @@ +from types import SimpleNamespace +from unittest.mock import MagicMock + +from astrbot.core.agent.mcp_client import MCPTool, _normalize_mcp_input_schema + + +class TestNormalizeMcpInputSchema: + def test_lifts_property_level_required_booleans_to_parent_required_array(self): + schema = { + "type": "object", + "properties": { + "stock_code": {"type": "string", "required": True}, + "market": {"type": "string", "required": False}, + }, + } + + normalized = _normalize_mcp_input_schema(schema) + + assert normalized["required"] == ["stock_code"] + assert "required" not in normalized["properties"]["stock_code"] + assert "required" not in normalized["properties"]["market"] + assert schema["properties"]["stock_code"]["required"] is True + + def test_preserves_existing_required_arrays_while_fixing_nested_objects(self): + schema = { + "type": "object", + "required": ["server"], + "properties": { + "server": { + "type": "object", + "required": ["transport"], + "properties": { + "transport": {"type": "string"}, + "stock_code": {"type": "string", "required": True}, + "market": {"type": "string", "required": False}, + }, + } + }, + } + + normalized = _normalize_mcp_input_schema(schema) + + assert normalized["required"] == ["server"] + assert normalized["properties"]["server"]["required"] == [ + "transport", + "stock_code", + ] + assert "required" not in normalized["properties"]["server"]["properties"]["stock_code"] + assert "required" not in normalized["properties"]["server"]["properties"]["market"] + + +class TestMCPToolSchemaNormalization: + def test_mcp_tool_accepts_property_level_required_booleans(self): + mcp_tool = SimpleNamespace( + name="quote_lookup", + description="Lookup a quote", + inputSchema={ + "type": "object", + "properties": { + "stock_code": {"type": "string", "required": True}, + "market": {"type": "string", "required": False}, + }, + }, + ) + + tool = MCPTool(mcp_tool, MagicMock(), "gf-securities") + + assert tool.parameters["required"] == ["stock_code"] + assert "required" not in tool.parameters["properties"]["stock_code"] + assert "required" not in tool.parameters["properties"]["market"] From 6de21a09a12292a18a537dcd0a0b2eb0ecc04c2f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=82=B9=E6=B0=B8=E8=B5=AB?= <1259085392@qq.com> Date: Mon, 20 Apr 2026 11:54:25 +0900 Subject: [PATCH 2/5] style: format mcp schema normalization tests --- tests/unit/test_mcp_client_schema.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/tests/unit/test_mcp_client_schema.py b/tests/unit/test_mcp_client_schema.py index d8d0246349..6c922a61df 100644 --- a/tests/unit/test_mcp_client_schema.py +++ b/tests/unit/test_mcp_client_schema.py @@ -45,8 +45,13 @@ def test_preserves_existing_required_arrays_while_fixing_nested_objects(self): "transport", "stock_code", ] - assert "required" not in normalized["properties"]["server"]["properties"]["stock_code"] - assert "required" not in normalized["properties"]["server"]["properties"]["market"] + assert ( + "required" + not in normalized["properties"]["server"]["properties"]["stock_code"] + ) + assert ( + "required" not in normalized["properties"]["server"]["properties"]["market"] + ) class TestMCPToolSchemaNormalization: From 21b571ea58f64e5b8277b900d4fc22384abf3564 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=82=B9=E6=B0=B8=E8=B5=AB?= <1259085392@qq.com> Date: Mon, 20 Apr 2026 12:11:16 +0900 Subject: [PATCH 3/5] style: sort mcp client imports --- astrbot/core/agent/mcp_client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/astrbot/core/agent/mcp_client.py b/astrbot/core/agent/mcp_client.py index 1629410475..ffcb13080f 100644 --- a/astrbot/core/agent/mcp_client.py +++ b/astrbot/core/agent/mcp_client.py @@ -6,8 +6,8 @@ import sys from contextlib import AsyncExitStack from datetime import timedelta -from typing import Any, Generic from pathlib import Path, PureWindowsPath +from typing import Any, Generic from tenacity import ( before_sleep_log, From b3478d5d8b1bc8c2f94ddd8c4e5277a3a1dcdc73 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=82=B9=E6=B0=B8=E8=B5=AB?= <1259085392@qq.com> Date: Mon, 20 Apr 2026 13:20:23 +0900 Subject: [PATCH 4/5] fix: preserve nested mcp required flags --- astrbot/core/agent/mcp_client.py | 15 +++++++++++++-- tests/unit/test_mcp_client_schema.py | 23 +++++++++++++++++++++++ 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/astrbot/core/agent/mcp_client.py b/astrbot/core/agent/mcp_client.py index ffcb13080f..f44223eaf1 100644 --- a/astrbot/core/agent/mcp_client.py +++ b/astrbot/core/agent/mcp_client.py @@ -347,6 +347,11 @@ def _normalize(node: Any) -> Any: properties = normalized.get("properties") if isinstance(properties, dict): + original_properties = ( + node.get("properties") + if isinstance(node.get("properties"), dict) + else {} + ) required = normalized.get("required") required_list = required[:] if isinstance(required, list) else [] @@ -354,9 +359,15 @@ def _normalize(node: Any) -> Any: if not isinstance(prop_schema, dict): continue - prop_required = prop_schema.get("required") + original_prop_schema = original_properties.get(prop_name, {}) + prop_required = ( + original_prop_schema.get("required") + if isinstance(original_prop_schema, dict) + else None + ) if isinstance(prop_required, bool): - prop_schema.pop("required", None) + if prop_schema.get("required") is prop_required: + prop_schema.pop("required", None) if prop_required: required_list.append(prop_name) diff --git a/tests/unit/test_mcp_client_schema.py b/tests/unit/test_mcp_client_schema.py index 6c922a61df..525d3009a1 100644 --- a/tests/unit/test_mcp_client_schema.py +++ b/tests/unit/test_mcp_client_schema.py @@ -53,6 +53,29 @@ def test_preserves_existing_required_arrays_while_fixing_nested_objects(self): "required" not in normalized["properties"]["server"]["properties"]["market"] ) + def test_preserves_parent_required_flag_for_nested_object_properties(self): + schema = { + "type": "object", + "properties": { + "server": { + "type": "object", + "required": True, + "properties": { + "transport": {"type": "string", "required": True}, + }, + } + }, + } + + normalized = _normalize_mcp_input_schema(schema) + + assert normalized["required"] == ["server"] + assert normalized["properties"]["server"]["required"] == ["transport"] + assert ( + "required" + not in normalized["properties"]["server"]["properties"]["transport"] + ) + class TestMCPToolSchemaNormalization: def test_mcp_tool_accepts_property_level_required_booleans(self): From 26e2002d2d3359042a09c0cabdda31473fc07b55 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=82=B9=E6=B0=B8=E8=B5=AB?= <1259085392@qq.com> Date: Mon, 20 Apr 2026 13:25:16 +0900 Subject: [PATCH 5/5] test: cover malformed mcp required fields --- astrbot/core/agent/mcp_client.py | 7 +------ tests/unit/test_mcp_client_schema.py | 19 +++++++++++++++++++ 2 files changed, 20 insertions(+), 6 deletions(-) diff --git a/astrbot/core/agent/mcp_client.py b/astrbot/core/agent/mcp_client.py index f44223eaf1..b75999ea65 100644 --- a/astrbot/core/agent/mcp_client.py +++ b/astrbot/core/agent/mcp_client.py @@ -372,12 +372,7 @@ def _normalize(node: Any) -> Any: required_list.append(prop_name) if required_list: - seen: set[str] = set() - normalized["required"] = [ - name - for name in required_list - if not (name in seen or seen.add(name)) - ] + normalized["required"] = list(dict.fromkeys(required_list)) elif isinstance(required, list): normalized.pop("required", None) diff --git a/tests/unit/test_mcp_client_schema.py b/tests/unit/test_mcp_client_schema.py index 525d3009a1..0c3d9bc6ae 100644 --- a/tests/unit/test_mcp_client_schema.py +++ b/tests/unit/test_mcp_client_schema.py @@ -76,6 +76,25 @@ def test_preserves_parent_required_flag_for_nested_object_properties(self): not in normalized["properties"]["server"]["properties"]["transport"] ) + def test_ignores_non_boolean_required_values_and_non_dict_properties(self): + schema = { + "type": "object", + "properties": { + "server": "invalid-property-schema", + "market": {"type": "string", "required": "yes"}, + "stock_code": {"type": "string", "required": True}, + }, + } + + normalized = _normalize_mcp_input_schema(schema) + + assert normalized["required"] == ["stock_code"] + assert normalized["properties"]["server"] == "invalid-property-schema" + assert normalized["properties"]["market"]["required"] == "yes" + assert "required" not in normalized["properties"]["stock_code"] + assert schema["properties"]["server"] == "invalid-property-schema" + assert schema["properties"]["market"]["required"] == "yes" + class TestMCPToolSchemaNormalization: def test_mcp_tool_accepts_property_level_required_booleans(self):