fastmcp icon indicating copy to clipboard operation
fastmcp copied to clipboard

Allow alternative serialization to str when objects are returned

Open strawgate opened this issue 8 months ago • 5 comments

Right now there's a content formatter that gets called when tools return. Anything other than a string basically.

The content formatter just dumps it to JSON

It would be nice if this was customizable. At the moment I have to wrap all my tool return calls in a formatter call to return something other than compressed json

For example, I'd like to return yaml from tool calls as that tends to be readable for humans and for machines or alternatively maybe I'd like to return prettified json

Perhaps a configurable formatting callable that defaults to dumping json?

strawgate avatar Apr 18 '25 20:04 strawgate

For now I'm doing:


        # We need to wrap the underlying call in a way that captures the response and then calls the formatter.
        def format_response(func: Callable) -> Callable:
            @functools.wraps(func)
            async def wrapper(*args, **kwargs):
                response = await func(*args, **kwargs)
                return self.response_formatter(response)

            return wrapper

        # Register the tools with the MCP server.
        mcp.add_tool(format_response(self.knowledge_base_client.create))
        mcp.add_tool(format_response(self.knowledge_base_client.get))

I would really like to switch to using the mixin and the decorators and will try to figure out if I can do this probably by nesting the decorators

strawgate avatar Apr 19 '25 22:04 strawgate

@jlowin for many clients it would be nice to dump responses to pretty yaml or pretty json instead of flattened JSON. Especially if this could be configured as an env var (though that could be part of what the user implements)

For example in Roo Code, responses w/ objects come as a single line of json which is probably fine for responses that only an LLM needs to see, but if it's desirable for the response to be usable by both the user and the LLM then the MCP author needs to perform their own serialization and return strings instead of objects.

It would be really nice if there was some convenient way to customize the response serialization if an object is returned.

For example:

Image

vs

Image

I could perhaps offer a contrib module for this, perhaps a decorator which goes before the FastMCP decorator that changes the return type but I think a decorator that changes the return type breaks function docs/type hints in the IDE (in vscode at least)

In this way, we can continue to write methods and functions which take complex inputs and return complex outputs but when used as a tool serialize appropriately for the use-case without requiring custom wrapping or MCP-specific versions of each method/function

strawgate avatar Apr 27 '25 15:04 strawgate

I'd be happy to offer a PR to either:

  1. Allow the decorators to take a serializer parameter which the user provides
  2. Add a server-level setting for a serializer which effects all tools/resources registered with the server

if you were open to this

strawgate avatar Apr 27 '25 16:04 strawgate

Hey @strawgate -- I'm not sure I'm totally following where the serializer should be injected.

In this code, I have a random tool that returns an object (for no other reason than because it doesn't have a nice serialization). If I call my_tool() as a user, I get the object; but if I simulate the call to the MCP, I get the serialized TextContent. Is that the behavior you meant? Or like an alternative serialization for the MCP call?

from fastmcp import FastMCP

mcp = FastMCP()


@mcp.tool()
def my_tool():
    return object()


await mcp._mcp_call_tool('my_tool', {})
# [TextContent(type='text', text='<object object at 0x12024f400>', annotations=None)]

my_tool()
# <object at 0x1034f2950>

jlowin avatar Apr 30 '25 17:04 jlowin

When using Pydantic models

import asyncio

from fastmcp import FastMCP
from pydantic import BaseModel

mcp = FastMCP()

class CustomReturn(BaseModel):
    """Custom return type for the MCP server."""
    message: str


@mcp.tool()
def my_tool():
    return CustomReturn(message="test")

async def main():
    await mcp._mcp_call_tool('my_tool', {})
    # [TextContent(type='text', text='{"message": "test"}, annotations=None)]

my_tool() 
# CustomReturn(message='test')

if __name__ == "__main__":
    asyncio.run(main())

which is done by


def _convert_to_content(
    result: Any,
    _process_as_single_item: bool = False,
) -> list[TextContent | ImageContent | EmbeddedResource]:
    """Convert a result to a sequence of content objects."""
    ...
    if not isinstance(result, str):
        try:
            result = json.dumps(pydantic_core.to_jsonable_python(result))
        except Exception:
            result = str(result)

    return [TextContent(type="text", text=result)]

so it's this json.dumps call that would be nice to make a configurable serializer so that you can return objects from function/method calls and control how they get turned into text separately from modifying the functions themselves

strawgate avatar May 03 '25 00:05 strawgate

I think this is closed by #308

jlowin avatar May 03 '25 16:05 jlowin