Skip to content

Commit 98dbfa6

Browse files
ampagentIsaac Miller
andcommitted
feat: add opt-in MCP structured results
Amp-Thread-ID: https://ampcode.com/threads/T-019fddd4-5966-702a-a38e-43dfa7bc9fa1 Co-authored-by: Isaac Miller <isaac@cmpnd.ai>
1 parent 822f393 commit 98dbfa6

5 files changed

Lines changed: 154 additions & 19 deletions

File tree

‎docs/docs/diving-deeper/tools.md‎

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -90,16 +90,23 @@ MCP servers publish tools with JSON schemas. `dspy.Tool.from_mcp_tool(session, t
9090

9191
```python
9292
dspy_tool = dspy.Tool.from_mcp_tool(session, mcp_tool)
93+
94+
structured_tool = dspy.Tool.from_mcp_tool(
95+
session,
96+
mcp_tool,
97+
result_mode="structured",
98+
)
9399
```
94100

95101
The bridge:
96102

97103
1. Converts the MCP input schema into DSPy's `args`, `arg_types`, and `arg_desc`.
98104
2. Creates an async callable that invokes `session.call_tool(...)`.
99-
3. Unpacks MCP text content into a string or list and preserves non-text content.
100-
4. Raises an execution error when the MCP response has `isError=True`.
105+
3. By default, unpacks MCP text content into a string or list and preserves the existing non-text fallback.
106+
4. With `result_mode="structured"`, returns `structuredContent` exactly when present and otherwise uses the default conversion.
107+
5. Raises an execution error when the MCP response has `isError=True`.
101108

102-
The bridge supports both the camelCase result fields in MCP SDK v1 and their snake_case replacements in v2 without changing text or non-text result behavior.
109+
The bridge supports both the camelCase result fields in MCP SDK v1 and their snake_case replacements in v2. Structured mode does not parse values or unwrap a single `result` field, because MCP structured content can be any JSON value and a `result` field may belong to the application.
103110

104111
MCP tools are asynchronous because `mcp.ClientSession` is asynchronous. Use a module's async entry point, such as `acall`, or explicitly enable async-to-sync conversion when appropriate.
105112

@@ -116,8 +123,8 @@ Validates, coerces, and executes tool arguments through synchronous or asynchron
116123
**`Tool.format_as_litellm_function_call()`** → `dict`
117124
Returns the OpenAI/LiteLLM-style function descriptor used by adapters for native calling.
118125

119-
**`Tool.from_mcp_tool(session, tool)`** → `Tool`
120-
Wraps a remote MCP tool as an asynchronous DSPy tool.
126+
**`Tool.from_mcp_tool(session, tool, *, result_mode="text")`** → `Tool`
127+
Wraps a remote MCP tool as an asynchronous DSPy tool. Set `result_mode="structured"` for exact machine-readable MCP results; the default keeps the model-facing content behavior.
121128

122129
**`Tool.from_langchain(tool)`** → `Tool`
123130
Wraps a LangChain tool in the same DSPy interface.

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

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,26 @@ dspy_tool = dspy.Tool.from_mcp_tool(session, mcp_tool)
158158
result = await dspy_tool.acall(param1="value", param2=123)
159159
```
160160

161+
### Choosing the result representation
162+
163+
By default, DSPy uses the MCP result's model-facing `content` field. This preserves the existing behavior: one text block becomes a string, multiple text blocks become a list, and results without text fall back to their non-text content.
164+
165+
For programmatic workflows that need the MCP result's machine-readable JSON value, opt into structured content when converting the tool:
166+
167+
```python
168+
structured_tool = dspy.Tool.from_mcp_tool(
169+
session,
170+
mcp_tool,
171+
result_mode="structured",
172+
)
173+
174+
result = await structured_tool.acall(param1="value", param2=123)
175+
```
176+
177+
Structured mode returns `structuredContent` exactly as the server sent it. Objects, arrays, strings, numbers, booleans, JSON `null`, and empty values are preserved without parsing or unwrapping. For example, the official MCP Python SDK may represent a tool annotated `-> int` as `{"result": 3}`; DSPy returns that entire object rather than guessing that the `result` field is an SDK-generated envelope. If a result has no structured content, DSPy falls back to the default content conversion.
178+
179+
Use the default mode for tools primarily observed by a ReAct agent or another language model, since MCP `content` is the server's model-facing representation. Use structured mode when application code needs native JSON values for validation, indexing, or explicit tool chaining. In both modes, treat tool results as untrusted server data and validate them before passing them to sensitive operations.
180+
161181
## Learn More
162182

