Skip to content

Commit 93cbf6b

Browse files
giles17CopilotCopilot
authored
Python: Parse MCP CallToolResult.structuredContent field to prevent tool results returning None (microsoft#6421)
* Parse structuredContent from MCP CallToolResult (microsoft#3313) The _parse_tool_result_from_mcp method only iterated over the content field from CallToolResult, ignoring the structuredContent field entirely. MCP servers that return JSON data via structuredContent (e.g., Power BI MCP) appeared to return None. Add handling for structuredContent: when present, serialize it as JSON text and append it to the result list. This preserves the data for the LLM while maintaining backward compatibility with existing behavior. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Python: Parse MCP CallToolResult.structuredContent field to prevent tool results returning None Fixes microsoft#3313 * Address review feedback: add default=str to json.dumps and remove .checkpoints/ - Add default=str to json.dumps for structuredContent serialization so non-JSON-serializable values (e.g. bytes) degrade gracefully instead of raising TypeError - Remove all .checkpoints/ runtime artifacts from the repository - Add **/.checkpoints/ to .gitignore to prevent future accidental commits - Add test for non-serializable structuredContent values Fixes microsoft#3313 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Address review feedback for microsoft#3313: Python: MCP CallToolResult.structuredContent field is not parsed, causing tool results to return None --------- Co-authored-by: Copilot <copilot@github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 9a56bc9 commit 93cbf6b

12 files changed

Lines changed: 158 additions & 144 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -206,6 +206,7 @@ temp*/
206206
.temp/
207207

208208
# AI
209+
**/.checkpoints/
209210
.claude/
210211
.omc/
211212
.omx/

python/packages/core/agent_framework/_mcp.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -577,6 +577,9 @@ def _parse_tool_result_from_mcp(
577577
case _:
578578
result.append(Content.from_text(str(item)))
579579

580+
if mcp_type.structuredContent is not None:
581+
result.append(Content.from_text(json.dumps(mcp_type.structuredContent, default=str)))
582+
580583
if not result:
581584
result.append(Content.from_text("null"))
582585
return result

python/packages/core/agent_framework/_skills.py

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3516,9 +3516,7 @@ async def get_content(self) -> str:
35163516
result = await self._client.read_resource(_mcp_any_url(self._skill_md_uri))
35173517
text = _mcp_join_text(result)
35183518
if not text:
3519-
raise ValueError(
3520-
f"The MCP server returned no text content for SKILL.md resource '{self._skill_md_uri}'."
3521-
)
3519+
raise ValueError(f"The MCP server returned no text content for SKILL.md resource '{self._skill_md_uri}'.")
35223520
self._content = text
35233521
return text
35243522

@@ -3572,11 +3570,7 @@ def _validate_resource_name(name: str) -> str | None:
35723570
or ``None`` if the name is unsafe.
35733571
"""
35743572
normalized = name.replace("\\", "/")
3575-
if (
3576-
normalized.startswith("/")
3577-
or "://" in normalized
3578-
or any(seg == ".." for seg in normalized.split("/"))
3579-
):
3573+
if normalized.startswith("/") or "://" in normalized or any(seg == ".." for seg in normalized.split("/")):
35803574
logger.debug("Rejecting resource name with unsafe path components: %r", name)
35813575
return None
35823576
return normalized

python/packages/core/tests/core/test_mcp.py

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -342,6 +342,69 @@ def test_parse_tool_result_from_mcp_resource_link_text_resource_and_unknown():
342342
assert result[1].text == "Embedded result"
343343

344344

345+
def test_parse_tool_result_from_mcp_structured_content_only():
346+
"""Test that structuredContent is parsed when content list is empty."""
347+
mcp_result = types.CallToolResult(
348+
content=[],
349+
structuredContent={"Tables": [{"Name": "Sales", "Columns": ["Amount", "Date"]}]},
350+
)
351+
result = _HELPER_MCP_TOOL._parse_tool_result_from_mcp(mcp_result)
352+
353+
assert isinstance(result, list)
354+
assert len(result) == 1
355+
assert result[0].type == "text"
356+
parsed = json.loads(result[0].text)
357+
assert parsed == {"Tables": [{"Name": "Sales", "Columns": ["Amount", "Date"]}]}
358+
359+
360+
def test_parse_tool_result_from_mcp_structured_content_with_text():
361+
"""Test that structuredContent is appended alongside regular content items."""
362+
mcp_result = types.CallToolResult(
363+
content=[types.TextContent(type="text", text="Summary")],
364+
structuredContent={"data": [1, 2, 3]},
365+
)
366+
result = _HELPER_MCP_TOOL._parse_tool_result_from_mcp(mcp_result)
367+
368+
assert isinstance(result, list)
369+
assert len(result) == 2
370+
assert result[0].type == "text"
371+
assert result[0].text == "Summary"
372+
assert result[1].type == "text"
373+
parsed = json.loads(result[1].text)
374+
assert parsed == {"data": [1, 2, 3]}
375+
376+
377+
def test_parse_tool_result_from_mcp_structured_content_none():
378+
"""Test that None structuredContent does not affect results."""
379+
mcp_result = types.CallToolResult(
380+
content=[types.TextContent(type="text", text="Hello")],
381+
structuredContent=None,
382+
)
383+
result = _HELPER_MCP_TOOL._parse_tool_result_from_mcp(mcp_result)
384+
385+
assert isinstance(result, list)
386+
assert len(result) == 1
387+
assert result[0].type == "text"
388+
assert result[0].text == "Hello"
389+
390+
391+
def test_parse_tool_result_from_mcp_structured_content_non_serializable():
392+
"""Test that non-JSON-serializable values in structuredContent degrade gracefully."""
393+
mcp_result = types.CallToolResult(
394+
content=[],
395+
structuredContent={"data": b"raw bytes", "count": 42},
396+
)
397+
result = _HELPER_MCP_TOOL._parse_tool_result_from_mcp(mcp_result)
398+
399+
assert isinstance(result, list)
400+
assert len(result) == 1
401+
assert result[0].type == "text"
402+
parsed = json.loads(result[0].text)
403+
assert parsed["count"] == 42
404+
# bytes should be converted to string representation via default=str
405+
assert "raw bytes" in parsed["data"]
406+
407+
345408
def test_mcp_content_types_to_ai_content_text():
346409
"""Test conversion of MCP text content to AI content."""
347410
mcp_content = types.TextContent(type="text", text="Sample text")

python/packages/core/tests/core/test_mcp_observability.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@ def _make_call_tool_result(text: str = "result", is_error: bool = False) -> Mock
7676
result = Mock()
7777
result.isError = is_error
7878
result.content = [types.TextContent(type="text", text=text)]
79+
result.structuredContent = None
7980
return result
8081

8182

@@ -281,9 +282,7 @@ async def test_mcp_prompts_get_creates_client_span(span_exporter: InMemorySpanEx
281282
async def test_mcp_prompts_get_mcp_error_sets_error_type(span_exporter: InMemorySpanExporter):
282283
"""When session.get_prompt() raises McpError, the span should have error.type and ERROR status."""
283284
tool = _make_connected_mcp_tool()
284-
tool.session.get_prompt = AsyncMock(
285-
side_effect=McpError(ErrorData(code=-32602, message="prompt not found"))
286-
)
285+
tool.session.get_prompt = AsyncMock(side_effect=McpError(ErrorData(code=-32602, message="prompt not found")))
287286

288287
span_exporter.clear()
289288
with pytest.raises(ToolExecutionException):

0 commit comments

Comments
 (0)