From 69049c4fb17205f1810beb4252c3e9b145f14ee9 Mon Sep 17 00:00:00 2001 From: Genadij Blinov Date: Wed, 12 Aug 2026 16:36:03 +0300 Subject: [PATCH 1/3] fix(mcp): support MCP 2.0 tool schemas --- src/conductor/mcp/manager.py | 2 +- tests/test_mcp/test_manager.py | 28 ++++++++++++++++++++++------ 2 files changed, 23 insertions(+), 7 deletions(-) diff --git a/src/conductor/mcp/manager.py b/src/conductor/mcp/manager.py index dd9c6bf0..9c4b36e4 100644 --- a/src/conductor/mcp/manager.py +++ b/src/conductor/mcp/manager.py @@ -204,7 +204,7 @@ async def own_connection_lifecycle() -> None: { "name": prefixed_name, "description": tool.description or "", - "input_schema": tool.inputSchema, + "input_schema": tool.model_dump(by_alias=True)["inputSchema"], "server": name, "original_name": tool.name, } diff --git a/tests/test_mcp/test_manager.py b/tests/test_mcp/test_manager.py index 47b0967a..7a355424 100644 --- a/tests/test_mcp/test_manager.py +++ b/tests/test_mcp/test_manager.py @@ -275,12 +275,24 @@ def manager(self) -> Any: return mgr async def test_connect_server_mocked(self, manager: Any) -> None: - """Test connect_server with fully mocked MCP client.""" - # Create mock tool - mock_tool = MagicMock() - mock_tool.name = "search" - mock_tool.description = "Search the web" - mock_tool.inputSchema = {"type": "object", "properties": {"query": {"type": "string"}}} + """Requirement: tool discovery accepts MCP 2.x snake-case model fields.""" + from pydantic import BaseModel, Field + + class MCP2Tool(BaseModel): + name: str + description: str | None = None + input_schema: dict[str, Any] = Field(alias="inputSchema") + + mock_tool = MCP2Tool.model_validate( + { + "name": "search", + "description": "Search the web", + "inputSchema": { + "type": "object", + "properties": {"query": {"type": "string"}}, + }, + } + ) # Create mock list_tools response mock_list_tools_response = MagicMock() @@ -335,6 +347,10 @@ async def test_connect_server_mocked(self, manager: Any) -> None: assert tools[0]["original_name"] == "search" assert tools[0]["server"] == "web-search" assert tools[0]["description"] == "Search the web" + assert tools[0]["input_schema"] == { + "type": "object", + "properties": {"query": {"type": "string"}}, + } # Verify internal state assert "web-search" in manager.sessions From 4bc8c6a0d2528f2484e774df1a9ff93adab5fc06 Mon Sep 17 00:00:00 2001 From: Genadij Blinov Date: Wed, 19 Aug 2026 21:29:15 +0300 Subject: [PATCH 2/3] fix(mcp): read renamed structured_content field on MCP 2.0 call_tool results MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MCP 2.0 applied the same snake_case rename a second time in this file: result.structuredContent at the call_tool site became structured_content. That path failed quietly — the AttributeError was wrapped into a RuntimeError the model read as an ordinary tool failure, with no retry and no error surfacing on the dashboard. Only structured-output tools reached it, since any text content short-circuits the branch. Both rename sites now share one helper that tries the 2.x field name and falls back to the 1.x one via a sentinel (a plain "or" chain is wrong here: structured_content is legitimately None on most 2.x results and would fall through to the legacy name on the common path). model_dump is deliberately not used at the call_tool site — CallToolResult carries the content payload up to max_chars, so dumping per call copies the whole thing. Tests now pin both directions instead of only the 2.x shape: the connect_server test is parametrized over a real mcp.types.Tool (the 1.28.1 shape the lockfile pins) and an MCP-2.x stand-in, and a new call_tool test covers the structured-content branch against both the real CallToolResult and a 2.x-shaped stand-in. Each one-name-only read was mutation-checked to fail the opposite parametrization. The pydantic stand-ins moved to module scope; unlike the module's other local imports, pydantic has no MCP_SDK_AVAILABLE constraint. The changelog entry now covers the tool-call path as well as discovery. --- src/conductor/mcp/manager.py | 20 +++++- tests/test_mcp/test_manager.py | 107 +++++++++++++++++++++++++++------ 2 files changed, 105 insertions(+), 22 deletions(-) diff --git a/src/conductor/mcp/manager.py b/src/conductor/mcp/manager.py index 9c4b36e4..a4a4283b 100644 --- a/src/conductor/mcp/manager.py +++ b/src/conductor/mcp/manager.py @@ -45,6 +45,17 @@ MCP_SDK_AVAILABLE = ClientSession is not None +_MISSING: Any = object() + + +def _mcp_field(model: Any, current_name: str, legacy_name: str) -> Any: + """Read a model field renamed between MCP 1.x and 2.x.""" + value = getattr(model, current_name, _MISSING) + if value is _MISSING: + value = getattr(model, legacy_name) + return value + + # Marker constants. The generic hint is embedded by the manager and replaced # with the fs hint by Claude's agentic loop when filesystem-like tools are # available. No placeholder mechanism is used; callers replace the exact @@ -204,7 +215,9 @@ async def own_connection_lifecycle() -> None: { "name": prefixed_name, "description": tool.description or "", - "input_schema": tool.model_dump(by_alias=True)["inputSchema"], + # model_dump(by_alias=True) works for Tool, but the + # helper keeps both rename sites in one idiom. + "input_schema": _mcp_field(tool, "input_schema", "inputSchema"), "server": name, "original_name": tool.name, } @@ -327,8 +340,9 @@ async def call_tool( response_text = "\n".join(text_parts) if text_parts else "" # If no text content, try structured content - if not response_text and result.structuredContent: - response_text = str(result.structuredContent) + structured = _mcp_field(result, "structured_content", "structuredContent") + if not response_text and structured: + response_text = str(structured) try: response_text = self._maybe_truncate_response( diff --git a/tests/test_mcp/test_manager.py b/tests/test_mcp/test_manager.py index 7a355424..8fe89cf8 100644 --- a/tests/test_mcp/test_manager.py +++ b/tests/test_mcp/test_manager.py @@ -17,6 +17,53 @@ import anyio import pytest +from pydantic import BaseModel, Field + + +class MCP2Tool(BaseModel): + """MCP 2.x-shaped stand-in for Tool with snake-case field names.""" + + name: str + description: str | None = None + input_schema: dict[str, Any] = Field(alias="inputSchema") + + +class MCP2CallToolResult(BaseModel): + """MCP 2.x-shaped stand-in for CallToolResult with snake-case fields.""" + + content: list[Any] = [] + structured_content: Any | None = Field(alias="structuredContent", default=None) + isError: bool = False + + +def _make_mcp1_tool() -> Any: + """Build a real mcp.types.Tool using its 1.x field name.""" + from mcp.types import Tool + + return Tool.model_validate( + { + "name": "search", + "description": "Search the web", + "inputSchema": { + "type": "object", + "properties": {"query": {"type": "string"}}, + }, + } + ) + + +def _make_mcp2_tool() -> MCP2Tool: + """Build an MCP 2.x-shaped Tool stand-in.""" + return MCP2Tool.model_validate( + { + "name": "search", + "description": "Search the web", + "inputSchema": { + "type": "object", + "properties": {"query": {"type": "string"}}, + }, + } + ) class _TaskAffineMCP: @@ -261,6 +308,37 @@ async def test_call_tool_with_mock_session(self, manager: Any) -> None: assert result == "Tool result" mock_session.call_tool.assert_called_once_with("my-tool", arguments={"arg": "value"}) + async def test_call_tool_structured_content_both_shapes(self, manager: Any) -> None: + """Requirement: call_tool reads structuredContent from both MCP 1.x and 2.x results.""" + from mcp.types import CallToolResult + + async def _run_case(result: Any) -> str: + mock_session = AsyncMock() + mock_session.call_tool.return_value = result + + manager.tool_to_server["test-server__my-tool"] = "test-server" + manager.sessions["test-server"] = mock_session + + return await manager.call_tool("test-server__my-tool", {"arg": "value"}) + + real_result = CallToolResult.model_validate( + { + "content": [], + "structuredContent": {"answer": 42}, + "isError": False, + } + ) + assert await _run_case(real_result) == str({"answer": 42}) + + mcp2_result = MCP2CallToolResult.model_validate( + { + "content": [], + "structuredContent": {"answer": 42}, + "isError": False, + } + ) + assert await _run_case(mcp2_result) == str({"answer": 42}) + class TestMCPManagerConnectServer: """Tests for MCPManager.connect_server method (with mocked MCP client).""" @@ -274,25 +352,16 @@ def manager(self) -> Any: mgr = MCPManager() return mgr - async def test_connect_server_mocked(self, manager: Any) -> None: - """Requirement: tool discovery accepts MCP 2.x snake-case model fields.""" - from pydantic import BaseModel, Field - - class MCP2Tool(BaseModel): - name: str - description: str | None = None - input_schema: dict[str, Any] = Field(alias="inputSchema") - - mock_tool = MCP2Tool.model_validate( - { - "name": "search", - "description": "Search the web", - "inputSchema": { - "type": "object", - "properties": {"query": {"type": "string"}}, - }, - } - ) + @pytest.mark.parametrize( + "make_tool", + [ + pytest.param(_make_mcp1_tool, id="mcp1-real"), + pytest.param(_make_mcp2_tool, id="mcp2-standin"), + ], + ) + async def test_connect_server_mocked(self, manager: Any, make_tool: Any) -> None: + """Requirement: tool discovery reads both MCP 1.x and 2.x Tool field names.""" + mock_tool = make_tool() # Create mock list_tools response mock_list_tools_response = MagicMock() From 0ba4db4dd736398803e3a103603d74461f6e1973 Mon Sep 17 00:00:00 2001 From: Genadij Blinov Date: Wed, 19 Aug 2026 21:33:48 +0300 Subject: [PATCH 3/3] docs(changelog): record the MCP 2.0 field-rename fix --- CHANGELOG.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 922ebb9d..c9d54ef5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased](https://github.com/microsoft/conductor/compare/v0.1.33...HEAD) +### Fixed + +- **MCP tool discovery and structured tool results no longer break with MCP + 2.0** (#419). MCP 2.0 renamed the Python field on `mcp.types.Tool` from + `inputSchema` to `input_schema` and on `mcp.types.CallToolResult` from + `structuredContent` to `structured_content`, retaining the camelCase name as + the serialization alias in both cases. The second rename failed quietly: a + tool returning only structured content raised `AttributeError`, which was + wrapped into a `RuntimeError` the model read as an ordinary tool failure. + Conductor now reads both fields through a shared helper that tries the 2.x + name and falls back to the 1.x one, preserving compatibility with both MCP + 1.x and 2.x. + ## [0.1.33](https://github.com/microsoft/conductor/compare/v0.1.32...v0.1.33) - 2026-08-18 ### Added