Skip to content

Commit 6ea8f72

Browse files
vamganVamil Gandhidbschmigelski
authored
feat: add optional outputSchema support for tool specifications (#818)
* feat: add optional outputSchema support for tool specifications --------- Co-authored-by: Vamil Gandhi <[email protected]> Co-authored-by: Dean Schmigelski <[email protected]>
1 parent 00a1f28 commit 6ea8f72

File tree

10 files changed

+133
-20
lines changed

10 files changed

+133
-20
lines changed

src/strands/models/bedrock.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -214,7 +214,16 @@ def format_request(
214214
{
215215
"toolConfig": {
216216
"tools": [
217-
*[{"toolSpec": tool_spec} for tool_spec in tool_specs],
217+
*[
218+
{
219+
"toolSpec": {
220+
"name": tool_spec["name"],
221+
"description": tool_spec["description"],
222+
"inputSchema": tool_spec["inputSchema"],
223+
}
224+
}
225+
for tool_spec in tool_specs
226+
],
218227
*(
219228
[{"cachePoint": {"type": self.config["cache_tools"]}}]
220229
if self.config.get("cache_tools")

src/strands/tools/mcp/mcp_agent_tool.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,18 +54,24 @@ def tool_spec(self) -> ToolSpec:
5454
"""Get the specification of the tool.
5555
5656
This method converts the MCP tool specification to the agent framework's
57-
ToolSpec format, including the input schema and description.
57+
ToolSpec format, including the input schema, description, and optional output schema.
5858
5959
Returns:
6060
ToolSpec: The tool specification in the agent framework format
6161
"""
6262
description: str = self.mcp_tool.description or f"Tool which performs {self.mcp_tool.name}"
63-
return {
63+
64+
spec: ToolSpec = {
6465
"inputSchema": {"json": self.mcp_tool.inputSchema},
6566
"name": self.mcp_tool.name,
6667
"description": description,
6768
}
6869

70+
if self.mcp_tool.outputSchema:
71+
spec["outputSchema"] = {"json": self.mcp_tool.outputSchema}
72+
73+
return spec
74+
6975
@property
7076
def tool_type(self) -> str:
7177
"""Get the type of the tool.

src/strands/types/tools.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
from dataclasses import dataclass
1010
from typing import TYPE_CHECKING, Any, AsyncGenerator, Awaitable, Callable, Literal, Protocol, Union
1111

12-
from typing_extensions import TypedDict
12+
from typing_extensions import NotRequired, TypedDict
1313

1414
from .media import DocumentContent, ImageContent
1515

@@ -27,11 +27,15 @@ class ToolSpec(TypedDict):
2727
description: A human-readable description of what the tool does.
2828
inputSchema: JSON Schema defining the expected input parameters.
2929
name: The unique name of the tool.
30+
outputSchema: Optional JSON Schema defining the expected output format.
31+
Note: Not all model providers support this field. Providers that don't
32+
support it should filter it out before sending to their API.
3033
"""
3134

3235
description: str
3336
inputSchema: JSONSchema
3437
name: str
38+
outputSchema: NotRequired[JSONSchema]
3539

3640

3741
class Tool(TypedDict):

tests/strands/models/test_bedrock.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1788,3 +1788,25 @@ def test_custom_model_id_not_overridden_by_region_formatting(session_cls):
17881788
model_id = model.get_config().get("model_id")
17891789

17901790
assert model_id == custom_model_id
1791+
1792+
1793+
def test_format_request_filters_output_schema(model, messages, model_id):
1794+
"""Test that outputSchema is filtered out from tool specs in Bedrock requests."""
1795+
tool_spec_with_output_schema = {
1796+
"description": "Test tool with output schema",
1797+
"name": "test_tool",
1798+
"inputSchema": {"type": "object", "properties": {}},
1799+
"outputSchema": {"type": "object", "properties": {"result": {"type": "string"}}},
1800+
}
1801+
1802+
request = model.format_request(messages, [tool_spec_with_output_schema])
1803+
1804+
tool_spec = request["toolConfig"]["tools"][0]["toolSpec"]
1805+
1806+
# Verify outputSchema is not included
1807+
assert "outputSchema" not in tool_spec
1808+
1809+
# Verify other fields are preserved
1810+
assert tool_spec["name"] == "test_tool"
1811+
assert tool_spec["description"] == "Test tool with output schema"
1812+
assert tool_spec["inputSchema"] == {"type": "object", "properties": {}}

tests/strands/tools/mcp/test_mcp_agent_tool.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ def mock_mcp_tool():
1313
mock_tool.name = "test_tool"
1414
mock_tool.description = "A test tool"
1515
mock_tool.inputSchema = {"type": "object", "properties": {}}
16+
mock_tool.outputSchema = None # MCP tools can have optional outputSchema
1617
return mock_tool
1718

1819

@@ -47,6 +48,7 @@ def test_tool_spec_with_description(mcp_agent_tool, mock_mcp_tool):
4748
assert tool_spec["name"] == "test_tool"
4849
assert tool_spec["description"] == "A test tool"
4950
assert tool_spec["inputSchema"]["json"] == {"type": "object", "properties": {}}
51+
assert "outputSchema" not in tool_spec
5052

5153

5254
def test_tool_spec_without_description(mock_mcp_tool, mock_mcp_client):
@@ -58,6 +60,25 @@ def test_tool_spec_without_description(mock_mcp_tool, mock_mcp_client):
5860
assert tool_spec["description"] == "Tool which performs test_tool"
5961

6062

63+
def test_tool_spec_with_output_schema(mock_mcp_tool, mock_mcp_client):
64+
mock_mcp_tool.outputSchema = {"type": "object", "properties": {"result": {"type": "string"}}}
65+
66+
agent_tool = MCPAgentTool(mock_mcp_tool, mock_mcp_client)
67+
tool_spec = agent_tool.tool_spec
68+
69+
assert "outputSchema" in tool_spec
70+
assert tool_spec["outputSchema"]["json"] == {"type": "object", "properties": {"result": {"type": "string"}}}
71+
72+
73+
def test_tool_spec_without_output_schema(mock_mcp_tool, mock_mcp_client):
74+
mock_mcp_tool.outputSchema = None
75+
76+
agent_tool = MCPAgentTool(mock_mcp_tool, mock_mcp_client)
77+
tool_spec = agent_tool.tool_spec
78+
79+
assert "outputSchema" not in tool_spec
80+
81+
6182
@pytest.mark.asyncio
6283
async def test_stream(mcp_agent_tool, mock_mcp_client, alist):
6384
tool_use = {"toolUseId": "test-123", "name": "test_tool", "input": {"param": "value"}}

tests_integ/mcp/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
"""MCP integration tests package."""

tests_integ/echo_server.py renamed to tests_integ/mcp/echo_server.py

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,15 @@
1515
$ python echo_server.py
1616
"""
1717

18-
from typing import Any, Dict
19-
2018
from mcp.server import FastMCP
19+
from pydantic import BaseModel
20+
21+
22+
class EchoResponse(BaseModel):
23+
"""Response model for echo with structured content."""
24+
25+
echoed: str
26+
message_length: int
2127

2228

2329
def start_echo_server():
@@ -37,8 +43,8 @@ def echo(to_echo: str) -> str:
3743

3844
# FastMCP automatically constructs structured output schema from method signature
3945
@mcp.tool(description="Echos response back with structured content", structured_output=True)
40-
def echo_with_structured_content(to_echo: str) -> Dict[str, Any]:
41-
return {"echoed": to_echo}
46+
def echo_with_structured_content(to_echo: str) -> EchoResponse:
47+
return EchoResponse(echoed=to_echo, message_length=len(to_echo))
4248

4349
mcp.run(transport="stdio")
4450

tests_integ/test_mcp_client.py renamed to tests_integ/mcp/test_mcp_client.py

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@ def test_mcp_client():
7676

7777
sse_mcp_client = MCPClient(lambda: sse_client("http://127.0.0.1:8000/sse"))
7878
stdio_mcp_client = MCPClient(
79-
lambda: stdio_client(StdioServerParameters(command="python", args=["tests_integ/echo_server.py"]))
79+
lambda: stdio_client(StdioServerParameters(command="python", args=["tests_integ/mcp/echo_server.py"]))
8080
)
8181

8282
with sse_mcp_client, stdio_mcp_client:
@@ -150,19 +150,19 @@ def test_mcp_client():
150150

151151
# With the new MCPToolResult, structured content is in its own field
152152
assert "structuredContent" in result
153-
assert result["structuredContent"]["result"] == {"echoed": "STRUCTURED_DATA_TEST"}
153+
assert result["structuredContent"] == {"echoed": "STRUCTURED_DATA_TEST", "message_length": 20}
154154

155155
# Verify the result is an MCPToolResult (at runtime it's just a dict, but type-wise it should be MCPToolResult)
156156
assert result["status"] == "success"
157157
assert result["toolUseId"] == tool_use_id
158158

159159
assert len(result["content"]) == 1
160-
assert json.loads(result["content"][0]["text"]) == {"echoed": "STRUCTURED_DATA_TEST"}
160+
assert json.loads(result["content"][0]["text"]) == {"echoed": "STRUCTURED_DATA_TEST", "message_length": 20}
161161

162162

163163
def test_can_reuse_mcp_client():
164164
stdio_mcp_client = MCPClient(
165-
lambda: stdio_client(StdioServerParameters(command="python", args=["tests_integ/echo_server.py"]))
165+
lambda: stdio_client(StdioServerParameters(command="python", args=["tests_integ/mcp/echo_server.py"]))
166166
)
167167
with stdio_mcp_client:
168168
stdio_mcp_client.list_tools_sync()
@@ -185,7 +185,7 @@ async def test_mcp_client_async_structured_content():
185185
that appears in structuredContent field.
186186
"""
187187
stdio_mcp_client = MCPClient(
188-
lambda: stdio_client(StdioServerParameters(command="python", args=["tests_integ/echo_server.py"]))
188+
lambda: stdio_client(StdioServerParameters(command="python", args=["tests_integ/mcp/echo_server.py"]))
189189
)
190190

191191
with stdio_mcp_client:
@@ -200,20 +200,20 @@ async def test_mcp_client_async_structured_content():
200200
assert "structuredContent" in result
201201
# "result" nesting is not part of the MCP Structured Content specification,
202202
# but rather a FastMCP implementation detail
203-
assert result["structuredContent"]["result"] == {"echoed": "ASYNC_STRUCTURED_TEST"}
203+
assert result["structuredContent"] == {"echoed": "ASYNC_STRUCTURED_TEST", "message_length": 21}
204204

205205
# Verify basic MCPToolResult structure
206206
assert result["status"] in ["success", "error"]
207207
assert result["toolUseId"] == tool_use_id
208208

209209
assert len(result["content"]) == 1
210-
assert json.loads(result["content"][0]["text"]) == {"echoed": "ASYNC_STRUCTURED_TEST"}
210+
assert json.loads(result["content"][0]["text"]) == {"echoed": "ASYNC_STRUCTURED_TEST", "message_length": 21}
211211

212212

213213
def test_mcp_client_without_structured_content():
214214
"""Test that MCP client works correctly when tools don't return structured content."""
215215
stdio_mcp_client = MCPClient(
216-
lambda: stdio_client(StdioServerParameters(command="python", args=["tests_integ/echo_server.py"]))
216+
lambda: stdio_client(StdioServerParameters(command="python", args=["tests_integ/mcp/echo_server.py"]))
217217
)
218218

219219
with stdio_mcp_client:
@@ -279,7 +279,7 @@ def test_mcp_client_timeout_integration():
279279

280280
def slow_transport():
281281
time.sleep(4) # Longer than timeout
282-
return stdio_client(StdioServerParameters(command="python", args=["tests_integ/echo_server.py"]))
282+
return stdio_client(StdioServerParameters(command="python", args=["tests_integ/mcp/echo_server.py"]))
283283

284284
client = MCPClient(slow_transport, startup_timeout=2)
285285
initial_threads = threading.active_count()

tests_integ/test_mcp_client_structured_content_with_hooks.py renamed to tests_integ/mcp/test_mcp_client_structured_content_with_hooks.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ def test_mcp_client_hooks_structured_content():
3737

3838
# Set up MCP client for echo server
3939
stdio_mcp_client = MCPClient(
40-
lambda: stdio_client(StdioServerParameters(command="python", args=["tests_integ/echo_server.py"]))
40+
lambda: stdio_client(StdioServerParameters(command="python", args=["tests_integ/mcp/echo_server.py"]))
4141
)
4242

4343
with stdio_mcp_client:
@@ -58,8 +58,8 @@ def test_mcp_client_hooks_structured_content():
5858

5959
# Verify structured content is present and correct
6060
assert "structuredContent" in result
61-
assert result["structuredContent"]["result"] == {"echoed": test_data}
61+
assert result["structuredContent"] == {"echoed": test_data, "message_length": 15}
6262

6363
# Verify text content matches structured content
6464
text_content = json.loads(result["content"][0]["text"])
65-
assert text_content == {"echoed": test_data}
65+
assert text_content == {"echoed": test_data, "message_length": 15}
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
"""Integration test for MCP tools with output schema."""
2+
3+
from mcp import StdioServerParameters, stdio_client
4+
5+
from strands.tools.mcp.mcp_client import MCPClient
6+
7+
from .echo_server import EchoResponse
8+
9+
10+
def test_mcp_tool_output_schema():
11+
"""Test that MCP tools with output schema include it in tool spec."""
12+
stdio_mcp_client = MCPClient(
13+
lambda: stdio_client(StdioServerParameters(command="python", args=["tests_integ/mcp/echo_server.py"]))
14+
)
15+
16+
with stdio_mcp_client:
17+
tools = stdio_mcp_client.list_tools_sync()
18+
19+
# Find tools with and without output schema
20+
echo_tool = next(tool for tool in tools if tool.tool_name == "echo")
21+
structured_tool = next(tool for tool in tools if tool.tool_name == "echo_with_structured_content")
22+
23+
# Verify echo tool has no output schema
24+
echo_spec = echo_tool.tool_spec
25+
assert "outputSchema" not in echo_spec
26+
27+
# Verify structured tool has output schema
28+
structured_spec = structured_tool.tool_spec
29+
assert "outputSchema" in structured_spec
30+
31+
# Validate output schema matches expected structure
32+
expected_schema = {
33+
"description": "Response model for echo with structured content.",
34+
"properties": {
35+
"echoed": {"title": "Echoed", "type": "string"},
36+
"message_length": {"title": "Message Length", "type": "integer"},
37+
},
38+
"required": ["echoed", "message_length"],
39+
"title": "EchoResponse",
40+
"type": "object",
41+
}
42+
43+
assert structured_spec["outputSchema"]["json"] == expected_schema
44+
assert structured_spec["outputSchema"]["json"] == EchoResponse.model_json_schema()

0 commit comments

Comments
 (0)