163183
- [MCP Official Documentation](https://modelcontextprotocol.io/)

‎dspy/adapters/types/tool.py‎

Lines changed: 12 additions & 3 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, Protocol, get_origin, get_type_hints
3+
from typing import TYPE_CHECKING, Any, Callable, Literal, Protocol, get_origin, get_type_hints
44

55
import json_repair
66
import pydantic
@@ -204,20 +204,29 @@ async def acall(self, **kwargs):
204204
return result
205205

206206
@classmethod
207-
def from_mcp_tool(cls, session: _MCPToolClient, tool: "mcp.types.Tool") -> "Tool":
207+
def from_mcp_tool(
208+
cls,
209+
session: _MCPToolClient,
210+
tool: "mcp.types.Tool",
211+
*,
212+
result_mode: Literal["text", "structured"] = "text",
213+
) -> "Tool":
208214
"""
209215
Build a DSPy tool from an MCP tool and a compatible MCP client.
210216
211217
Args:
212218
session: An MCP client or session with an async ``call_tool`` method.
213219
tool: The MCP tool to convert.
220+
result_mode: ``"text"`` preserves DSPy's existing text/non-text
221+
conversion. ``"structured"`` returns MCP structured content
222+
exactly when present, with the existing conversion as fallback.
214223
215224
Returns:
216225
A Tool object.
217226
"""
218227
from dspy.utils.mcp import convert_mcp_tool
219228

220-
return convert_mcp_tool(session, tool)
229+
return convert_mcp_tool(session, tool, result_mode=result_mode)
221230

222231
@classmethod
223232
def from_langchain(cls, tool: "BaseTool") -> "Tool":

‎dspy/utils/mcp.py‎

Lines changed: 31 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from typing import TYPE_CHECKING, Any
1+
from typing import TYPE_CHECKING, Any, Literal
22

33
from dspy.adapters.types.tool import Tool, _MCPToolClient, convert_input_schema_to_tool_args
44

@@ -14,7 +14,20 @@ def _get_field(obj: Any, snake_name: str, camel_name: str, default: Any = None)
1414
return default
1515

1616

17-
def _convert_mcp_tool_result(call_tool_result: "mcp.types.CallToolResult") -> str | list[Any]:
17+
def _field_is_set(obj: Any, snake_name: str, camel_name: str) -> bool:
18+
"""Check whether an optional field was explicitly set, including to null."""
19+
fields_set = getattr(obj, "model_fields_set", None)
20+
if fields_set is None:
21+
fields_set = getattr(obj, "__fields_set__", None)
22+
if fields_set is not None:
23+
return snake_name in fields_set or camel_name in fields_set
24+
return hasattr(obj, snake_name) or hasattr(obj, camel_name)
25+
26+
27+
def _convert_mcp_tool_result(
28+
call_tool_result: "mcp.types.CallToolResult",
29+
result_mode: Literal["text", "structured"] = "text",
30+
) -> Any:
1831
from mcp.types import TextContent
1932

2033
text_contents: list[TextContent] = []
@@ -32,10 +45,18 @@ def _convert_mcp_tool_result(call_tool_result: "mcp.types.CallToolResult") -> st
3245
if _get_field(call_tool_result, "is_error", "isError", default=False):
3346
raise RuntimeError(f"Failed to call a MCP tool: {tool_content}")
3447

48+
if result_mode == "structured" and _field_is_set(call_tool_result, "structured_content", "structuredContent"):
49+
return _get_field(call_tool_result, "structured_content", "structuredContent")
50+
3551
return tool_content or non_text_contents
3652

3753

38-
def convert_mcp_tool(session: _MCPToolClient, tool: "mcp.types.Tool") -> Tool:
54+
def convert_mcp_tool(
55+
session: _MCPToolClient,
56+
tool: "mcp.types.Tool",
57+
*,
58+
result_mode: Literal["text", "structured"] = "text",
59+
) -> Tool:
3960
"""Build a DSPy tool from an MCP tool.
4061
4162
Both mcp SDK v1's ``ClientSession`` and v2's high-level ``Client`` satisfy
@@ -44,16 +65,22 @@ def convert_mcp_tool(session: _MCPToolClient, tool: "mcp.types.Tool") -> Tool:
4465
Args:
4566
session: An MCP client or session with an async ``call_tool`` method.
4667
tool: The MCP tool to convert.
68+
result_mode: ``"text"`` preserves DSPy's existing text/non-text
69+
conversion. ``"structured"`` returns MCP structured content
70+
exactly when present, with the existing conversion as fallback.
4771
4872
Returns:
4973
A dspy Tool object.
5074
"""
75+
if result_mode not in ("text", "structured"):
76+
raise ValueError(f"Unsupported MCP result mode: {result_mode!r}")
77+
5178
input_schema = _get_field(tool, "input_schema", "inputSchema", default={})
5279
args, arg_types, arg_desc = convert_input_schema_to_tool_args(input_schema)
5380

5481
# Convert the MCP tool and Session to a single async method
5582
async def func(*args, **kwargs):
5683
result = await session.call_tool(tool.name, arguments=kwargs)
57-
return _convert_mcp_tool_result(result)
84+
return _convert_mcp_tool_result(result, result_mode=result_mode)
5885

5986
return Tool(func=func, name=tool.name, desc=tool.description, args=args, arg_types=arg_types, arg_desc=arg_desc)

‎tests/utils/test_mcp.py‎

Lines changed: 79 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -12,30 +12,99 @@
1212
if importlib.util.find_spec("mcp") is None:
1313
pytest.skip(reason="mcp is not installed", allow_module_level=True)
1414

15+
_UNSET = object()
1516

16-
def make_call_tool_result(field_style, texts=(), is_error=False):
17+
18+
def make_call_tool_result(field_style, texts=(), structured=_UNSET, is_error=False):
1719
from mcp.types import TextContent
1820

1921
fields = {
2022
"content": [TextContent(type="text", text=text) for text in texts],
21-
"structured_content" if field_style == "snake" else "structuredContent": {"result": "ignored"},
2223
"is_error" if field_style == "snake" else "isError": is_error,
2324
}
25+
if structured is not _UNSET:
26+
fields["structured_content" if field_style == "snake" else "structuredContent"] = structured
2427
return SimpleNamespace(**fields)
2528

2629

2730
@pytest.mark.extra
2831
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"]
32+
camel_result = make_call_tool_result("camel", texts=["hi"], structured={"result": "ignored"})
33+
snake_result = make_call_tool_result("snake", texts=["a", "b"], structured={"result": "ignored"})
34+
35+
assert _convert_mcp_tool_result(camel_result) == "hi"
36+
assert _convert_mcp_tool_result(snake_result) == ["a", "b"]
37+
38+
39+
@pytest.mark.extra
40+
@pytest.mark.parametrize("field_style", ["camel", "snake"])
41+
@pytest.mark.parametrize(
42+
"structured",
43+
[
44+
pytest.param({"answer": 42}, id="object"),
45+
pytest.param([1, 2], id="array"),
46+
pytest.param("answer", id="string"),
47+
pytest.param(3.5, id="number"),
48+
pytest.param(False, id="boolean"),
49+
pytest.param(None, id="null"),
50+
pytest.param({}, id="empty-object"),
51+
pytest.param([], id="empty-array"),
52+
pytest.param("", id="empty-string"),
53+
pytest.param(0, id="zero"),
54+
],
55+
)
56+
def test_structured_result_mode_returns_every_json_value_exactly(field_style, structured):
57+
result = make_call_tool_result(field_style, texts=["fallback"], structured=structured)
58+
59+
converted = _convert_mcp_tool_result(result, result_mode="structured")
60+
assert converted == structured
61+
assert type(converted) is type(structured)
62+
63+
64+
@pytest.mark.extra
65+
@pytest.mark.parametrize("field_style", ["camel", "snake"])
66+
def test_structured_result_mode_falls_back_when_field_is_absent(field_style):
67+
result = make_call_tool_result(field_style, texts=["fallback"])
68+
69+
assert _convert_mcp_tool_result(result, result_mode="structured") == "fallback"
70+
71+
72+
@pytest.mark.extra
73+
def test_structured_result_mode_distinguishes_explicit_null_from_omission():
74+
from mcp.types import CallToolResult
75+
76+
model_fields = getattr(CallToolResult, "model_fields", None)
77+
if model_fields is None:
78+
model_fields = getattr(CallToolResult, "__fields__", {})
79+
field_name = next(
80+
(name for name in ("structured_content", "structuredContent") if name in model_fields),
81+
None,
82+
)
83+
if field_name is None:
84+
pytest.skip("This MCP version predates structured content")
85+
86+
omitted = CallToolResult(content=[])
87+
explicit_null = CallToolResult(content=[], **{field_name: None})
88+
89+
assert _convert_mcp_tool_result(omitted, result_mode="structured") == []
90+
assert _convert_mcp_tool_result(explicit_null, result_mode="structured") is None
3191

3292

3393
@pytest.mark.extra
3494
@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)
95+
@pytest.mark.parametrize("result_mode", ["text", "structured"])
96+
def test_error_result_raises_before_conversion(field_style, result_mode):
97+
result = make_call_tool_result(field_style, texts=["boom"], structured={"result": "ignored"}, is_error=True)
3798
with pytest.raises(RuntimeError, match="Failed to call a MCP tool: boom"):
38-
_convert_mcp_tool_result(result)
99+
_convert_mcp_tool_result(result, result_mode=result_mode)
100+
101+
102+
@pytest.mark.extra
103+
def test_convert_mcp_tool_rejects_unknown_result_mode():
104+
tool = SimpleNamespace(name="test", description="test", input_schema={})
105+
106+
with pytest.raises(ValueError, match="Unsupported MCP result mode: 'invalid'"):
107+
convert_mcp_tool(SimpleNamespace(), tool, result_mode="invalid")
39108

40109

41110
@pytest.mark.asyncio
@@ -56,7 +125,10 @@ def increment(value: int) -> int:
56125
async with Client(server) as client:
57126
response = await client.list_tools()
58127
increment_tool = Tool.from_mcp_tool(client, response.tools[0])
128+
structured_increment_tool = Tool.from_mcp_tool(client, response.tools[0], result_mode="structured")
129+
59130
assert await increment_tool.acall(value=1) == "2"
131+
assert await structured_increment_tool.acall(value=1) == {"result": 2}
60132

61133

62134
@pytest.mark.asyncio

0 commit comments

Comments
 (0)