diff --git a/docs/apps/generative.mdx b/docs/apps/generative.mdx index 4046acd0f..cabdf1dfa 100644 --- a/docs/apps/generative.mdx +++ b/docs/apps/generative.mdx @@ -123,6 +123,7 @@ The streaming renderer loads Pyodide from CDN in the browser. The CSP is configu ## Next Steps +- **[GenerativeUI Provider Reference](/apps/providers/generative)** — Configuration options and quick setup - **[Prefab UI](/apps/prefab)** — The component library and state system the LLM writes code against - **[Prefab Component Reference](https://prefab.prefect.io/docs/components)** — Full component library documentation - **[Development](/apps/development)** — Preview generative UI tools locally with `fastmcp dev apps` diff --git a/docs/apps/images/app-file-upload.png b/docs/apps/images/app-file-upload.png new file mode 100644 index 000000000..1178c09af Binary files /dev/null and b/docs/apps/images/app-file-upload.png differ diff --git a/docs/apps/overview.mdx b/docs/apps/overview.mdx index c74482bd7..895c91301 100644 --- a/docs/apps/overview.mdx +++ b/docs/apps/overview.mdx @@ -148,7 +148,7 @@ mcp = FastMCP("Prefab Studio") mcp.add_provider(GenerativeUI()) ``` -See [Generative UI](/apps/generative) for the full guide. +See [Generative UI](/apps/generative) for the full guide, or the [provider reference](/apps/providers/generative) for configuration options. ## Which Approach? @@ -160,6 +160,8 @@ When you want the LLM to design the UI at runtime, use **[Generative UI](/apps/g When you need your own HTML/JS (maps, 3D, video), use **[Custom HTML](/apps/low-level)**. +FastMCP also includes ready-made **[app providers](/apps/providers/generative)** that add common capabilities with a single `add_provider()` call. + ## Custom HTML Apps All the approaches above use [Prefab UI](https://prefab.prefect.io) to build UIs in pure Python. If you need full control — your own HTML, CSS, JavaScript, a specific framework — you can use the [MCP Apps extension directly](/apps/low-level). You write the HTML yourself and communicate with the host via the [`@modelcontextprotocol/ext-apps`](https://github.com/modelcontextprotocol/ext-apps) SDK. diff --git a/docs/apps/providers/file-upload.mdx b/docs/apps/providers/file-upload.mdx new file mode 100644 index 000000000..a1dc0dc47 --- /dev/null +++ b/docs/apps/providers/file-upload.mdx @@ -0,0 +1,125 @@ +--- +title: File Upload +sidebarTitle: File Upload +description: Drag-and-drop file upload for any MCP server +icon: upload +tag: NEW +--- + +import { VersionBadge } from '/snippets/version-badge.mdx' + + + +`FileUpload` adds drag-and-drop file upload to any server. Users upload files through an interactive UI, bypassing the LLM context window entirely. The LLM can then list and read uploaded files through model-visible tools. + + + The FileUpload provider shown in Goose, with a drag-and-drop zone for uploading files + + +```python +from fastmcp import FastMCP +from fastmcp.apps.file_upload import FileUpload + +mcp = FastMCP("My Server") +mcp.add_provider(FileUpload()) +``` + +This registers four tools: + +| Tool | Visibility | Purpose | +|------|-----------|---------| +| `file_manager` | Model | Opens the drag-and-drop upload UI | +| `store_files` | App only | Called by the UI when the user clicks Upload | +| `list_files` | Model | Returns metadata for all uploaded files | +| `read_file` | Model | Returns a file's contents by name | + +The LLM sees `file_manager`, `list_files`, and `read_file`. It calls `file_manager` to show the upload interface, then uses `list_files` and `read_file` to work with whatever the user uploaded. `store_files` is app-only — the UI calls it directly through the `___` routing mechanism and the LLM never needs to know about it. + +## Configuration + +```python +FileUpload( + name="Files", # App name (used in tool routing) + max_file_size=10 * 1024 * 1024, # 10 MB default, enforced server-side + title="File Upload", # Heading shown in the UI + description="Drop files to...", # Description text below the heading + drop_label="Drop files here", # Label inside the drop zone +) +``` + +The `max_file_size` limit is enforced both in the UI (the DropZone rejects oversized files) and on the server (the `store_files` tool validates before calling `on_store`). + +## Storage Scoping + +By default, files are stored in memory and scoped by MCP session ID. Each session gets its own isolated file store — files uploaded in one conversation aren't visible in another. + +This works with **stdio**, **SSE**, and **stateful HTTP** transports, where sessions persist across requests. In **stateless HTTP** mode, each request creates a new session, so the default scoping won't work. + +For stateless deployments, override `_get_scope_key` to return a stable identifier. For example, to scope files by authenticated user: + +```python +from fastmcp.apps.file_upload import FileUpload + +class UserScopedUpload(FileUpload): + def _get_scope_key(self, ctx): + return ctx.access_token["sub"] +``` + +For process-wide shared storage (all users see all files): + +```python +class SharedUpload(FileUpload): + def _get_scope_key(self, ctx): + return "__shared__" +``` + +## Custom Storage + +The default implementation stores files in memory for the lifetime of the server process. For persistent storage, subclass `FileUpload` and override three methods. Each receives the current `Context`, giving you access to session IDs, auth tokens, and request metadata for partitioning and authorization. + +```python +import base64 + +from fastmcp.apps.file_upload import FileUpload + +class S3Upload(FileUpload): + def on_store(self, files, ctx): + user_id = ctx.access_token["sub"] + for f in files: + s3.put_object( + Bucket="uploads", + Key=f"{user_id}/{f['name']}", + Body=base64.b64decode(f["data"]), + ) + return self.on_list(ctx) + + def on_list(self, ctx): + user_id = ctx.access_token["sub"] + objects = s3.list_objects(Bucket="uploads", Prefix=f"{user_id}/") + return [ + { + "name": obj["Key"].split("/", 1)[1], + "type": "application/octet-stream", + "size": obj["Size"], + "size_display": f"{obj['Size']} B", + "uploaded_at": obj["LastModified"].isoformat(), + } + for obj in objects.get("Contents", []) + ] + + def on_read(self, name, ctx): + user_id = ctx.access_token["sub"] + obj = s3.get_object(Bucket="uploads", Key=f"{user_id}/{name}") + content = obj["Body"].read() + return { + "name": name, + "size": obj["ContentLength"], + "type": obj["ContentType"], + "uploaded_at": obj["LastModified"].isoformat(), + "content": content.decode("utf-8"), + } +``` + +Each file dict passed to `on_store` contains `name`, `size`, `type`, and `data` (base64-encoded content). The return value from `on_store` and `on_list` should be a list of summary dicts with `name`, `type`, `size`, `size_display`, and `uploaded_at` fields — these populate the file list in the UI. + +`on_read` returns a dict with file metadata and either `content` (decoded text) or `content_base64` (a base64 preview for binary files). diff --git a/docs/apps/providers/generative.mdx b/docs/apps/providers/generative.mdx new file mode 100644 index 000000000..9f5002221 --- /dev/null +++ b/docs/apps/providers/generative.mdx @@ -0,0 +1,49 @@ +--- +title: Generative UI +sidebarTitle: Generative UI +description: Let the LLM generate custom UIs at runtime +icon: wand-magic-sparkles +tag: NEW +--- + +import { VersionBadge } from '/snippets/version-badge.mdx' + + + +`GenerativeUI` lets the LLM write Prefab Python code at runtime and render it as a streaming interactive UI. Instead of calling pre-built tools with fixed interfaces, the model creates tailored visualizations for whatever data it's working with. + +```python +from fastmcp import FastMCP +from fastmcp.apps.generative import GenerativeUI + +mcp = FastMCP("My Server") +mcp.add_provider(GenerativeUI()) +``` + +This registers: + +| Component | Type | Purpose | +|-----------|------|---------| +| `generate_prefab_ui` | Tool | Accepts Python code, executes in Pyodide sandbox, renders result | +| `search_prefab_components` | Tool | Lets the LLM discover available Prefab components | +| Generative renderer | Resource | `ui://` resource with browser-side Pyodide for streaming | + +The LLM writes real Python — loops, f-strings, computation — using Prefab's component library (charts, tables, forms, cards, layout primitives). As the model generates tokens, the host streams partial code to the renderer via `ontoolinputpartial`, so the user watches the UI build up in real time. + +## Configuration + +```python +GenerativeUI( + tool_name="generate_prefab_ui", # Rename the generation tool + components_tool_name="search_prefab_components", # Rename the search tool + include_components_tool=True, # Set False to omit the search tool +) +``` + +## Requirements + +Requires `fastmcp[apps]` (installs `prefab-ui`). The Pyodide sandbox for server-side validation requires Deno, which installs automatically on first use. The streaming renderer loads Pyodide from CDN in the browser — CSP is configured automatically by the provider. + +## Learn More + +See the full **[Generative UI guide](/apps/generative)** for details on how streaming works, what the LLM writes, how to pass data, and the component search tool. diff --git a/docs/changelog.mdx b/docs/changelog.mdx index 3e21089af..d55783e9f 100644 --- a/docs/changelog.mdx +++ b/docs/changelog.mdx @@ -9,10 +9,10 @@ tag: NEW **[v3.1.1: 'Tis But a Patch](https://github.com/PrefectHQ/fastmcp/releases/tag/v3.1.1)** -Pins `pydantic-monty<0.0.8` to fix a breaking change in Monty that affects code mode. Monty 0.0.8 removed the `external_functions` constructor parameter, causing `MontySandboxProvider` to fail. This patch caps the version so existing installs work correctly. +Pins `pydantic-monty` below 0.0.8 to fix a breaking change in Monty that affects code mode. Monty 0.0.8 removed the `external_functions` constructor parameter, causing `MontySandboxProvider` to fail. This patch caps the version so existing installs work correctly. ### Fixes 🐞 -* Pin pydantic-monty<0.0.8 to fix code mode by [@jlowin](https://github.com/jlowin) in [#3497](https://github.com/PrefectHQ/fastmcp/pull/3497) +* Pin pydantic-monty below 0.0.8 to fix code mode by [@jlowin](https://github.com/jlowin) in [#3497](https://github.com/PrefectHQ/fastmcp/pull/3497) **Full Changelog**: [v3.1.0...v3.1.1](https://github.com/PrefectHQ/fastmcp/compare/v3.1.0...v3.1.1) diff --git a/docs/docs.json b/docs/docs.json index 5e08f3069..323517825 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -159,19 +159,26 @@ }, { "collapsed": true, - "group": "Authentication", - "icon": "key", + "group": "Auth", + "icon": "shield-check", "pages": [ - "servers/auth/authentication", - "servers/auth/token-verification", - "servers/auth/remote-oauth", - "servers/auth/oauth-proxy", - "servers/auth/oidc-proxy", - "servers/auth/full-oauth-server", - "servers/auth/multi-auth" + { + "collapsed": true, + "group": "Authentication", + "icon": "key", + "pages": [ + "servers/auth/authentication", + "servers/auth/token-verification", + "servers/auth/remote-oauth", + "servers/auth/oauth-proxy", + "servers/auth/oidc-proxy", + "servers/auth/full-oauth-server", + "servers/auth/multi-auth" + ] + }, + "servers/authorization" ] }, - "servers/authorization", { "collapsed": true, "group": "Deployment", @@ -189,16 +196,34 @@ "group": "Apps", "pages": [ "apps/overview", - "apps/prefab", - "apps/interactive-apps", - "apps/generative", - "apps/development", { "collapsed": true, - "group": "Reference", - "icon": "book", + "group": "Building Apps", + "icon": "hammer", "pages": [ - "apps/patterns", + "apps/prefab", + "apps/interactive-apps", + "apps/generative", + "apps/patterns" + ], + "tag": "NEW" + }, + { + "collapsed": true, + "group": "Providers", + "icon": "layer-group", + "pages": [ + "apps/providers/file-upload", + "apps/providers/generative" + ], + "tag": "NEW" + }, + { + "collapsed": true, + "group": "Advanced", + "icon": "gear", + "pages": [ + "apps/development", "apps/architecture", "apps/low-level" ], diff --git a/examples/apps/file_upload/file_upload_server.py b/examples/apps/file_upload/file_upload_server.py index 73e994cc6..c567f7820 100644 --- a/examples/apps/file_upload/file_upload_server.py +++ b/examples/apps/file_upload/file_upload_server.py @@ -1,199 +1,13 @@ """File upload — bypass the LLM context window to get files onto the server. -The most practical use of MCP Apps: letting users upload files directly to -the server without pushing bytes through the model's context. The LLM asks -the user to upload, the user drops files, clicks Upload, and the server -stores them. The LLM can then work with the files through backend tools. - Usage: uv run python file_upload_server.py """ -from __future__ import annotations +from fastmcp import FastMCP +from fastmcp.apps.file_upload import FileUpload -import base64 -from datetime import datetime - -from prefab_ui.actions import SetState, ShowToast -from prefab_ui.actions.mcp import CallTool -from prefab_ui.app import PrefabApp -from prefab_ui.components import ( - H3, - Badge, - Button, - Card, - CardContent, - CardFooter, - CardHeader, - Column, - DropZone, - Muted, - Row, - Separator, - Small, - Text, -) -from prefab_ui.components.control_flow import Else, ForEach, If -from prefab_ui.rx import ERROR, RESULT, STATE, Rx - -from fastmcp import FastMCP, FastMCPApp - -# --------------------------------------------------------------------------- -# In-memory file store -# --------------------------------------------------------------------------- - -_files: dict[str, dict] = {} - -# --------------------------------------------------------------------------- -# App -# --------------------------------------------------------------------------- - -app = FastMCPApp("Files") - - -@app.tool() -def store_files(files: list[dict]) -> list[dict]: - """Store uploaded files. Receives file objects with name, size, type, data (base64).""" - for f in files: - _files[f["name"]] = { - "name": f["name"], - "size": f["size"], - "type": f["type"], - "data": f["data"], - "uploaded_at": datetime.now().isoformat(timespec="seconds"), - } - return _file_summaries() - - -@app.tool(model=True) -def list_files() -> list[dict]: - """List all uploaded files with metadata.""" - return _file_summaries() - - -@app.tool(model=True) -def read_file(name: str) -> dict: - """Read an uploaded file's contents by name.""" - if name not in _files: - available = list(_files.keys()) - raise ValueError(f"File {name!r} not found. Available: {available}") - entry = _files[name] - result = { - "name": entry["name"], - "size": entry["size"], - "type": entry["type"], - "uploaded_at": entry["uploaded_at"], - } - if entry["type"].startswith("text/") or entry["name"].endswith( - (".csv", ".json", ".txt", ".md", ".py", ".yaml", ".yml", ".toml") - ): - try: - result["content"] = base64.b64decode(entry["data"]).decode("utf-8") - except (UnicodeDecodeError, Exception): - result["content_base64"] = entry["data"][:200] + "..." - else: - result["content_base64"] = entry["data"][:200] + "..." - return result - - -@app.ui() -def file_manager() -> PrefabApp: - """Upload and manage files. Drop files here to send them to the server.""" - with Card(css_class="max-w-2xl mx-auto") as view: - with CardHeader(): - with Row(gap=2, align="center"): - H3("File Upload") - with If(STATE.stored.length()): - Badge(STATE.stored.length(), variant="secondary") - - with CardContent(): - with Column(gap=4): - Muted( - "Drop files to upload them to the server. " - "The model can then read and analyze them " - "without using the context window." - ) - - DropZone( - name="pending", - icon="inbox", - label="Drop files here", - description="Any file type, up to 10MB", - multiple=True, - max_size=10 * 1024 * 1024, - ) - - # Show pending files - with If(STATE.pending.length()): - with Column(gap=2): - with ForEach("pending") as (i, item): - with Row(gap=2, align="center"): - with Column(gap=0): - Small(item.name) - Muted(f"{item.type} · {item.size} bytes") - - Button( - "Upload to Server", - on_click=CallTool( - "store_files", - arguments={"files": Rx("pending")}, - on_success=[ - SetState("stored", RESULT), - SetState("pending", []), - ShowToast("Files uploaded!", variant="success"), - ], - on_error=ShowToast(ERROR, variant="error"), - ), - ) - - # Show uploaded files - with If(STATE.stored.length()): - Separator() - Text("Uploaded", css_class="font-medium text-sm") - with ForEach("stored") as f: - with Row(gap=2, align="center", css_class="justify-between"): - with Column(gap=0): - Small(f.name) - Muted(f.uploaded_at) - with Row(gap=2): - Badge(f.type, variant="secondary") - Badge(f.size_display, variant="outline") - - with CardFooter(): - with Row(align="center", css_class="w-full"): - with If(STATE.stored.length()): - Muted( - f"{STATE.stored.length()}" - f" {STATE.stored.length().pluralize('file')} on server" - ) - with Else(): - Muted("No files uploaded yet") - - return PrefabApp(view=view, state={"pending": [], "stored": _file_summaries()}) - - -def _file_summaries() -> list[dict]: - summaries = [] - for entry in _files.values(): - size = entry["size"] - if size < 1024: - size_display = f"{size} B" - elif size < 1024 * 1024: - size_display = f"{size / 1024:.1f} KB" - else: - size_display = f"{size / (1024 * 1024):.1f} MB" - summaries.append( - { - "name": entry["name"], - "type": entry["type"], - "size_display": size_display, - "uploaded_at": entry["uploaded_at"], - } - ) - return summaries - - -mcp = FastMCP("File Upload Server", providers=[app]) +mcp = FastMCP("File Upload Server", providers=[FileUpload()]) if __name__ == "__main__": mcp.run() diff --git a/src/fastmcp/apps/file_upload.py b/src/fastmcp/apps/file_upload.py new file mode 100644 index 000000000..8cc91e039 --- /dev/null +++ b/src/fastmcp/apps/file_upload.py @@ -0,0 +1,393 @@ +"""FileUpload — a Provider that adds drag-and-drop file upload to any server. + +Lets users upload files directly to the server through an interactive UI, +bypassing the LLM context window entirely. The LLM can then read and work +with uploaded files through model-visible tools. + +Requires ``fastmcp[apps]`` (prefab-ui). + +Usage:: + + from fastmcp import FastMCP + from fastmcp.apps import FileUpload + + mcp = FastMCP("My Server") + mcp.add_provider(FileUpload()) + +For custom persistence, override the storage methods:: + + class S3Upload(FileUpload): + def on_store(self, files, ctx): + # write to S3, return summaries + ... + + def on_list(self, ctx): + # list from S3 + ... + + def on_read(self, name, ctx): + # read from S3 + ... +""" + +from __future__ import annotations + +try: + from prefab_ui.actions import SetState, ShowToast + from prefab_ui.actions.mcp import CallTool + from prefab_ui.app import PrefabApp + from prefab_ui.components import ( + H3, + Badge, + Button, + Card, + CardContent, + CardFooter, + CardHeader, + Column, + DropZone, + Muted, + Row, + Separator, + Small, + Text, + ) + from prefab_ui.components.control_flow import Else, ForEach, If + from prefab_ui.rx import ERROR, RESULT, STATE, Rx +except ImportError as _exc: + raise ImportError( + "FileUpload requires prefab-ui. Install with: pip install 'fastmcp[apps]'" + ) from _exc + +import base64 +from datetime import datetime +from typing import Any + +from fastmcp.apps.app import FastMCPApp +from fastmcp.server.context import Context + +_TEXT_EXTENSIONS = frozenset( + (".csv", ".json", ".txt", ".md", ".py", ".yaml", ".yml", ".toml") +) + + +def _format_size(size: int) -> str: + if size < 1024: + return f"{size} B" + elif size < 1024 * 1024: + return f"{size / 1024:.1f} KB" + else: + return f"{size / (1024 * 1024):.1f} MB" + + +def _make_summary(entry: dict[str, Any]) -> dict[str, Any]: + return { + "name": entry["name"], + "type": entry["type"], + "size": entry["size"], + "size_display": _format_size(entry["size"]), + "uploaded_at": entry["uploaded_at"], + } + + +class FileUpload(FastMCPApp): + """A Provider that adds file upload capabilities to a server. + + Registers a drag-and-drop UI tool, a backend storage tool, and + model-visible tools for listing and reading uploaded files. + + Files are scoped by MCP session and stored in memory by default. + Override ``on_store``, ``on_list``, and ``on_read`` for custom + persistence (filesystem, S3, database, etc.). Each method receives + the current ``Context``, giving access to session ID, auth tokens, + and request metadata for partitioning and authorization. + + **Session scoping:** The default storage uses ``ctx.session_id`` to + isolate files by session. This works with stdio, SSE, and stateful + HTTP transports. In **stateless HTTP** mode, each request creates a + new session, so files won't persist across requests. For stateless + deployments, override the storage methods to partition by a stable + identifier from the auth context:: + + class UserScopedUpload(FileUpload): + def on_store(self, files, ctx): + user_id = ctx.access_token["sub"] + ... + + Example:: + + from fastmcp import FastMCP + from fastmcp.apps.file_upload import FileUpload + + mcp = FastMCP("My Server") + mcp.add_provider(FileUpload()) + """ + + def __init__( + self, + name: str = "Files", + *, + max_file_size: int = 10 * 1024 * 1024, + title: str = "File Upload", + description: str = ( + "Drop files to upload them to the server. " + "The model can then read and analyze them " + "without using the context window." + ), + drop_label: str = "Drop files here", + ) -> None: + super().__init__(name) + self._max_file_size = max_file_size + self._title = title + self._description = description + self._drop_label = drop_label + + # Default in-memory store, keyed by session_id + self._store: dict[str, dict[str, dict[str, Any]]] = {} + + self._register_tools() + + def __repr__(self) -> str: + return f"FileUpload({self.name!r})" + + # ------------------------------------------------------------------ + # Storage interface — override these for custom persistence + # ------------------------------------------------------------------ + + def _get_scope_key(self, ctx: Context) -> str: + """Return the key used to partition file storage. + + Defaults to ``ctx.session_id``, which is stable for stdio, SSE, + and stateful HTTP. The default ``on_store``/``on_list``/``on_read`` + implementations call this to partition the in-memory store. + + Override to scope by user, tenant, or any other dimension:: + + def _get_scope_key(self, ctx): + return ctx.access_token["sub"] + """ + try: + return ctx.session_id + except RuntimeError: + return "__default__" + + def on_store( + self, + files: list[dict[str, Any]], + ctx: Context, + ) -> list[dict[str, Any]]: + """Store uploaded files and return summaries. + + Args: + files: List of file dicts, each with ``name``, ``size``, + ``type``, and ``data`` (base64-encoded content). + ctx: The current request context. Use for session ID, + auth tokens, or any metadata needed for partitioning. + + Override this method for custom persistence. The default + implementation stores files in memory, scoped by + ``_get_scope_key(ctx)``. + + Returns: + List of file summary dicts (``name``, ``type``, ``size``, + ``size_display``, ``uploaded_at``). + """ + scope = self._get_scope_key(ctx) + session_files = self._store.setdefault(scope, {}) + for f in files: + session_files[f["name"]] = { + "name": f["name"], + "size": f["size"], + "type": f["type"], + "data": f["data"], + "uploaded_at": datetime.now().isoformat(timespec="seconds"), + } + return [_make_summary(e) for e in session_files.values()] + + def on_list(self, ctx: Context) -> list[dict[str, Any]]: + """List all stored files. + + Args: + ctx: The current request context. + + Override this method for custom persistence. The default + implementation returns files from the current scope. + + Returns: + List of file summary dicts. + """ + scope = self._get_scope_key(ctx) + session_files = self._store.get(scope, {}) + return [_make_summary(e) for e in session_files.values()] + + def on_read(self, name: str, ctx: Context) -> dict[str, Any]: + """Read a file's contents by name. + + Args: + name: The filename to read. + ctx: The current request context. + + Override this method for custom persistence. The default + implementation reads from the current scope's in-memory store. + Text files are decoded from base64; binary files return a + truncated base64 preview. + + Returns: + Dict with file metadata and ``content`` (text) or + ``content_base64`` (binary preview). + + Raises: + ValueError: If the file is not found. + """ + scope = self._get_scope_key(ctx) + session_files = self._store.get(scope, {}) + if name not in session_files: + available = list(session_files.keys()) + raise ValueError(f"File {name!r} not found. Available: {available}") + entry = session_files[name] + result: dict[str, Any] = { + "name": entry["name"], + "size": entry["size"], + "type": entry["type"], + "uploaded_at": entry["uploaded_at"], + } + is_text = entry["type"].startswith("text/") or any( + entry["name"].endswith(ext) for ext in _TEXT_EXTENSIONS + ) + if is_text: + try: + result["content"] = base64.b64decode(entry["data"]).decode("utf-8") + except UnicodeDecodeError: + result["content_base64"] = entry["data"][:200] + "..." + else: + result["content_base64"] = entry["data"][:200] + "..." + return result + + # ------------------------------------------------------------------ + # Tool registration + # ------------------------------------------------------------------ + + def _register_tools(self) -> None: + provider = self + + @self.tool() + def store_files(files: list[dict], ctx: Context) -> list[dict]: + """Store uploaded files. Receives file objects with name, size, type, data (base64).""" + for f in files: + if f.get("size", 0) > provider._max_file_size: + raise ValueError( + f"File {f.get('name', '?')!r} exceeds max size " + f"({_format_size(f['size'])} > " + f"{_format_size(provider._max_file_size)})" + ) + return provider.on_store(files, ctx) + + @self.tool(model=True) + def list_files(ctx: Context) -> list[dict]: + """List all uploaded files with metadata.""" + return provider.on_list(ctx) + + @self.tool(model=True) + def read_file(name: str, ctx: Context) -> dict: + """Read an uploaded file's contents by name.""" + return provider.on_read(name, ctx) + + @self.ui() + def file_manager(ctx: Context) -> PrefabApp: + """Upload and manage files. Drop files here to send them to the server.""" + with Card(css_class="max-w-2xl mx-auto") as view: + with CardHeader(), Row(gap=2, align="center"): + H3(provider._title) + with If(STATE.stored.length()): + Badge( + STATE.stored.length(), # ty:ignore[invalid-argument-type] + variant="secondary", + ) + + with CardContent(), Column(gap=4): + Muted(provider._description) + + DropZone( + name="pending", + icon="inbox", + label=provider._drop_label, + description=( + "Any file type, up to " + f"{_format_size(provider._max_file_size)}" + ), + multiple=True, + max_size=provider._max_file_size, + ) + + with If(STATE.pending.length()), Column(gap=2): + with ( + ForEach("pending"), + Row(gap=2, align="center"), + Column(gap=0), + ): + Small(Rx("$item.name")) # ty:ignore[invalid-argument-type] + Muted(Rx("$item.type")) # ty:ignore[invalid-argument-type] + + Button( + "Upload to Server", + on_click=CallTool( + "store_files", + arguments={ + "files": Rx("pending"), + }, + on_success=[ + SetState("stored", RESULT), + SetState("pending", []), + ShowToast( + "Files uploaded!", + variant="success", + ), + ], + on_error=ShowToast( + ERROR, # ty:ignore[invalid-argument-type] + variant="error", + ), + ), + ) + + with If(STATE.stored.length()): + Separator() + Text( + "Uploaded", + css_class="font-medium text-sm", + ) + with ( + ForEach("stored") as f, + Row( + gap=2, + align="center", + css_class="justify-between", + ), + ): + with Column(gap=0): + Small(f.name) # ty:ignore[invalid-argument-type] + Muted(f.uploaded_at) # ty:ignore[invalid-argument-type] + with Row(gap=2): + Badge(f.type, variant="secondary") # ty:ignore[invalid-argument-type] + Badge( + f.size_display, # ty:ignore[invalid-argument-type] + variant="outline", + ) + + with CardFooter(), Row(align="center", css_class="w-full"): + with If(STATE.stored.length()): + Muted( + f"{STATE.stored.length()}" + f" {STATE.stored.length().pluralize('file')}" + " on server" + ) + with Else(): + Muted("No files uploaded yet") + + return PrefabApp( + view=view, + state={ + "pending": [], + "stored": provider.on_list(ctx), + }, + ) diff --git a/tests/apps/__init__.py b/tests/apps/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/apps/test_file_upload.py b/tests/apps/test_file_upload.py new file mode 100644 index 000000000..c8bd7470e --- /dev/null +++ b/tests/apps/test_file_upload.py @@ -0,0 +1,174 @@ +"""Tests for the FileUpload provider.""" + +import base64 + +import pytest + +from fastmcp import FastMCP +from fastmcp.apps.file_upload import FileUpload + + +def _make_file( + name: str = "test.txt", + content: str = "hello world", + mime_type: str = "text/plain", +) -> dict: + data = base64.b64encode(content.encode()).decode() + return { + "name": name, + "size": len(content), + "type": mime_type, + "data": data, + } + + +class TestFileUploadProvider: + async def test_basic_store_and_list(self): + server = FastMCP("test", providers=[FileUpload()]) + files = [_make_file()] + + result = await server.call_tool("Files___store_files", {"files": files}) + text = result.content[0].text # type: ignore[union-attr] # ty:ignore[unresolved-attribute] + assert "test.txt" in text + + result = await server.call_tool("list_files", {}) + text = result.content[0].text # type: ignore[union-attr] # ty:ignore[unresolved-attribute] + assert "test.txt" in text + + async def test_read_text_file(self): + server = FastMCP("test", providers=[FileUpload()]) + files = [_make_file(content="DON'T PANIC")] + + await server.call_tool("Files___store_files", {"files": files}) + + result = await server.call_tool("read_file", {"name": "test.txt"}) + text = result.content[0].text # type: ignore[union-attr] # ty:ignore[unresolved-attribute] + assert "DON'T PANIC" in text + + async def test_read_binary_file(self): + server = FastMCP("test", providers=[FileUpload()]) + data = base64.b64encode(b"\x00\x01\x02\xff").decode() + files = [{"name": "image.png", "size": 4, "type": "image/png", "data": data}] + + await server.call_tool("Files___store_files", {"files": files}) + + result = await server.call_tool("read_file", {"name": "image.png"}) + text = result.content[0].text # type: ignore[union-attr] # ty:ignore[unresolved-attribute] + assert "content_base64" in text + + async def test_read_missing_file_raises(self): + server = FastMCP("test", providers=[FileUpload()]) + + with pytest.raises(Exception, match="not found"): + await server.call_tool("read_file", {"name": "nope.txt"}) + + async def test_multiple_files(self): + server = FastMCP("test", providers=[FileUpload()]) + files = [ + _make_file("a.txt", "aaa"), + _make_file("b.txt", "bbb"), + ] + + await server.call_tool("Files___store_files", {"files": files}) + + result = await server.call_tool("list_files", {}) + text = result.content[0].text # type: ignore[union-attr] # ty:ignore[unresolved-attribute] + assert "a.txt" in text + assert "b.txt" in text + + async def test_overwrite_file(self): + server = FastMCP("test", providers=[FileUpload()]) + + await server.call_tool( + "Files___store_files", + {"files": [_make_file(content="version 1")]}, + ) + await server.call_tool( + "Files___store_files", + {"files": [_make_file(content="version 2")]}, + ) + + result = await server.call_tool("read_file", {"name": "test.txt"}) + text = result.content[0].text # type: ignore[union-attr] # ty:ignore[unresolved-attribute] + assert "version 2" in text + + async def test_custom_name(self): + server = FastMCP("test", providers=[FileUpload(name="Uploads")]) + + tools = await server.list_tools() + tool_names = [t.name for t in tools] + assert "file_manager" in tool_names + + # Routing uses the custom name + files = [_make_file()] + result = await server.call_tool("Uploads___store_files", {"files": files}) + text = result.content[0].text # type: ignore[union-attr] # ty:ignore[unresolved-attribute] + assert "test.txt" in text + + async def test_ui_tool_visible_backend_hidden(self): + server = FastMCP("test", providers=[FileUpload()]) + + tools = await server.list_tools() + tool_names = [t.name for t in tools] + + assert "file_manager" in tool_names + assert "list_files" in tool_names + assert "read_file" in tool_names + assert "store_files" not in tool_names + + async def test_max_file_size_enforced_server_side(self): + server = FastMCP("test", providers=[FileUpload(max_file_size=100)]) + big_file = _make_file(content="x" * 200) + + with pytest.raises(Exception, match="exceeds max size"): + await server.call_tool("Files___store_files", {"files": [big_file]}) + + +class TestFileUploadSubclass: + async def test_custom_storage(self): + """Subclassing lets users provide their own persistence.""" + stored: dict[str, dict] = {} + + class MemoryUpload(FileUpload): + def on_store(self, files: list[dict], ctx) -> list[dict]: + for f in files: + stored[f["name"]] = f + return [ + { + "name": f["name"], + "type": f["type"], + "size": f["size"], + "size_display": "?", + "uploaded_at": "now", + } + for f in files + ] + + def on_list(self, ctx) -> list[dict]: + return [ + { + "name": f["name"], + "type": f["type"], + "size": f["size"], + "size_display": "?", + "uploaded_at": "now", + } + for f in stored.values() + ] + + def on_read(self, name: str, ctx) -> dict: + if name not in stored: + raise ValueError(f"Not found: {name}") + f = stored[name] + return {"name": f["name"], "content": "custom read"} + + server = FastMCP("test", providers=[MemoryUpload()]) + files = [_make_file()] + + await server.call_tool("Files___store_files", {"files": files}) + + assert "test.txt" in stored + + result = await server.call_tool("read_file", {"name": "test.txt"}) + text = result.content[0].text # type: ignore[union-attr] # ty:ignore[unresolved-attribute] + assert "custom read" in text