Skip to content

Commit 4398947

Browse files
fix: normalize invalid MCP required flags in MCP schemas (#6077)
* fix: normalize invalid MCP required flags * style: format mcp schema normalization tests * style: sort mcp client imports * fix: preserve nested mcp required flags * test: cover malformed mcp required fields --------- Co-authored-by: Stable Genius <259448942+stablegenius49@users.noreply.github.com> Co-authored-by: エイカク <1259085392z@gmail.com> Co-authored-by: 邹永赫 <1259085392@qq.com>
1 parent ba1e222 commit 4398947

File tree

2 files changed

+175
-2
lines changed

2 files changed

+175
-2
lines changed

astrbot/core/agent/mcp_client.py

Lines changed: 58 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
11
import asyncio
2+
import copy
23
import logging
34
import os
45
import re
56
import sys
67
from contextlib import AsyncExitStack
78
from datetime import timedelta
89
from pathlib import Path, PureWindowsPath
9-
from typing import Generic
10+
from typing import Any, Generic
1011

1112
from tenacity import (
1213
before_sleep_log,
@@ -325,6 +326,61 @@ async def _quick_test_mcp_connection(config: dict) -> tuple[bool, str]:
325326
return False, f"{e!s}"
326327

327328

329+
def _normalize_mcp_input_schema(schema: dict[str, Any]) -> dict[str, Any]:
330+
"""Normalize common non-standard MCP JSON Schema variants.
331+
332+
Some MCP servers incorrectly mark required properties with a boolean
333+
`required: true` on the property schema itself. Draft 2020-12 requires the
334+
parent object to declare `required` as an array of property names instead.
335+
We lift those booleans to the parent object so the schema remains usable
336+
without disabling validation entirely.
337+
"""
338+
339+
def _normalize(node: Any) -> Any:
340+
if isinstance(node, list):
341+
return [_normalize(item) for item in node]
342+
343+
if not isinstance(node, dict):
344+
return node
345+
346+
normalized = {key: _normalize(value) for key, value in node.items()}
347+
348+
properties = normalized.get("properties")
349+
if isinstance(properties, dict):
350+
original_properties = (
351+
node.get("properties")
352+
if isinstance(node.get("properties"), dict)
353+
else {}
354+
)
355+
required = normalized.get("required")
356+
required_list = required[:] if isinstance(required, list) else []
357+
358+
for prop_name, prop_schema in properties.items():
359+
if not isinstance(prop_schema, dict):
360+
continue
361+
362+
original_prop_schema = original_properties.get(prop_name, {})
363+
prop_required = (
364+
original_prop_schema.get("required")
365+
if isinstance(original_prop_schema, dict)
366+
else None
367+
)
368+
if isinstance(prop_required, bool):
369+
if prop_schema.get("required") is prop_required:
370+
prop_schema.pop("required", None)
371+
if prop_required:
372+
required_list.append(prop_name)
373+
374+
if required_list:
375+
normalized["required"] = list(dict.fromkeys(required_list))
376+
elif isinstance(required, list):
377+
normalized.pop("required", None)
378+
379+
return normalized
380+
381+
return _normalize(copy.deepcopy(schema))
382+
383+
328384
class MCPClient:
329385
def __init__(self) -> None:
330386
# Initialize session and client objects
@@ -602,7 +658,7 @@ def __init__(
602658
super().__init__(
603659
name=mcp_tool.name,
604660
description=mcp_tool.description or "",
605-
parameters=mcp_tool.inputSchema,
661+
parameters=_normalize_mcp_input_schema(mcp_tool.inputSchema),
606662
)
607663
self.mcp_tool = mcp_tool
608664
self.mcp_client = mcp_client
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
from types import SimpleNamespace
2+
from unittest.mock import MagicMock
3+
4+
from astrbot.core.agent.mcp_client import MCPTool, _normalize_mcp_input_schema
5+
6+
7+
class TestNormalizeMcpInputSchema:
8+
def test_lifts_property_level_required_booleans_to_parent_required_array(self):
9+
schema = {
10+
"type": "object",
11+
"properties": {
12+
"stock_code": {"type": "string", "required": True},
13+
"market": {"type": "string", "required": False},
14+
},
15+
}
16+
17+
normalized = _normalize_mcp_input_schema(schema)
18+
19+
assert normalized["required"] == ["stock_code"]
20+
assert "required" not in normalized["properties"]["stock_code"]
21+
assert "required" not in normalized["properties"]["market"]
22+
assert schema["properties"]["stock_code"]["required"] is True
23+
24+
def test_preserves_existing_required_arrays_while_fixing_nested_objects(self):
25+
schema = {
26+
"type": "object",
27+
"required": ["server"],
28+
"properties": {
29+
"server": {
30+
"type": "object",
31+
"required": ["transport"],
32+
"properties": {
33+
"transport": {"type": "string"},
34+
"stock_code": {"type": "string", "required": True},
35+
"market": {"type": "string", "required": False},
36+
},
37+
}
38+
},
39+
}
40+
41+
normalized = _normalize_mcp_input_schema(schema)
42+
43+
assert normalized["required"] == ["server"]
44+
assert normalized["properties"]["server"]["required"] == [
45+
"transport",
46+
"stock_code",
47+
]
48+
assert (
49+
"required"
50+
not in normalized["properties"]["server"]["properties"]["stock_code"]
51+
)
52+
assert (
53+
"required" not in normalized["properties"]["server"]["properties"]["market"]
54+
)
55+
56+
def test_preserves_parent_required_flag_for_nested_object_properties(self):
57+
schema = {
58+
"type": "object",
59+
"properties": {
60+
"server": {
61+
"type": "object",
62+
"required": True,
63+
"properties": {
64+
"transport": {"type": "string", "required": True},
65+
},
66+
}
67+
},
68+
}
69+
70+
normalized = _normalize_mcp_input_schema(schema)
71+
72+
assert normalized["required"] == ["server"]
73+
assert normalized["properties"]["server"]["required"] == ["transport"]
74+
assert (
75+
"required"
76+
not in normalized["properties"]["server"]["properties"]["transport"]
77+
)
78+
79+
def test_ignores_non_boolean_required_values_and_non_dict_properties(self):
80+
schema = {
81+
"type": "object",
82+
"properties": {
83+
"server": "invalid-property-schema",
84+
"market": {"type": "string", "required": "yes"},
85+
"stock_code": {"type": "string", "required": True},
86+
},
87+
}
88+
89+
normalized = _normalize_mcp_input_schema(schema)
90+
91+
assert normalized["required"] == ["stock_code"]
92+
assert normalized["properties"]["server"] == "invalid-property-schema"
93+
assert normalized["properties"]["market"]["required"] == "yes"
94+
assert "required" not in normalized["properties"]["stock_code"]
95+
assert schema["properties"]["server"] == "invalid-property-schema"
96+
assert schema["properties"]["market"]["required"] == "yes"
97+
98+
99+
class TestMCPToolSchemaNormalization:
100+
def test_mcp_tool_accepts_property_level_required_booleans(self):
101+
mcp_tool = SimpleNamespace(
102+
name="quote_lookup",
103+
description="Lookup a quote",
104+
inputSchema={
105+
"type": "object",
106+
"properties": {
107+
"stock_code": {"type": "string", "required": True},
108+
"market": {"type": "string", "required": False},
109+
},
110+
},
111+
)
112+
113+
tool = MCPTool(mcp_tool, MagicMock(), "gf-securities")
114+
115+
assert tool.parameters["required"] == ["stock_code"]
116+
assert "required" not in tool.parameters["properties"]["stock_code"]
117+
assert "required" not in tool.parameters["properties"]["market"]

0 commit comments

Comments
 (0)