Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
20 changes: 17 additions & 3 deletions src/conductor/mcp/manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -204,7 +215,9 @@ async def own_connection_lifecycle() -> None:
{
"name": prefixed_name,
"description": tool.description or "",
"input_schema": tool.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,
}
Expand Down Expand Up @@ -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(
Expand Down
99 changes: 92 additions & 7 deletions tests/test_mcp/test_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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)."""
Expand All @@ -274,13 +352,16 @@ def manager(self) -> Any:
mgr = MCPManager()
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"}}}
@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()
Expand Down Expand Up @@ -335,6 +416,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
Expand Down
Loading