Skip to content

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

Merged
merged 4 commits into from
Jun 26, 2025

Conversation

bhosmer-ant
Copy link
Contributor

@bhosmer-ant bhosmer-ant commented Jun 20, 2025

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 passing structured_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,

  • the function return type annotation is converted into Pydantic model (see "Supported return types" below)
  • the tool's outputSchema is generated from this model
  • structuredContent results are validated on the server before sending, via the lowlevel server's structured output support

Client: adds validation of structuredContent results against tool outputSchema
Tests: lots
Docs: added to README.md
Examples: added examples/fastmcp/weather_structured.py showing various return type options

Supported return types

@mcp.tool() supports the following function return types for structured output:

  • Pydantic models: Used directly to validate function results and generate outputSchema
  • dict[str, *], TypedDict, NamedTuple, classes with typed fields: Converted to Pydantic models
  • Primitives, lists, generics: Wrapped in {\"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

  • Protocol defines structured output in a backwards-compatible way
  • SDK API changes should be backwards compatible

Types of changes

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to change)
  • Documentation update

Checklist

  • I have read the MCP Documentation
  • My code follows the repository's style guidelines
  • New and existing tests pass locally
  • I have added appropriate error handling
  • I have added or updated documentation as needed

Additional context

Many thanks to @davemssavage, who prototyped an initial version of this functionality.

@Kludex
Copy link
Member

Kludex commented Jun 21, 2025

I don't think the structured_output is needed on the FastMCP. Is it?

@ihrpr
Copy link
Contributor

ihrpr commented Jun 23, 2025

I don't think the structured_output is needed on the FastMCP. Is it?

@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?

@Kludex
Copy link
Member

Kludex commented Jun 23, 2025

Sorry, I meant "does the flag need to exist? Can the behavior be always true and based on the type hints?"

Copy link
Contributor

@ihrpr ihrpr left a 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?

@@ -315,6 +311,7 @@ def add_tool(
title: str | None = None,
description: str | None = None,
annotations: ToolAnnotations | None = None,
structured_output: bool = False,
Copy link
Contributor

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.

Suggested change
structured_output: bool = False,
structured_output: bool = True,

Copy link
Contributor Author

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:
Copy link
Contributor

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

Copy link
Contributor Author

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

assert result.isError is False
assert result.structuredContent == {"theme": "dark", "language": "en", "timezone": "UTC"}

@pytest.mark.anyio
Copy link
Contributor

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

Copy link
Contributor Author

@bhosmer-ant bhosmer-ant Jun 23, 2025

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 :)

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)],
Copy link
Contributor

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?

Copy link
Contributor Author

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>)].

@bhosmer-ant
Copy link
Contributor Author

bhosmer-ant commented Jun 23, 2025

@ihrpr per offline convo, recapping here

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.

Problems with this are:

  • not all return types yield a structured output schema (e.g. classes without annotated properties) - if we wanted to define FastMCP as supporting only tools that do, we could do that, but it would be a pretty big (BC breaking) change
  • BC in general - tools might be returning results that aren't easily represented as structured output, irrespective of return type annotations
  • new tools might want to return results that aren't easily represented as structured output. (this is more of a "what tools do you want FastMCP to support" design question)

I think it would make sense to have validation on the lowlevel, what is the reason of not having the validation there?

  • lowlevel doesn't do input validation, so I went with what seemed to be the intent there. But per discussion, it sounds like lowlevel is more heavily used than I realized - will add (output) schema validation there

@bhosmer-ant
Copy link
Contributor Author

Sorry, I meant "does the flag need to exist? Can the behavior be always true and based on the type hints?"

@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)

@Kludex
Copy link
Member

Kludex commented Jun 23, 2025

On the FastMCP repository, they have an analogous PR that seems to not include any parameter: jlowin/fastmcp#901 cc @jlowin

@jlowin
Copy link
Contributor

jlowin commented Jun 23, 2025

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.

@bhosmer-ant bhosmer-ant force-pushed the basil/structured_output branch 3 times, most recently from ff01365 to 0695b4b Compare June 25, 2025 05:54
@bhosmer-ant bhosmer-ant requested a review from ihrpr June 25, 2025 06:14
@bhosmer-ant
Copy link
Contributor Author

Updates:

  • made structured_output decorator param optional. If not passed, uses type sniffing to decide whether the tool should be structured or unstructured. (Can override by passing the flag explicitly to force either structured or unstructured.)
  • rebased onto lowlevel server schema validation, although it turns out the multiple ad hoc things FastMCP does to both incoming arguments and results mean we have to jump through some hoops to preserve compatibility. Later versions of FastMCP should break compatibility and simplify.

@ihrpr ihrpr changed the base branch from main to basil/lowlevel_schema_validation June 25, 2025 07:32
ihrpr
ihrpr previously approved these changes Jun 25, 2025
Copy link
Contributor

@ihrpr ihrpr left a 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

Base automatically changed from basil/lowlevel_schema_validation to main June 25, 2025 13:23
@bhosmer-ant bhosmer-ant dismissed ihrpr’s stale review June 25, 2025 13:23

The base branch was changed.

- 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
@bhosmer-ant bhosmer-ant force-pushed the basil/structured_output branch 2 times, most recently from 791f6da to 38a2691 Compare June 25, 2025 17:43
ihrpr
ihrpr previously approved these changes Jun 25, 2025
Copy link
Contributor

@ihrpr ihrpr left a 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)

ihrpr
ihrpr previously approved these changes Jun 25, 2025
- strict serializability checking
- simpler, more consistent handling of return type detection and conversion
- centralized logic
Copy link
Contributor

@ihrpr ihrpr left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you!

@ihrpr ihrpr merged commit 43bb24f into main Jun 26, 2025
12 checks passed
@ihrpr ihrpr deleted the basil/structured_output branch June 26, 2025 08:36
@ihrpr ihrpr linked an issue Jun 26, 2025 that may be closed by this pull request
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

add Tool.outputSchema and CallToolResult.structuredContent
4 participants