Skip to content

Add FileUpload provider#3669

Open
jlowin wants to merge 2 commits intomainfrom
file-upload-provider
Open

Add FileUpload provider#3669
jlowin wants to merge 2 commits intomainfrom
file-upload-provider

Conversation

@jlowin
Copy link
Copy Markdown
Member

@jlowin jlowin commented Mar 28, 2026

Extracts the file upload example into a reusable FileUpload provider. Adding file upload to any server is now one line:

from fastmcp import FastMCP
from fastmcp.apps.file_upload import FileUpload

mcp = FastMCP("My Server")
mcp.add_provider(FileUpload())

This registers a drag-and-drop UI tool, a backend store_files tool (app-only), and model-visible list_files/read_file tools. Files are scoped by MCP session and stored in memory by default — each session gets its own file store, and files don't leak between conversations.

For custom persistence (S3, database, filesystem), subclass and override three methods:

class S3Upload(FileUpload):
    def on_store(self, files): ...
    def on_list(self): ...
    def on_read(self, name): ...

TODO: Verify session scoping behavior in stateless HTTP transport — need to confirm virtual sessions work correctly so files don't end up in a shared __default__ bucket.

@marvin-context-protocol marvin-context-protocol bot added feature Major new functionality. Reserved for 2-4 significant PRs per release. Not for issues. provider Related to the FastMCP Provider class server Related to FastMCP server implementation or server-side functionality. labels Mar 28, 2026
Base automatically changed from app-tool-prefixed-names to main March 28, 2026 01:46
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: ae23027aa2

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +157 to +160
for f in files:
session_files[f["name"]] = {
"name": f["name"],
"size": f["size"],
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Enforce server-side max_file_size checks

on_store persists every uploaded file without validating against self._max_file_size, so the configured limit is only a UI hint. Because app tools can still be invoked directly by name (for example Files___store_files), a client can bypass the DropZone constraint and submit arbitrarily large base64 payloads, leading to unbounded in-memory growth in _store despite max_file_size being set.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Good catch — fixed in 614bc2b. The validation now happens in the tool function itself (before on_store), so subclasses don't need to re-implement the size check.

Comment on lines +231 to +233
@self.tool(model=True)
def list_files() -> list[dict]:
"""List all uploaded files with metadata."""
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Namespace model-visible file tools to avoid collisions

Registering model-visible tools with generic names like list_files/read_file makes this provider conflict with host servers that already define those names. FastMCP deduplicates tool listings by name and resolves call_tool by a single winner for that name, so in collisions the FileUpload tools can be hidden or unreachable to the model, undermining the “add to any server” behavior.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

This is what namespaces are for — server.add_provider(FileUpload(), namespace="files") gives you files_list_files, files_read_file, etc. Generic names are intentional for the simple case where there's no collision.

@chatgpt-codex-connector
Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

jlowin added 2 commits March 27, 2026 21:54
Session-scoped in-memory storage by default. Override on_store,
on_list, on_read for custom persistence.
@jlowin jlowin force-pushed the file-upload-provider branch from 614bc2b to c054a78 Compare March 28, 2026 01:54
@chatgpt-codex-connector
Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

@marvin-context-protocol
Copy link
Copy Markdown
Contributor

marvin-context-protocol bot commented Mar 28, 2026

Test Failure Analysis

Summary: One pre-existing test is failing across all Python versions — test_background_task_can_read_snapshotted_request_headers — due to HTTP request context not being available when a background task calls get_http_request() directly (without using FastMCP dependency injection).

Note: This failure is not introduced by this PR. The failing test was added to main in the previous commit (1d8a8bc, "chore: Update SDK documentation #3668") and the same failure can be seen in the most recent push-to-main CI run (run 23674775716). The PR's own changes (the FileUpload provider) are unrelated.


Root Cause: When a Docket background worker executes a tool, HTTP request headers are snapshotted to Redis at submission time (in submit_to_docket). However, those headers are only restored from Redis when the tool uses CurrentRequest() or CurrentHeaders() as dependency-injected parameters — not when the tool calls get_http_request() directly.

The test uses get_http_request() directly inside a task=True tool:

@server.tool(task=True)
async def check_request_header() -> str:
    request = get_http_request()  # fails — _task_http_headers ContextVar is not set
    return request.headers.get("x-tenant-id", "missing")

get_http_request() (in src/fastmcp/server/dependencies.py:484) reads _task_http_headers.get(), which is None in the worker because no code called _restore_task_http_headers() before the function ran. That restoration only happens inside _CurrentRequest.__aenter__ and _CurrentHeaders.__aenter__.

Suggested Solution: The fix should restore _task_http_headers early in the background task execution path, before the user's function runs. The most targeted option:

In FunctionTool.run() (or a new wrapper registered with Docket), check get_task_context() and call _restore_task_http_headers() if not already set. Since this is async, it fits naturally into the async def run() flow:

# At the start of FunctionTool.run() (or a Docket-registered wrapper):
if _task_http_headers.get() is None:
    task_info = get_task_context()
    if task_info is not None:
        await _restore_task_http_headers(task_info.session_id, task_info.task_id)

This mirrors what _CurrentRequest.__aenter__ already does (see dependencies.py:1186-1197), but applied at the tool execution level so it covers all tools regardless of whether they use dependency injection.

Detailed Analysis

Failure log excerpt:

FAILED tests/server/http/test_http_dependencies.py::test_background_task_can_read_snapshotted_request_headers
fastmcp.exceptions.ToolError: No active HTTP request found.

ERROR   docket.worker:worker.py:991 ↩ tool:check_request_header@(){...}
RuntimeError: No active HTTP request found.

The contrast with the passing test (test_background_task_current_http_dependencies_restore_headers):

# This PASSES — uses dependency injection
@server.tool(task=True)
async def check_headers(
    headers: dict[str, str] = CurrentHeaders(),  # triggers _restore_task_http_headers
    request: Request = CurrentRequest(),          # triggers _restore_task_http_headers
) -> dict[str, str]: ...

The failing test (added in commit 1d8a8bc):

# This FAILS — calls get_http_request() directly
@server.tool(task=True)
async def check_request_header() -> str:
    request = get_http_request()  # _task_http_headers not set in worker
    return request.headers.get("x-tenant-id", "missing")

Code flow:

  • submit_to_docket() (handlers.py:127-143) — snapshots headers to Redis ✓
  • _restore_task_http_headers() (dependencies.py:847) — restores from Redis, but only called from _CurrentRequest.__aenter__ / _CurrentHeaders.__aenter__
  • get_http_request() (dependencies.py:444) — reads _task_http_headers.get() which is None

Pre-existing on main: confirmed by run 23674775716 showing the same failure before this PR was merged.

Related Files
  • tests/server/http/test_http_dependencies.py:172 — the failing test (added in commit 1d8a8bc)
  • src/fastmcp/server/dependencies.py:444get_http_request(), reads _task_http_headers.get()
  • src/fastmcp/server/dependencies.py:847_restore_task_http_headers(), restores from Redis
  • src/fastmcp/server/dependencies.py:1186_CurrentRequest.__aenter__, where restoration currently happens
  • src/fastmcp/server/tasks/handlers.py:127submit_to_docket(), where headers are snapshotted to Redis
  • src/fastmcp/tools/function_tool.py:238FunctionTool.run(), candidate location for the fix

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

feature Major new functionality. Reserved for 2-4 significant PRs per release. Not for issues. provider Related to the FastMCP Provider class server Related to FastMCP server implementation or server-side functionality.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant