-
Notifications
You must be signed in to change notification settings - Fork 1.9k
feat: Add structured output support for tool functions #993
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
Conversation
I don't think the |
@Kludex, Structured output is part of the spec. Since FastMCP in this repository serves as a high-level API and is the primary entry point for users to define tools, it needs to include structured_output. This gives users the ability to define tools with structured output support, which is a standard feature of the MCP protocol. or you mean there is no much need of of the param itself as it's already part of the spec? |
Sorry, I meant "does the flag need to exist? Can the behavior be always true and based on the type hints?" |
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 think we might just use HighLevel API (FastMCP) to always return structured and unstructured output. Given that it always has a return type we can return structured output and unstructured will be non-wrapped output behaviour.
- I think it would make sense to have validation on the lowlevel, what is the reason of not having the validation there?
src/mcp/server/fastmcp/server.py
Outdated
@@ -315,6 +311,7 @@ def add_tool( | |||
title: str | None = None, | |||
description: str | None = None, | |||
annotations: ToolAnnotations | None = None, | |||
structured_output: bool = False, |
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.
Is there a reason we want to have a default set to True
, given the backwards compatibility, where we also send an unstructured output.
As per other comments, we might just remove this entirely, it will be a nice way to migrate most of the tool to return structure output.
structured_output: bool = False, | |
structured_output: bool = True, |
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.
BC problem with this is that existing tools don't (or may not) map cleanly to structured output - and even when they do, we would need to decide for a given tool whether to employ
- the "new structured tool BC conversion" (serializing the json to text and stuffing it into a single TextContent block) or
- the "old unstructured tool BC conversion" (running the return value through the old conversion, which leaves content blocks in place and does a few other fairly ad hoc things)
...so defaulting structured_output=True would produce silent breaking changes for a certain class of tools, which seems bad. if there's a way to tighten the valid-structured-return-type rule so that only tools for which the conversion produces the same result make it through without error (and others produce a "please add structured_output=False
to your decorator error" message), then we could dodge the silent-BC-break issue, will look into that
@@ -68,6 +84,28 @@ async def call_fn_with_arg_validation( | |||
return fn(**arguments_parsed_dict) | |||
raise TypeError("fn must be either Callable or Awaitable") | |||
|
|||
def to_validated_dict(self, result: Any) -> dict[str, Any]: | |||
"""Validate and convert the result to a dict after validation.""" | |||
if self.output_model is None: |
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 was already checked before calling to_validated_dict
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.
true in the current codepath, but it's (currently) a public method
tests/server/fastmcp/test_server.py
Outdated
assert result.isError is False | ||
assert result.structuredContent == {"theme": "dark", "language": "en", "timezone": "UTC"} | ||
|
||
@pytest.mark.anyio |
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.
here tests started using lowlevel server
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.
[edit] ah misunderstood your comment - will move lowlevel server tests outta here :)
src/mcp/server/lowlevel/server.py
Outdated
json_content = pydantic_core.to_json(results, fallback=str, indent=2).decode() | ||
return types.ServerResult( | ||
types.CallToolResult( | ||
content=[types.TextContent(type="text", text=json_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 will not be fully backwards compatible, would leaving this as content=list(results)
work?
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.
the dict
return type from a tool is new, so this codepath should be new (for any code that typechecked). Don't think list(results)
would work since that would give you [<some python dict>]
rather than [TextContent(<serialized python dict>)]
.
@ihrpr per offline convo, recapping here
Problems with this are:
|
@Kludex see my reply to @ihrpr - making it always true would make some functions ineligible as tools, and would silently change the results returned by others (the latter also an issue with making it default-true) |
On the FastMCP repository, they have an analogous PR that seems to not include any parameter: jlowin/fastmcp#901 cc @jlowin |
Hey! Just to clarify, jlowin/fastmcp#901 implements output schemas but not structured content (yet) because I'm actually waiting for this low-level PR to be merged so I can fully test those outputs. That said, the approach we are taking toward structured content in FastMCP 2 is much more opinionated - if a function has a serializable return annotation, it automatically gets structured output, no flags needed. This feels more aligned with what users expect from a high-level SDK (automatic behavior based on type hints) vs low-level SDK (explicit configuration). That said, I understand the backwards compatibility concerns that motivate the structured_output=True flag approach. Different design philosophies for different use cases! Happy to share more thoughts on our implementation if it would be helpful. The automatic dict→structured content conversion is definitely a tricky area where user expectations can and will vary. |
ff01365
to
0695b4b
Compare
Updates:
|
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.
LGTM, a few things to clean up before merging
- Add support for tool functions to return structured data with validation - Functions can now use structured_output=True to enable output validation - Add outputSchema field to Tool type in MCP protocol - Implement client-side validation of structured content - Add comprehensive tests for all supported types - Add documentation and examples
791f6da
to
38a2691
Compare
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.
LGTM
(as discussed, there is potential refactoring opportunity, we can try it out and if not worth it, we can merge)
38a2691
to
3ab59c5
Compare
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.
Thank you!
Adds support for structured output in FastMCP and Client, allowing tool functions to return well-typed, validated data that clients can easily process and validate.
Key Changes
FastMCP:
Adds structured tool support to FastMCP's
@mcp.tool()
decorator. By default, functions with structured-output-compatible return types become structured tools, other functions become unstructured tools. This behavior can be overridden by passingstructured_output=False
to the decorator. (structured_output=True
will force FastMCP to attempt to create a structured tool, which may be useful in certain edge cases.)To convert a function into a structured tool,
outputSchema
is generated from this modelstructuredContent
results are validated on the server before sending, via the lowlevel server's structured output supportClient: adds validation of
structuredContent
results against tooloutputSchema
Tests: lots
Docs: added to README.md
Examples: added
examples/fastmcp/weather_structured.py
showing various return type optionsSupported return types
@mcp.tool()
supports the following function return types for structured output:outputSchema
{\"result\": value}
Note on backward compatibility
In keeping with the MCP specification regarding backward compatibility, structured tools will provide both structured results and backwards-compatible unstructured results. In order to provide backward compatibility with previous versions of FastMCP in particular, we do not use the generic JSON serialization provided by the lowlevel server for this purpose. Instead we use the existing FastMCP unstructured results pipeline, which performs several ad hoc conversions of function results. Future versions of FastMCP will instead use the generic lowlevel server pipeline.
Motivation and Context
Support structured output as defined in spec rev 2025-06-18
How Has This Been Tested?
New tests added
Breaking Changes
Types of changes
Checklist
Additional context
Many thanks to @davemssavage, who prototyped an initial version of this functionality.