-
Notifications
You must be signed in to change notification settings - Fork 1.3k
Prefer structuredContent as per MCP Specifications #2854
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Conversation
tests/test_mcp.py
Outdated
async def test_prefers_structured_content(mcp_server: MCPServerStdio): | ||
"""When structuredContent is present, return it instead of legacy content.""" | ||
|
||
class DummyResult: |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We shouldn't need any mocking here, you can update tests/mcp_server.py
with a tool that actually returns structured content and test against that.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@shaheerzaman You could add a new one
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
done!
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@DouweM not sure why the tests in CI pipeline are failing. I just added 2 tests and they passed on my local.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@shaheerzaman And if you run the entire test file do they pass locally?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
now they are passing. I had to modify the code a little bit
tests/mcp_server.py
Outdated
@mcp.tool() | ||
async def get_structured() -> CallToolResult: | ||
"""Return only structured content to exercise client preference.""" | ||
return CallToolResult( |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This isn't right, according to the mcp
source code, a tool returning a dict will always end up using structuredContent
:
We should never need to return CallToolResult
directly. Fixing this will also stop us having to read result['structuredContent']
in the test -- the whole point is that the tool returns the structured content, not an object with a structuredContent
key.
Since the MCP library already uses structuredContent
for tools returning dict
, we may not actually need a new tool here, as the existing get_dict
tool will presumably already go through this new path.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
done!
pydantic_ai_slim/pydantic_ai/mcp.py
Outdated
structured = result.structuredContent | ||
if structured is not None: | ||
output = structured | ||
error_text = str(structured) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Using a stringified dict as the error message doesn't make much sense, I think we should prefer getting the error message from text parts in content
, and only fall back on structuredContent
if there is no text content.
As you can see here, isError=True
currently always comes with text content
:
So I think we can have the isError
check first, and if it's True
, prefer content
over structuredContent
for the error message, and if it's False
, prefer structuredContent
over content
for the actual return value
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
done!
tests/test_mcp.py
Outdated
assert callback_message == 'Should I continue?', 'Callback should receive the question' | ||
assert result == f'User responded: {callback_response}', 'Tool should return the callback response' | ||
# Some servers return structured content for elicitation | ||
_result_text = result['result'] if isinstance(result, dict) and 'result' in result else result |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When do we need this?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
removed it!
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
doesn't look like you did :D
421e626
to
ff61c1e
Compare
@DouweM let me know if the PR looks good now? |
tests/test_mcp.py
Outdated
assert callback_message == 'Should I continue?', 'Callback should receive the question' | ||
assert result == f'User responded: {callback_response}', 'Tool should return the callback response' | ||
# Some servers return structured content for elicitation | ||
_result_text = result['result'] if isinstance(result, dict) and 'result' in result else result |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
doesn't look like you did :D
pydantic_ai_slim/pydantic_ai/mcp.py
Outdated
message = 'Error executing tool' | ||
raise exceptions.ModelRetry(message) | ||
|
||
if result.content: |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We're now prioritizing unstructured content again :/ Wasn't prioritizing structured content the point of this PR?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actually the situation is a bit more complicated than I realized, see https://github.com/pydantic/pydantic-ai/pull/2784/files#r2338018100. Can you implement it here the same as it is in that PR, meaning that we only use structuredContent
if there are no non-text parts in content
?
pydantic_ai_slim/pydantic_ai/mcp.py
Outdated
|
||
content = [await self._map_tool_result_part(part) for part in result.content] | ||
|
||
# Prefer 'structuredContent' only when 'content' is empty. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
That's not what I was suggesting -- when there was an error, we should prefer text content; when there is no error, we should prefer structured content
pydantic_ai_slim/pydantic_ai/mcp.py
Outdated
# Ref: Tools / Structured Content — "SHOULD also return the serialized JSON in a TextContent block." | ||
# https://modelcontextprotocol.io/specification/draft/server/tools |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Please remove this comment, it's a bit confusing
pydantic_ai_slim/pydantic_ai/mcp.py
Outdated
else: | ||
return content[0] if len(content) == 1 else content | ||
if result.content: | ||
mapped = [await self._map_tool_result_part(part) for part in result.content] |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Instead of doing the whole result mapping, can we just filter out mcp_types.TextContent
parts, and take their text
, and join those for the error message?
pydantic_ai_slim/pydantic_ai/mcp.py
Outdated
mapped = [await self._map_tool_result_part(part) for part in result.content] | ||
text_parts = [p for p in mapped if isinstance(p, str)] | ||
message = '\n'.join(text_parts) if text_parts else '\n'.join(str(p) for p in mapped) | ||
elif structured is not None: # pragma: no cover (server includes text for errors) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Please remove the extra comment bit here
pydantic_ai_slim/pydantic_ai/mcp.py
Outdated
text_parts = [p for p in mapped if isinstance(p, str)] | ||
message = '\n'.join(text_parts) if text_parts else '\n'.join(str(p) for p in mapped) | ||
elif structured is not None: # pragma: no cover (server includes text for errors) | ||
message = str(structured) # pragma: no cover (server includes text for errors) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't think we need this pragma: no cover
if the elif
it's inside already has it
pydantic_ai_slim/pydantic_ai/mcp.py
Outdated
message = 'Error executing tool' | ||
raise exceptions.ModelRetry(message) | ||
|
||
if result.content: |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actually the situation is a bit more complicated than I realized, see https://github.com/pydantic/pydantic-ai/pull/2784/files#r2338018100. Can you implement it here the same as it is in that PR, meaning that we only use structuredContent
if there are no non-text parts in content
?
@DouweM Can you please review ? |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@shaheerzaman Please fix the things I've pointed out, push to GitHub, and then have a look at the coverage
check if it's still failing.
pydantic_ai_slim/pydantic_ai/mcp.py
Outdated
|
||
content = [await self._map_tool_result_part(part) for part in result.content] | ||
|
||
mapped = [await self._map_tool_result_part(part) for part in result.content] |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is relatively expensive (parsing JSON etc), so we shouldn't do it unless necessary
pydantic_ai_slim/pydantic_ai/mcp.py
Outdated
else: | ||
return content[0] if len(content) == 1 else content | ||
if result.content: | ||
text_parts = [p for p in mapped if isinstance(p, str)] |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This can be done more cheaply by checking for mcp_types.TextContent
in parts
, rather than str
in mapped
pydantic_ai_slim/pydantic_ai/mcp.py
Outdated
if result.content: | ||
text_parts = [p for p in mapped if isinstance(p, str)] | ||
message = '\n'.join(text_parts) if text_parts else '\n'.join(str(p) for p in mapped) | ||
raise exceptions.ModelRetry(message) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Right now, if isError
is true but result.content
is empty, we don't raise an exception at all 😬 We should fix that, we should always raise an error on isError
. If there is no content, we should use structured content instead, or otherwise a custom message
pydantic_ai_slim/pydantic_ai/mcp.py
Outdated
|
||
# If any of the results are not text content, let's map them to Pydantic AI binary message parts | ||
if any(not isinstance(part, mcp_types.TextContent) for part in result.content): | ||
return mapped[0] if len(mapped) == 1 else mapped |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Can you restructure this like in my suggestion at https://github.com/pydantic/pydantic-ai/pull/2784/files#r2342260432, so that we don't repeat this line twice?
pydantic_ai_slim/pydantic_ai/mcp.py
Outdated
# Otherwise, if we have structured content, return that | ||
structured = result.structuredContent | ||
if structured is not None: | ||
value = structured['result'] if isinstance(structured, dict) and 'result' in structured else structured |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why do we need this? I don't think we should need this
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
some of the tests are failing without this.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@mohammadshaheerzaman-84 which tests? do we know the underlying reason? or the thing the test is supposed to verify that now broke? because this doesn't look like desirable behavior to me. we shouldn't have code just because tests were failing without it :) sometimes the test is wrong, or there's a better fix. I want to understand why this is necessary, which is not exactly the same thing as why you put this here
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
AssertionError: Tool should return the callback response E assert {'result': 'User responded: Yes, proceed with the action'} == 'User responded: Yes, proceed with the action'
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@DouweM The tool result comes in the form of dictionary with 'result' key and we are asserting for direct str tool output.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@shaheerzaman Interesting, looks like the 'result'
key is inserted automatically by the mcp
server:
So in this case, the original value is still in content
, but structured_data
will contain {"result": <value>"}
. But the result
key is not part of the MCP spec, so I think we have 2 choices:
- Update the expected values in the tests -- no need to parse out
result
, just update what we expect the return value to be to include the result key - Unwrap the
result
key as you were doing before, but only if it's the only key in the structured content, and with a comment explaining why we're doing this.
I'm fine with the second option.
pydantic_ai_slim/pydantic_ai/mcp.py
Outdated
return value # type: ignore | ||
|
||
if result.content: # pragma: no cover | ||
return mapped[0] if len(mapped) == 1 else mapped |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't think we need this
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
again i had to add this to make some of failing tests pass
pydantic_ai_slim/pydantic_ai/mcp.py
Outdated
if result.content: # pragma: no cover | ||
return mapped[0] if len(mapped) == 1 else mapped | ||
|
||
return str(value) # pragma: no cover |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We shouldn't need this -- if there is structured content, we should return it directly, never stringified
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
okay
This PR is stale, and will be closed in 3 days if no reply is received. |
This PR fixes #2742
Summary
Fixes MCP tool-call handling to prefer structuredContent when present, falling back to legacy content blocks only if absent. Prevents empty results when servers omit text content per MCP spec.
Motivation
MCP 5.2.6: tools that return structured content SHOULD also include a serialized TextContent, but it’s not required. Some servers return only structuredContent. Prior code only read result.content, causing empty tool results.
What Changed
Prefer result.structuredContent; return it directly.
If result.isError and structuredContent is present, raise ModelRetry(str(structuredContent)).
Fallback: map legacy result.content via existing _map_tool_result_part.
Files
pydantic_ai_slim/pydantic_ai/mcp.py:189
pydantic_ai_slim/pydantic_ai/mcp.py:238
Behavior
Structured-first: returns dict/list/str per server’s structuredContent.
Legacy-safe: identical behavior when structuredContent is missing.
Error path: raises with structured payload string when isError=True.
Tests
tests/test_mcp.py:1368 — prefers structuredContent (happy path).
tests/test_mcp.py:1388 — raised error uses structuredContent.
tests/test_mcp.py:1407 — prefers structuredContent when both present.
tests/test_mcp.py:1426 — falls back to legacy content mapping.
Compatibility
Backwards-compatible with servers that only return content.
Uses getattr(..., 'structuredContent', None) for older SDKs.
Spec Reference
MCP Specification §5.2.6 “Structured Content”: tools MAY omit text content; structured is primary.