Skip to content

Commit 32bcd36

Browse files
isaacbmillerampagentIsaac Miller
authored
feat: support MCP SDK v2 without changing result semantics (#10188)
Amp-Thread-ID: https://ampcode.com/threads/T-019fddd4-5966-702a-a38e-43dfa7bc9fa1 Co-authored-by: Amp <amp@ampcode.com> Co-authored-by: Isaac Miller <isaac@cmpnd.ai>
1 parent 0b93fb1 commit 32bcd36

8 files changed

Lines changed: 144 additions & 25 deletions

File tree

‎.github/workflows/run_tests.yml‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,34 @@ jobs:
133133
- name: Run extra and deno tests
134134
run: uv run -p .venv pytest tests/ -m 'extra or deno' --extra --deno -n auto --dist worksteal
135135

136+
mcp_v2_test:
137+
name: Run MCP v2 Tests
138+
runs-on: ubuntu-latest
139+
permissions:
140+
contents: read
141+
steps:
142+
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
143+
with:
144+
persist-credentials: false
145+
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
146+
with:
147+
python-version: "3.11"
148+
- name: Install uv with caching
149+
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
150+
with:
151+
enable-cache: true
152+
cache-dependency-glob: |
153+
**/pyproject.toml
154+
**/uv.lock
155+
- name: Create environment
156+
run: uv venv .venv
157+
- name: Install dependencies with MCP v2
158+
run: |
159+
uv sync -p .venv --extra dev
160+
uv pip install -p .venv "mcp==2.0.0"
161+
- name: Run MCP tests
162+
run: uv run --frozen --no-sync -p .venv pytest tests/utils/test_mcp.py -m extra --extra
163+
136164
llm_call_test:
137165
name: Run Tests with Real LM
138166
runs-on: ubuntu-latest

‎docs/docs/diving-deeper/tools-react-and-mcp.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,7 @@ Constructor helper. Builds a `ToolCalls` from a list of `{"name", "args"}` dicts
107107
### MCP bridge
108108

109109
**`dspy.utils.mcp.convert_mcp_tool(session, tool)`** → `Tool`
110-
Builds a `Tool` whose `func` is an `async def` closure: it awaits `session.call_tool(tool.name, arguments=kwargs)` and unpacks the result. The unpacker pulls text from any `TextContent` entries (returning a single string when there’s one, a list otherwise) and returns non-text entries as-is. An `isError=True` response raises `RuntimeError`. The MCP tool’s `inputSchema` is mapped to DSPy’s `args`/`arg_types`/`arg_desc` via `convert_input_schema_to_tool_args`.
110+
Builds a `Tool` whose `func` is an `async def` closure: it awaits `session.call_tool(tool.name, arguments=kwargs)` and unpacks the result. The bridge supports the camelCase fields in mcp SDK v1 and their snake_case replacements in v2. An error result raises `RuntimeError`; text and non-text content retain their existing behavior. The tool’s input schema is mapped to DSPy’s `args`/`arg_types`/`arg_desc` via `convert_input_schema_to_tool_args`.
111111

112112
**`dspy.utils.mcp.convert_input_schema_to_tool_args(schema)`** → `(args, arg_types, arg_desc)`
113113
Walks an MCP-style JSON schema and produces the three dicts a `Tool` needs. Resolves `$defs`, picks up `description` fields as `arg_desc`, and translates JSON-schema types to Python types via a `_TYPE_MAPPING` table. Exported in case callers want to write their own bridge against a non-MCP protocol with similar JSON-schema arguments.

‎docs/docs/learn/programming/mcp.md‎

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,13 +22,28 @@ MCP enables you to:
2222
- **Share tools across stacks** - Use the same tools across different frameworks.
2323
- **Simplify integration** - Convert MCP tools to DSPy tools with one line.
2424

25-
DSPy does not handle MCP server connections directly. You can use client interfaces of the `mcp` library to establish the connection and pass `mcp.ClientSession` to `dspy.Tool.from_mcp_tool` in order to convert mcp tools into DSPy tools.
25+
DSPy does not handle MCP server connections directly. Use a client interface from the `mcp` library, then pass the client or session to `dspy.Tool.from_mcp_tool`. DSPy supports both major versions of the `mcp` Python SDK.
2626

2727
## Using MCP with DSPy
2828

2929
### 1. HTTP Server (Remote)
3030

31-
For remote MCP servers over HTTP, use the streamable HTTP transport:
31+
With `mcp` SDK v2, use the high-level `Client` for a remote server:
32+
33+
```python
34+
import asyncio
35+
import dspy
36+
from mcp.client import Client
37+
38+
async def main():
39+
async with Client("http://localhost:8000/mcp") as client:
40+
response = await client.list_tools()
41+
dspy_tools = [dspy.Tool.from_mcp_tool(client, tool) for tool in response.tools]
42+
43+
asyncio.run(main())
44+
```
45+
46+
With SDK v1, use the streamable HTTP transport and a `ClientSession`:
3247

3348
```python
3449
import asyncio
@@ -69,7 +84,7 @@ asyncio.run(main())
6984

7085
### 2. Stdio Server (Local Process)
7186

72-
The most common way to use MCP is with a local server process communicating via stdio:
87+
The most common way to use MCP is with a local server process communicating via stdio. This example works with both SDK versions:
7388

7489
```python
7590
import asyncio

‎docs/docs/tutorials/mcp/index.md‎

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -46,11 +46,15 @@ it:
4646
import random
4747
import string
4848

49-
from mcp.server.fastmcp import FastMCP
5049
from pydantic import BaseModel
5150

51+
try:
52+
from mcp.server.fastmcp import FastMCP as MCPServer # SDK v1
53+
except ImportError:
54+
from mcp.server import MCPServer # SDK v2
55+
5256
# Create an MCP server
53-
mcp = FastMCP("Airline Agent")
57+
mcp = MCPServer("Airline Agent")
5458

5559

5660
class Date(BaseModel):
@@ -220,10 +224,10 @@ if __name__ == "__main__":
220224

221225
Before we start the server, let's take a look at the code.
222226

223-
We first create a `FastMCP` instance, which is a utility that helps quickly build an MCP server:
227+
We first create a server instance, using the class name provided by the installed SDK version:
224228

225229
```python
226-
mcp = FastMCP("Airline Agent")
230+
mcp = MCPServer("Airline Agent")
227231
```
228232

229233
Then we define our data structures, which in a real-world application would be the database schema, e.g.:

‎dspy/adapters/types/tool.py‎

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import asyncio
22
import inspect
3-
from typing import TYPE_CHECKING, Any, Callable, get_origin, get_type_hints
3+
from typing import TYPE_CHECKING, Any, Callable, Protocol, get_origin, get_type_hints
44

55
import json_repair
66
import pydantic
@@ -18,6 +18,10 @@
1818
_TYPE_MAPPING = {"string": str, "integer": int, "number": float, "boolean": bool, "array": list, "object": dict}
1919

2020

21+
class _MCPToolClient(Protocol):
22+
async def call_tool(self, name: str, arguments: dict[str, Any] | None = None) -> Any: ...
23+
24+
2125
class Tool(Type):
2226
"""Tool class.
2327
@@ -200,12 +204,12 @@ async def acall(self, **kwargs):
200204
return result
201205

202206
@classmethod
203-
def from_mcp_tool(cls, session: "mcp.ClientSession", tool: "mcp.types.Tool") -> "Tool":
207+
def from_mcp_tool(cls, session: _MCPToolClient, tool: "mcp.types.Tool") -> "Tool":
204208
"""
205-
Build a DSPy tool from an MCP tool and a ClientSession.
209+
Build a DSPy tool from an MCP tool and a compatible MCP client.
206210
207211
Args:
208-
session: The MCP session to use.
212+
session: An MCP client or session with an async ``call_tool`` method.
209213
tool: The MCP tool to convert.
210214
211215
Returns:

‎dspy/utils/mcp.py‎

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,19 @@
11
from typing import TYPE_CHECKING, Any
22

3-
from dspy.adapters.types.tool import Tool, convert_input_schema_to_tool_args
3+
from dspy.adapters.types.tool import Tool, _MCPToolClient, convert_input_schema_to_tool_args
44

55
if TYPE_CHECKING:
66
import mcp
77

88

9+
def _get_field(obj: Any, snake_name: str, camel_name: str, default: Any = None) -> Any:
10+
"""Read a field whose Python name changed between mcp SDK v1 and v2."""
11+
for name in (snake_name, camel_name):
12+
if hasattr(obj, name):
13+
return getattr(obj, name)
14+
return default
15+
16+
917
def _convert_mcp_tool_result(call_tool_result: "mcp.types.CallToolResult") -> str | list[Any]:
1018
from mcp.types import TextContent
1119

@@ -21,23 +29,27 @@ def _convert_mcp_tool_result(call_tool_result: "mcp.types.CallToolResult") -> st
2129
if len(text_contents) == 1:
2230
tool_content = tool_content[0]
2331

24-
if call_tool_result.isError:
32+
if _get_field(call_tool_result, "is_error", "isError", default=False):
2533
raise RuntimeError(f"Failed to call a MCP tool: {tool_content}")
2634

2735
return tool_content or non_text_contents
2836

2937

30-
def convert_mcp_tool(session: "mcp.ClientSession", tool: "mcp.types.Tool") -> Tool:
38+
def convert_mcp_tool(session: _MCPToolClient, tool: "mcp.types.Tool") -> Tool:
3139
"""Build a DSPy tool from an MCP tool.
3240
41+
Both mcp SDK v1's ``ClientSession`` and v2's high-level ``Client`` satisfy
42+
the client interface used by this bridge.
43+
3344
Args:
34-
session: The MCP session to use.
45+
session: An MCP client or session with an async ``call_tool`` method.
3546
tool: The MCP tool to convert.
3647
3748
Returns:
3849
A dspy Tool object.
3950
"""
40-
args, arg_types, arg_desc = convert_input_schema_to_tool_args(tool.inputSchema)
51+
input_schema = _get_field(tool, "input_schema", "inputSchema", default={})
52+
args, arg_types, arg_desc = convert_input_schema_to_tool_args(input_schema)
4153

4254
# Convert the MCP tool and Session to a single async method
4355
async def func(*args, **kwargs):

‎tests/utils/resources/mcp_server.py‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
1-
from mcp.server.fastmcp import FastMCP
21
from pydantic import BaseModel
32

4-
mcp = FastMCP("test")
3+
try:
4+
from mcp.server.fastmcp import FastMCP as MCPServer
5+
except ImportError:
6+
from mcp.server import MCPServer
7+
8+
mcp = MCPServer("test")
59

610

711
class Profile(BaseModel):

‎tests/utils/test_mcp.py‎

Lines changed: 58 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,72 @@
11
import asyncio
22
import importlib
3+
import sys
4+
from importlib.metadata import version
5+
from types import SimpleNamespace
36

47
import pytest
58

6-
from dspy.utils.mcp import convert_mcp_tool
9+
from dspy import Tool
10+
from dspy.utils.mcp import _convert_mcp_tool_result, convert_mcp_tool
711

812
if importlib.util.find_spec("mcp") is None:
913
pytest.skip(reason="mcp is not installed", allow_module_level=True)
1014

1115

16+
def make_call_tool_result(field_style, texts=(), is_error=False):
17+
from mcp.types import TextContent
18+
19+
fields = {
20+
"content": [TextContent(type="text", text=text) for text in texts],
21+
"structured_content" if field_style == "snake" else "structuredContent": {"result": "ignored"},
22+
"is_error" if field_style == "snake" else "isError": is_error,
23+
}
24+
return SimpleNamespace(**fields)
25+
26+
27+
@pytest.mark.extra
28+
def test_convert_mcp_tool_result_supports_both_field_styles_without_changing_results():
29+
assert _convert_mcp_tool_result(make_call_tool_result("camel", texts=["hi"])) == "hi"
30+
assert _convert_mcp_tool_result(make_call_tool_result("snake", texts=["a", "b"])) == ["a", "b"]
31+
32+
33+
@pytest.mark.extra
34+
@pytest.mark.parametrize("field_style", ["camel", "snake"])
35+
def test_error_result_raises(field_style):
36+
result = make_call_tool_result(field_style, texts=["boom"], is_error=True)
37+
with pytest.raises(RuntimeError, match="Failed to call a MCP tool: boom"):
38+
_convert_mcp_tool_result(result)
39+
40+
41+
@pytest.mark.asyncio
42+
@pytest.mark.extra
43+
async def test_convert_mcp_tool_with_v2_client():
44+
if int(version("mcp").split(".")[0]) < 2:
45+
pytest.skip("The high-level MCP client is available in SDK v2")
46+
47+
from mcp.client import Client
48+
from mcp.server import MCPServer
49+
50+
server = MCPServer("test")
51+
52+
@server.tool()
53+
def increment(value: int) -> int:
54+
return value + 1
55+
56+
async with Client(server) as client:
57+
response = await client.list_tools()
58+
increment_tool = Tool.from_mcp_tool(client, response.tools[0])
59+
assert await increment_tool.acall(value=1) == "2"
60+
61+
1262
@pytest.mark.asyncio
1363
@pytest.mark.extra
1464
async def test_convert_mcp_tool():
1565
from mcp import ClientSession, StdioServerParameters
1666
from mcp.client.stdio import stdio_client
67+
1768
server_params = StdioServerParameters(
18-
command="python",
69+
command=sys.executable,
1970
args=["tests/utils/resources/mcp_server.py"],
2071
env=None,
2172
)
@@ -28,7 +79,10 @@ async def test_convert_mcp_tool():
2879
add_tool = convert_mcp_tool(session, response.tools[0])
2980
assert add_tool.name == "add"
3081
assert add_tool.desc == "Add two numbers"
31-
assert add_tool.args == {"a": {"title": "A", "type": "integer"}, "b": {"title": "B", "type": "integer"}}
82+
assert add_tool.args == {
83+
"a": {"title": "A", "type": "integer"},
84+
"b": {"title": "B", "type": "integer"},
85+
}
3286
assert add_tool.arg_types == {"a": int, "b": int}
3387
assert add_tool.arg_desc == {
3488
"a": "No description provided. (Required)",
@@ -49,9 +103,7 @@ async def test_convert_mcp_tool():
49103
error_tool = convert_mcp_tool(session, response.tools[2])
50104
assert error_tool.name == "wrong_tool"
51105
assert error_tool.desc == "This tool raises an error"
52-
with pytest.raises(
53-
RuntimeError, match="Failed to call a MCP tool: Error executing tool wrong_tool: error!"
54-
):
106+
with pytest.raises(RuntimeError, match="error!"):
55107
await error_tool.acall()
56108

57109
# Check nested Pydantic arg

0 commit comments

Comments
 (0)