File Upload and Download in MCP Tools

An MCP tool can take a file the user attached to a conversation, and hand back a file the user can download. QAnswer is the MCP client: it calls your server over streamable HTTP, and the file travels inside the tool's arguments and its result. Your server needs no extra endpoint and never handles a download link.

The examples come from poc-file-mcp, a small FastMCP server with a single tool, uppercase_file, which reads a text file and returns an upper-cased copy of it.

The file envelope

A file always travels as the same JSON object, in both directions:

{
  "file_name": "hello.txt",
  "mime_type": "text/plain",
  "base64": "aGVsbG8gd29ybGQ="
}
  • file_name: the file name, with its extension.
  • mime_type: the media type, for instance text/plain.
  • base64: the file content, base64-encoded.

This envelope is a QAnswer convention carried in plain MCP text content. It is not part of the MCP specification.

Accept a file

Give the file parameter the type File in the tool's input schema. This is the schema of uppercase_file:

{
  "type": "object",
  "properties": {
    "source_file": {
      "type": "File",
      "description": "The text file to upper-case."
    }
  },
  "required": [
    "source_file"
  ]
}

To accept several files, declare an array of File items:

{
  "source_files": {
    "type": "array",
    "items": {
      "type": "File"
    },
    "description": "The files to merge."
  }
}

What happens when the tool is called

  1. The model sees the parameter as a plain string, or an array of strings, and fills it with the name of a file attached to the conversation, for instance hello.txt.
  2. Before calling your tool, QAnswer replaces each name with the envelope of that file, serialized as a JSON string. An array parameter receives a list of these strings, in the same order.
  3. Your tool parses the value with json.loads: it receives a string, not an object.

For hello.txt, containing hello world, uppercase_file receives these arguments:

{
  "source_file": "{\"file_name\":\"hello.txt\",\"mime_type\":\"text/plain\",\"base64\":\"aGVsbG8gd29ybGQ=\"}"
}

Rules

  • Only the first parameter of type File in a tool is filled. Put all the files in one parameter, as an array if needed.
  • The model can only pick files of the current conversation: documents and images the user attached, and files that earlier tool calls returned.
  • When a name matches no attached file, a file is larger than 25 MB or more than 10 files are given, your tool is not called. The model gets an error listing the attached files and can try again with a correct name.
  • QAnswer stores the input schema of each tool when the tools of the server are synced. Sync them again after changing a schema.

Return a file

To hand a file back, return its envelope as the text of a single text content block, as uppercase_file does. Several files go in a list under files, or attachments:

{
  "files": [
    {
      "file_name": "summary.csv",
      "mime_type": "text/csv",
      "base64": "…"
    },
    {
      "file_name": "chart.png",
      "mime_type": "image/png",
      "base64": "…"
    }
  ]
}

Any tool can return files, including one without a File parameter, such as a chart or report generator.

What the user and the model see

  • Each returned file is saved in the conversation and shown as a file card under the answer of the assistant, where the user can preview and download it.
  • The file is served from GET /api/conversation/{conversation_id}/files/{file_id}, with the usual credentials of the user. There is no public link.
  • The model never sees the file content. QAnswer replaces the whole tool result with a short summary of which files were attached and which failed, so any other text in the result is dropped. Put everything the user needs in the file.
  • A file without a file_name is called processed-file, and a name without an extension gets one guessed from mime_type.
  • Returned files are stored, not indexed: they are not searchable like the documents the user uploads.
  • Images returned as MCP image content are attached to the conversation as well, and the model sees them too.
  • Embedded resources, resource links and audio content are not turned into files.
Return the envelope as text
Return exactly one text block: several blocks are joined into one string, which is no longer valid JSON. With the @mcp.tool decorator, return a JSON string, not a dict. FastMCP also sends a returned dict as structured content, and the file would be attached twice.

The same rule applies with the @mcp.tool decorator. This tool takes no file and returns a CSV file:

@mcp.tool
def export_report() -> str:
    """Exports the sales report as a CSV file."""
    data = b"region,total\nnorth,42\nsouth,17\n"
    return json.dumps(
        {
            "file_name": "report.csv",
            "mime_type": "text/csv",
            "base64": base64.b64encode(data).decode("ascii"),
        }
    )

Example: uppercase_file

The complete poc-file-mcp server. It needs only fastmcp 3.2:

import base64
import json

from fastmcp import FastMCP
from fastmcp.tools import Tool, ToolResult
from mcp.types import TextContent


class UppercaseFileTool(Tool):
    """Reads a text file and returns a copy of it, upper-cased."""

    def __init__(self) -> None:
        super().__init__(
            name="uppercase_file",
            description=(
                "Reads a text file and returns a copy of it with its contents "
                "upper-cased."
            ),
            parameters={
                "type": "object",
                "properties": {
                    "source_file": {
                        "type": "File",
                        "description": "The text file to upper-case.",
                    }
                },
                "required": ["source_file"],
            },
        )

    async def run(self, arguments: dict) -> ToolResult:
        # QAnswer has replaced the file name with the envelope, as a JSON string
        envelope = json.loads(arguments["source_file"])
        raw = base64.b64decode(envelope["base64"])
        upper_text = raw.decode("utf-8").upper()

        # hand the new file back as one text block holding its envelope
        result_envelope = {
            "file_name": f"UPPER_{envelope['file_name']}",
            "mime_type": envelope.get("mime_type") or "text/plain",
            "base64": base64.b64encode(upper_text.encode("utf-8")).decode("ascii"),
        }
        return ToolResult(
            content=[TextContent(type="text", text=json.dumps(result_envelope))]
        )


mcp = FastMCP("poc-file-mcp")
mcp.add_tool(UppercaseFileTool())


if __name__ == "__main__":
    mcp.run(transport="streamable-http", host="0.0.0.0", port=8811, path="/mcp")

Install FastMCP and start the server. It serves streamable HTTP at http://localhost:8811/mcp:

pip install "fastmcp==3.2.0"
python server.py

Limits

  • 25 MB per file, in each direction. Base64 makes the payload about a third larger.
  • 10 files per array parameter.
  • Only files of the current conversation can be passed to a tool.
  • No media type is refused. A file without one is treated as application/octet-stream.
  • The low-level server of the MCP Python SDK checks arguments against the input schema by default and rejects every call, because File is not a JSON Schema type. Register its handler with call_tool(validate_input=False). FastMCP does not check against the schema by default.