From c8799a5cba2fb4bde4d41bba7aa93f79294e958b Mon Sep 17 00:00:00 2001
From: Adam Azzam <33043305+AAAZZAM@users.noreply.github.com>
Date: Mon, 20 Oct 2025 11:25:32 -0400
Subject: [PATCH 1/5] init
---
src/fastmcp/server/context.py | 10 +-
src/fastmcp/server/server.py | 9 ++
src/fastmcp/ui/__init__.py | 5 +
src/fastmcp/ui/manager.py | 26 +++++
src/fastmcp/ui/openai/__init__.py | 5 +
src/fastmcp/ui/openai/manager.py | 178 ++++++++++++++++++++++++++++++
tests/ui/__init__.py | 1 +
tests/ui/test_openai_widget.py | 82 ++++++++++++++
8 files changed, 313 insertions(+), 3 deletions(-)
create mode 100644 src/fastmcp/ui/__init__.py
create mode 100644 src/fastmcp/ui/manager.py
create mode 100644 src/fastmcp/ui/openai/__init__.py
create mode 100644 src/fastmcp/ui/openai/manager.py
create mode 100644 tests/ui/__init__.py
create mode 100644 tests/ui/test_openai_widget.py
diff --git a/src/fastmcp/server/context.py b/src/fastmcp/server/context.py
index a0fcfc1c2..12d6b46ac 100644
--- a/src/fastmcp/server/context.py
+++ b/src/fastmcp/server/context.py
@@ -429,9 +429,13 @@ async def sample(
]
elif isinstance(messages, Sequence):
sampling_messages = [
- SamplingMessage(content=TextContent(text=m, type="text"), role="user")
- if isinstance(m, str)
- else m
+ (
+ SamplingMessage(
+ content=TextContent(text=m, type="text"), role="user"
+ )
+ if isinstance(m, str)
+ else m
+ )
for m in messages
]
diff --git a/src/fastmcp/server/server.py b/src/fastmcp/server/server.py
index c758f64cb..e3eda0147 100644
--- a/src/fastmcp/server/server.py
+++ b/src/fastmcp/server/server.py
@@ -59,6 +59,7 @@
from fastmcp.tools import ToolManager
from fastmcp.tools.tool import FunctionTool, Tool, ToolResult
from fastmcp.tools.tool_transform import ToolTransformConfig
+from fastmcp.ui import UIManager
from fastmcp.utilities.cli import log_server_banner
from fastmcp.utilities.components import FastMCPComponent
from fastmcp.utilities.logging import get_logger, temporary_log_level
@@ -192,6 +193,7 @@ def __init__(
duplicate_behavior=on_duplicate_prompts,
mask_error_details=mask_error_details,
)
+ self._ui_manager: UIManager | None = None
self._tool_serializer = tool_serializer
self._lifespan: LifespanCallable[LifespanResultT] = lifespan or default_lifespan
@@ -357,6 +359,13 @@ def icons(self) -> list[mcp.types.Icon]:
else:
return list(self._mcp_server.icons)
+ @property
+ def ui(self) -> UIManager:
+ """Access UI components and integrations."""
+ if self._ui_manager is None:
+ self._ui_manager = UIManager(self)
+ return self._ui_manager
+
@asynccontextmanager
async def _lifespan_manager(self) -> AsyncIterator[None]:
if self._lifespan_result_set:
diff --git a/src/fastmcp/ui/__init__.py b/src/fastmcp/ui/__init__.py
new file mode 100644
index 000000000..d93486423
--- /dev/null
+++ b/src/fastmcp/ui/__init__.py
@@ -0,0 +1,5 @@
+"""UI components and integrations."""
+
+from fastmcp.ui.manager import UIManager
+
+__all__ = ["UIManager"]
diff --git a/src/fastmcp/ui/manager.py b/src/fastmcp/ui/manager.py
new file mode 100644
index 000000000..a55dbc229
--- /dev/null
+++ b/src/fastmcp/ui/manager.py
@@ -0,0 +1,26 @@
+"""UI manager for FastMCP."""
+
+from __future__ import annotations
+
+from typing import TYPE_CHECKING, Any
+
+if TYPE_CHECKING:
+ from fastmcp.server.server import FastMCP
+ from fastmcp.ui.openai import OpenAIUIManager
+
+
+class UIManager:
+ """Manager for UI-related components and integrations."""
+
+ def __init__(self, fastmcp: FastMCP[Any]) -> None:
+ self._fastmcp = fastmcp
+ self._openai: OpenAIUIManager | None = None
+
+ @property
+ def openai(self) -> OpenAIUIManager:
+ """Access OpenAI-specific UI components."""
+ if self._openai is None:
+ from fastmcp.ui.openai import OpenAIUIManager
+
+ self._openai = OpenAIUIManager(self._fastmcp)
+ return self._openai
diff --git a/src/fastmcp/ui/openai/__init__.py b/src/fastmcp/ui/openai/__init__.py
new file mode 100644
index 000000000..d606ad56f
--- /dev/null
+++ b/src/fastmcp/ui/openai/__init__.py
@@ -0,0 +1,5 @@
+"""OpenAI UI components."""
+
+from fastmcp.ui.openai.manager import OpenAIUIManager
+
+__all__ = ["OpenAIUIManager"]
diff --git a/src/fastmcp/ui/openai/manager.py b/src/fastmcp/ui/openai/manager.py
new file mode 100644
index 000000000..8642a5ddf
--- /dev/null
+++ b/src/fastmcp/ui/openai/manager.py
@@ -0,0 +1,178 @@
+"""OpenAI-specific UI components and widgets."""
+
+from __future__ import annotations
+
+import inspect
+from collections.abc import Callable
+from functools import partial
+from typing import TYPE_CHECKING, Any, cast, overload
+
+from mcp.types import AnyFunction, ToolAnnotations
+
+from fastmcp.tools.tool import FunctionTool, Tool
+from fastmcp.utilities.types import NotSet, NotSetT
+
+if TYPE_CHECKING:
+ from fastmcp.server.server import FastMCP
+
+
+class OpenAIUIManager:
+ """Manager for OpenAI-specific UI components like widgets."""
+
+ def __init__(self, fastmcp: FastMCP[Any]) -> None:
+ self._fastmcp = fastmcp
+
+ @overload
+ def widget(
+ self,
+ name_or_fn: AnyFunction,
+ *,
+ name: str | None = None,
+ title: str | None = None,
+ description: str | None = None,
+ tags: set[str] | None = None,
+ output_schema: dict[str, Any] | None | NotSetT = NotSet,
+ annotations: ToolAnnotations | dict[str, Any] | None = None,
+ exclude_args: list[str] | None = None,
+ meta: dict[str, Any] | None = None,
+ enabled: bool | None = None,
+ ) -> FunctionTool: ...
+
+ @overload
+ def widget(
+ self,
+ name_or_fn: str | None = None,
+ *,
+ name: str | None = None,
+ title: str | None = None,
+ description: str | None = None,
+ tags: set[str] | None = None,
+ output_schema: dict[str, Any] | None | NotSetT = NotSet,
+ annotations: ToolAnnotations | dict[str, Any] | None = None,
+ exclude_args: list[str] | None = None,
+ meta: dict[str, Any] | None = None,
+ enabled: bool | None = None,
+ ) -> Callable[[AnyFunction], FunctionTool]: ...
+
+ def widget(
+ self,
+ name_or_fn: str | AnyFunction | None = None,
+ *,
+ name: str | None = None,
+ title: str | None = None,
+ description: str | None = None,
+ tags: set[str] | None = None,
+ output_schema: dict[str, Any] | None | NotSetT = NotSet,
+ annotations: ToolAnnotations | dict[str, Any] | None = None,
+ exclude_args: list[str] | None = None,
+ meta: dict[str, Any] | None = None,
+ enabled: bool | None = None,
+ ) -> Callable[[AnyFunction], FunctionTool] | FunctionTool:
+ """Decorator to register an OpenAI widget as a tool.
+
+ OpenAI widgets are tools that can be called by OpenAI's assistants and displayed
+ in custom UIs. This decorator works identically to @server.tool but provides
+ semantic clarity for UI-focused functionality.
+
+ This decorator supports multiple calling patterns:
+ - @server.ui.openai.widget (without parentheses)
+ - @server.ui.openai.widget() (with empty parentheses)
+ - @server.ui.openai.widget("custom_name") (with name as first argument)
+ - @server.ui.openai.widget(name="custom_name") (with name as keyword argument)
+
+ Args:
+ name_or_fn: Either a function (when used as decorator), a string name, or None
+ name: Optional name for the widget (keyword-only, alternative to name_or_fn)
+ title: Optional title for the widget
+ description: Optional description of what the widget does
+ tags: Optional set of tags for categorizing the widget
+ output_schema: Optional JSON schema for the widget's output
+ annotations: Optional annotations about the widget's behavior
+ exclude_args: Optional list of argument names to exclude from the schema
+ meta: Optional meta information about the widget
+ enabled: Optional boolean to enable or disable the widget
+
+ Examples:
+ Register an OpenAI widget:
+ ```python
+ @app.ui.openai.widget
+ def my_widget(x: int) -> str:
+ return str(x)
+
+ @app.ui.openai.widget("custom_name")
+ def another_widget(data: str) -> dict:
+ return {"result": data}
+
+ @app.ui.openai.widget(name="weather_display")
+ def show_weather(city: str) -> str:
+ return f"Weather for {city}"
+ ```
+ """
+ if isinstance(annotations, dict):
+ annotations = ToolAnnotations(**annotations)
+
+ if isinstance(name_or_fn, classmethod):
+ raise ValueError(
+ inspect.cleandoc(
+ """
+ To decorate a classmethod, first define the method and then call
+ widget() directly on the method instead of using it as a
+ decorator. See https://gofastmcp.com/patterns/decorating-methods
+ for examples and more information.
+ """
+ )
+ )
+
+ # Determine the actual name and function based on the calling pattern
+ if inspect.isroutine(name_or_fn):
+ # Case 1: @widget (without parens) - function passed directly
+ # Case 2: direct call like widget(fn, name="something")
+ fn = name_or_fn
+ widget_name = name # Use keyword name if provided, otherwise None
+
+ # Register the widget as a tool immediately and return the tool object
+ tool = Tool.from_function(
+ fn,
+ name=widget_name,
+ title=title,
+ description=description,
+ tags=tags,
+ output_schema=output_schema,
+ annotations=cast(ToolAnnotations | None, annotations),
+ exclude_args=exclude_args,
+ meta=meta,
+ serializer=self._fastmcp._tool_serializer,
+ enabled=enabled,
+ )
+ self._fastmcp.add_tool(tool)
+ return tool
+
+ elif isinstance(name_or_fn, str):
+ # Case 3: @widget("custom_name") - name passed as first argument
+ if name is not None:
+ raise TypeError(
+ "Cannot specify both a name as first argument and as keyword argument. "
+ f"Use either @widget('{name_or_fn}') or @widget(name='{name}'), not both."
+ )
+ widget_name = name_or_fn
+ elif name_or_fn is None:
+ # Case 4: @widget or @widget(name="something") - use keyword name
+ widget_name = name
+ else:
+ raise TypeError(
+ f"First argument to @widget must be a function, string, or None, got {type(name_or_fn)}"
+ )
+
+ # Return partial for cases where we need to wait for the function
+ return partial(
+ self.widget,
+ name=widget_name,
+ title=title,
+ description=description,
+ tags=tags,
+ output_schema=output_schema,
+ annotations=annotations,
+ exclude_args=exclude_args,
+ meta=meta,
+ enabled=enabled,
+ )
diff --git a/tests/ui/__init__.py b/tests/ui/__init__.py
new file mode 100644
index 000000000..498a7a02f
--- /dev/null
+++ b/tests/ui/__init__.py
@@ -0,0 +1 @@
+"""Tests for UI components."""
diff --git a/tests/ui/test_openai_widget.py b/tests/ui/test_openai_widget.py
new file mode 100644
index 000000000..70be88a54
--- /dev/null
+++ b/tests/ui/test_openai_widget.py
@@ -0,0 +1,82 @@
+"""Tests for OpenAI widget decorator."""
+
+from fastmcp import FastMCP
+
+
+async def test_widget_decorator_basic():
+ """Test basic widget decorator usage."""
+ app = FastMCP("test")
+
+ @app.ui.openai.widget
+ def my_widget(x: int) -> str:
+ return f"Result: {x}"
+
+ # Widget should be registered as a tool
+ tools = await app.get_tools()
+ assert "my_widget" in tools
+ assert tools["my_widget"].name == "my_widget"
+
+
+async def test_widget_decorator_with_name():
+ """Test widget decorator with custom name."""
+ app = FastMCP("test")
+
+ @app.ui.openai.widget("custom_widget")
+ def my_function(x: int) -> str:
+ return f"Result: {x}"
+
+ tools = await app.get_tools()
+ assert "custom_widget" in tools
+ assert tools["custom_widget"].name == "custom_widget"
+
+
+async def test_widget_decorator_with_name_kwarg():
+ """Test widget decorator with name as keyword argument."""
+ app = FastMCP("test")
+
+ @app.ui.openai.widget(name="named_widget")
+ def my_function(x: int) -> str:
+ return f"Result: {x}"
+
+ tools = await app.get_tools()
+ assert "named_widget" in tools
+ assert tools["named_widget"].name == "named_widget"
+
+
+async def test_widget_decorator_with_description():
+ """Test widget decorator with description."""
+ app = FastMCP("test")
+
+ @app.ui.openai.widget(description="This is a test widget")
+ def my_widget(x: int) -> str:
+ return f"Result: {x}"
+
+ tools = await app.get_tools()
+ assert "my_widget" in tools
+ assert tools["my_widget"].description == "This is a test widget"
+
+
+async def test_widget_decorator_with_title():
+ """Test widget decorator with title."""
+ app = FastMCP("test")
+
+ @app.ui.openai.widget(title="My Widget Title")
+ def my_widget(x: int) -> str:
+ return f"Result: {x}"
+
+ tools = await app.get_tools()
+ assert "my_widget" in tools
+ assert tools["my_widget"].title == "My Widget Title"
+
+
+async def test_widget_can_be_called():
+ """Test that registered widgets can be called as tools."""
+ app = FastMCP("test")
+
+ @app.ui.openai.widget
+ def calculator(x: int, y: int) -> int:
+ return x + y
+
+ # Call the tool through the tool manager
+ result = await app._tool_manager.call_tool("calculator", {"x": 5, "y": 3})
+ assert result.content[0].text == "8" # type: ignore[attr-defined]
From 5db0702f6ab49bc2ee125bb9914c922fbdc076ec Mon Sep 17 00:00:00 2001
From: Adam Azzam <33043305+AAAZZAM@users.noreply.github.com>
Date: Mon, 20 Oct 2025 11:52:13 -0400
Subject: [PATCH 2/5] push
---
src/fastmcp/ui/openai/__init__.py | 12 +-
src/fastmcp/ui/openai/manager.py | 396 +++++++++++++++++++++++-----
src/fastmcp/ui/openai/metadata.py | 265 +++++++++++++++++++
src/fastmcp/ui/openai/response.py | 135 ++++++++++
tests/ui/test_openai_widget.py | 57 ++--
tests/ui/test_openai_widget_full.py | 357 +++++++++++++++++++++++++
6 files changed, 1131 insertions(+), 91 deletions(-)
create mode 100644 src/fastmcp/ui/openai/metadata.py
create mode 100644 src/fastmcp/ui/openai/response.py
create mode 100644 tests/ui/test_openai_widget_full.py
diff --git a/src/fastmcp/ui/openai/__init__.py b/src/fastmcp/ui/openai/__init__.py
index d606ad56f..02e3a613c 100644
--- a/src/fastmcp/ui/openai/__init__.py
+++ b/src/fastmcp/ui/openai/__init__.py
@@ -1,5 +1,15 @@
"""OpenAI UI components."""
from fastmcp.ui.openai.manager import OpenAIUIManager
+from fastmcp.ui.openai.response import (
+ WidgetToolResponse,
+ build_widget_tool_response,
+ transform_widget_response,
+)
-__all__ = ["OpenAIUIManager"]
+__all__ = [
+ "OpenAIUIManager",
+ "WidgetToolResponse",
+ "build_widget_tool_response",
+ "transform_widget_response",
+]
diff --git a/src/fastmcp/ui/openai/manager.py b/src/fastmcp/ui/openai/manager.py
index 8642a5ddf..8f9fb399c 100644
--- a/src/fastmcp/ui/openai/manager.py
+++ b/src/fastmcp/ui/openai/manager.py
@@ -2,15 +2,22 @@
from __future__ import annotations
+import asyncio
+import functools
import inspect
-from collections.abc import Callable
-from functools import partial
+from collections.abc import Callable, Sequence
from typing import TYPE_CHECKING, Any, cast, overload
from mcp.types import AnyFunction, ToolAnnotations
-from fastmcp.tools.tool import FunctionTool, Tool
-from fastmcp.utilities.types import NotSet, NotSetT
+from fastmcp.tools.tool import FunctionTool
+from fastmcp.ui.openai.metadata import (
+ build_widget_resource_meta,
+ build_widget_tool_meta,
+ merge_resource_annotations,
+ merge_tool_annotations,
+)
+from fastmcp.ui.openai.response import transform_widget_response
if TYPE_CHECKING:
from fastmcp.server.server import FastMCP
@@ -27,15 +34,26 @@ def widget(
self,
name_or_fn: AnyFunction,
*,
- name: str | None = None,
+ identifier: str | None = None,
+ template_uri: str,
+ html: str,
title: str | None = None,
description: str | None = None,
+ invoking: str | None = None,
+ invoked: str | None = None,
+ mime_type: str = "text/html+skybridge",
+ widget_description: str | None = None,
+ widget_prefers_border: bool = True,
+ widget_csp_resources: Sequence[str] | None = None,
+ widget_csp_connect: Sequence[str] | None = None,
+ widget_accessible: bool = True,
+ result_can_produce_widget: bool = True,
tags: set[str] | None = None,
- output_schema: dict[str, Any] | None | NotSetT = NotSet,
annotations: ToolAnnotations | dict[str, Any] | None = None,
- exclude_args: list[str] | None = None,
+ resource_annotations: dict[str, Any] | None = None,
meta: dict[str, Any] | None = None,
enabled: bool | None = None,
+ exclude_args: list[str] | None = None,
) -> FunctionTool: ...
@overload
@@ -43,71 +61,139 @@ def widget(
self,
name_or_fn: str | None = None,
*,
- name: str | None = None,
+ identifier: str | None = None,
+ template_uri: str,
+ html: str,
title: str | None = None,
description: str | None = None,
+ invoking: str | None = None,
+ invoked: str | None = None,
+ mime_type: str = "text/html+skybridge",
+ widget_description: str | None = None,
+ widget_prefers_border: bool = True,
+ widget_csp_resources: Sequence[str] | None = None,
+ widget_csp_connect: Sequence[str] | None = None,
+ widget_accessible: bool = True,
+ result_can_produce_widget: bool = True,
tags: set[str] | None = None,
- output_schema: dict[str, Any] | None | NotSetT = NotSet,
annotations: ToolAnnotations | dict[str, Any] | None = None,
- exclude_args: list[str] | None = None,
+ resource_annotations: dict[str, Any] | None = None,
meta: dict[str, Any] | None = None,
enabled: bool | None = None,
+ exclude_args: list[str] | None = None,
) -> Callable[[AnyFunction], FunctionTool]: ...
def widget(
self,
name_or_fn: str | AnyFunction | None = None,
*,
- name: str | None = None,
+ identifier: str | None = None,
+ template_uri: str | None = None,
+ html: str | None = None,
title: str | None = None,
description: str | None = None,
+ invoking: str | None = None,
+ invoked: str | None = None,
+ mime_type: str = "text/html+skybridge",
+ widget_description: str | None = None,
+ widget_prefers_border: bool = True,
+ widget_csp_resources: Sequence[str] | None = None,
+ widget_csp_connect: Sequence[str] | None = None,
+ widget_accessible: bool = True,
+ result_can_produce_widget: bool = True,
tags: set[str] | None = None,
- output_schema: dict[str, Any] | None | NotSetT = NotSet,
annotations: ToolAnnotations | dict[str, Any] | None = None,
- exclude_args: list[str] | None = None,
+ resource_annotations: dict[str, Any] | None = None,
meta: dict[str, Any] | None = None,
enabled: bool | None = None,
+ exclude_args: list[str] | None = None,
) -> Callable[[AnyFunction], FunctionTool] | FunctionTool:
- """Decorator to register an OpenAI widget as a tool.
+ """Register an OpenAI widget as a tool.
- OpenAI widgets are tools that can be called by OpenAI's assistants and displayed
- in custom UIs. This decorator works identically to @server.tool but provides
- semantic clarity for UI-focused functionality.
+ OpenAI widgets are interactive UI components that display in ChatGPT.
+ This decorator automatically:
+ 1. Registers widget HTML as an MCP resource
+ 2. Registers a tool with OpenAI metadata linking to the widget
+ 3. Auto-transforms function return values (str, dict, or tuple) to OpenAI format
+
+ The widget function can return:
+ - `str`: Narrative text shown in conversation (no structured data)
+ - `dict`: Structured data passed to widget JavaScript (no narrative)
+ - `tuple[str, dict]`: Both narrative text and structured data
This decorator supports multiple calling patterns:
- - @server.ui.openai.widget (without parentheses)
- - @server.ui.openai.widget() (with empty parentheses)
- - @server.ui.openai.widget("custom_name") (with name as first argument)
- - @server.ui.openai.widget(name="custom_name") (with name as keyword argument)
+ - @server.ui.openai.widget(identifier=..., template_uri=..., html=...)
+ - @server.ui.openai.widget("name", identifier=..., template_uri=..., html=...)
Args:
- name_or_fn: Either a function (when used as decorator), a string name, or None
- name: Optional name for the widget (keyword-only, alternative to name_or_fn)
- title: Optional title for the widget
- description: Optional description of what the widget does
- tags: Optional set of tags for categorizing the widget
- output_schema: Optional JSON schema for the widget's output
- annotations: Optional annotations about the widget's behavior
- exclude_args: Optional list of argument names to exclude from the schema
- meta: Optional meta information about the widget
- enabled: Optional boolean to enable or disable the widget
+ name_or_fn: Either a function (direct decoration) or string name
+ identifier: Tool name (defaults to function name or name_or_fn if str)
+ template_uri: Widget template URI (e.g. "ui://widget/pizza-map.html")
+ html: Widget HTML content (must include div and script tags)
+ title: Widget display title (defaults to identifier)
+ description: Tool description (defaults to function docstring)
+ invoking: Status message while tool executes (e.g. "Hand-tossing a map")
+ invoked: Status message after tool completes (e.g. "Served a fresh map")
+ mime_type: MIME type for widget HTML (default: "text/html+skybridge")
+ widget_description: Description of widget UI (defaults to "{title} widget UI.")
+ widget_prefers_border: Whether widget prefers a border (default: True)
+ widget_csp_resources: CSP resource_domains (default: ["https://persistent.oaistatic.com"])
+ widget_csp_connect: CSP connect_domains (default: [])
+ widget_accessible: Whether widget is accessible (default: True)
+ result_can_produce_widget: Whether result can produce widget (default: True)
+ tags: Tags for categorizing the widget
+ annotations: Tool annotations (merged with widget defaults)
+ resource_annotations: Resource annotations for HTML resource
+ meta: Additional tool metadata (merged with widget metadata)
+ enabled: Whether widget is enabled (default: True)
+ exclude_args: Arguments to exclude from tool schema
+
+ Returns:
+ Decorated function registered as a widget tool
Examples:
- Register an OpenAI widget:
+ Register a widget with data only:
```python
- @app.ui.openai.widget
- def my_widget(x: int) -> str:
- return str(x)
+ @app.ui.openai.widget(
+ identifier="pizza-map",
+ template_uri="ui://widget/pizza-map.html",
+ html='
',
+ invoking="Hand-tossing a map",
+ invoked="Served a fresh map"
+ )
+ def show_pizza_map(topping: str) -> dict:
+ \"\"\"Show a pizza map for the given topping.\"\"\"
+ return {"topping": topping} # Auto-transformed!
+ ```
- @app.ui.openai.widget("custom_name")
- def another_widget(data: str) -> dict:
- return {"result": data}
+ Register a widget with both text and data:
+ ```python
+ @app.ui.openai.widget(
+ identifier="weather",
+ template_uri="ui://widget/weather.html",
+ html='
...'
+ )
+ def show_weather(city: str) -> tuple[str, dict]:
+ return (f"Showing weather for {city}", {"city": city, "temp": 72})
+ ```
- @app.ui.openai.widget(name="weather_display")
- def show_weather(city: str) -> str:
- return f"Weather for {city}"
+ Register a widget with text only:
+ ```python
+ @app.ui.openai.widget(
+ identifier="status",
+ template_uri="ui://widget/status.html",
+ html='
...'
+ )
+ def show_status() -> str:
+ return "Status widget displayed"
```
"""
+ # Validate required parameters
+ if template_uri is None:
+ raise TypeError("widget() missing required keyword argument: 'template_uri'")
+ if html is None:
+ raise TypeError("widget() missing required keyword argument: 'html'")
+
if isinstance(annotations, dict):
annotations = ToolAnnotations(**annotations)
@@ -123,56 +209,232 @@ def show_weather(city: str) -> str:
)
)
- # Determine the actual name and function based on the calling pattern
+ # Determine the widget identifier and function based on calling pattern
if inspect.isroutine(name_or_fn):
- # Case 1: @widget (without parens) - function passed directly
- # Case 2: direct call like widget(fn, name="something")
+ # Case 1: @widget(template_uri=..., html=...) - function passed directly
fn = name_or_fn
- widget_name = name # Use keyword name if provided, otherwise None
+ widget_identifier = identifier or fn.__name__
- # Register the widget as a tool immediately and return the tool object
- tool = Tool.from_function(
- fn,
- name=widget_name,
+ # Register immediately and return
+ return self._register_widget(
+ fn=fn,
+ identifier=widget_identifier,
+ template_uri=template_uri,
+ html=html,
title=title,
description=description,
+ invoking=invoking,
+ invoked=invoked,
+ mime_type=mime_type,
+ widget_description=widget_description,
+ widget_prefers_border=widget_prefers_border,
+ widget_csp_resources=widget_csp_resources,
+ widget_csp_connect=widget_csp_connect,
+ widget_accessible=widget_accessible,
+ result_can_produce_widget=result_can_produce_widget,
tags=tags,
- output_schema=output_schema,
annotations=cast(ToolAnnotations | None, annotations),
- exclude_args=exclude_args,
+ resource_annotations=resource_annotations,
meta=meta,
- serializer=self._fastmcp._tool_serializer,
enabled=enabled,
+ exclude_args=exclude_args,
)
- self._fastmcp.add_tool(tool)
- return tool
elif isinstance(name_or_fn, str):
- # Case 3: @widget("custom_name") - name passed as first argument
- if name is not None:
+ # Case 2: @widget("custom_name", template_uri=..., html=...)
+ if identifier is not None:
raise TypeError(
"Cannot specify both a name as first argument and as keyword argument. "
- f"Use either @widget('{name_or_fn}') or @widget(name='{name}'), not both."
+ f"Use either @widget('{name_or_fn}', ...) or @widget(identifier='{identifier}', ...), not both."
)
- widget_name = name_or_fn
+ widget_identifier = name_or_fn
elif name_or_fn is None:
- # Case 4: @widget or @widget(name="something") - use keyword name
- widget_name = name
+ # Case 3: @widget(identifier="name", template_uri=..., html=...)
+ widget_identifier = identifier
else:
raise TypeError(
f"First argument to @widget must be a function, string, or None, got {type(name_or_fn)}"
)
- # Return partial for cases where we need to wait for the function
- return partial(
- self.widget,
- name=widget_name,
+ # Return decorator that will be applied to the function
+ def decorator(fn: AnyFunction) -> FunctionTool:
+ actual_identifier = widget_identifier or fn.__name__
+ return self._register_widget(
+ fn=fn,
+ identifier=actual_identifier,
+ template_uri=template_uri,
+ html=html,
+ title=title,
+ description=description,
+ invoking=invoking,
+ invoked=invoked,
+ mime_type=mime_type,
+ widget_description=widget_description,
+ widget_prefers_border=widget_prefers_border,
+ widget_csp_resources=widget_csp_resources,
+ widget_csp_connect=widget_csp_connect,
+ widget_accessible=widget_accessible,
+ result_can_produce_widget=result_can_produce_widget,
+ tags=tags,
+ annotations=cast(ToolAnnotations | None, annotations),
+ resource_annotations=resource_annotations,
+ meta=meta,
+ enabled=enabled,
+ exclude_args=exclude_args,
+ )
+
+ return decorator
+
+ def _register_widget(
+ self,
+ *,
+ fn: AnyFunction,
+ identifier: str,
+ template_uri: str,
+ html: str,
+ title: str | None,
+ description: str | None,
+ invoking: str | None,
+ invoked: str | None,
+ mime_type: str,
+ widget_description: str | None,
+ widget_prefers_border: bool,
+ widget_csp_resources: Sequence[str] | None,
+ widget_csp_connect: Sequence[str] | None,
+ widget_accessible: bool,
+ result_can_produce_widget: bool,
+ tags: set[str] | None,
+ annotations: ToolAnnotations | None,
+ resource_annotations: dict[str, Any] | None,
+ meta: dict[str, Any] | None,
+ enabled: bool | None,
+ exclude_args: list[str] | None,
+ ) -> FunctionTool:
+ """Internal method to register a widget.
+
+ This orchestrates:
+ 1. Registering the widget HTML as a resource
+ 2. Wrapping the function to auto-transform return values
+ 3. Registering the wrapped function as a tool with OpenAI metadata
+ """
+ # Use title or default to identifier
+ widget_title = title or identifier
+
+ # Step 1: Register widget HTML as a resource
+ self._register_widget_resource(
+ template_uri=template_uri,
+ html=html,
+ title=widget_title,
+ mime_type=mime_type,
+ widget_description=widget_description,
+ widget_prefers_border=widget_prefers_border,
+ widget_csp_resources=widget_csp_resources,
+ widget_csp_connect=widget_csp_connect,
+ resource_annotations=resource_annotations,
+ )
+
+ # Step 2: Wrap function to auto-transform return values
+ wrapped_fn = self._wrap_widget_function(fn)
+
+ # Step 3: Build OpenAI widget metadata for the tool
+ widget_meta = build_widget_tool_meta(
+ template_uri=template_uri,
+ html=html,
+ title=widget_title,
+ invoking=invoking,
+ invoked=invoked,
+ mime_type=mime_type,
+ widget_description=widget_description,
+ widget_prefers_border=widget_prefers_border,
+ widget_csp_resources=widget_csp_resources,
+ widget_csp_connect=widget_csp_connect,
+ widget_accessible=widget_accessible,
+ result_can_produce_widget=result_can_produce_widget,
+ additional_meta=meta,
+ )
+
+ # Merge tool annotations with widget defaults
+ merged_annotations = merge_tool_annotations(
+ annotations.model_dump() if annotations else None
+ )
+
+ # Step 4: Register as a tool with widget metadata
+ from fastmcp.tools.tool import Tool
+
+ tool = Tool.from_function(
+ wrapped_fn,
+ name=identifier,
title=title,
description=description,
tags=tags,
- output_schema=output_schema,
- annotations=annotations,
+ annotations=ToolAnnotations(**merged_annotations),
exclude_args=exclude_args,
- meta=meta,
+ meta=widget_meta,
+ serializer=self._fastmcp._tool_serializer,
enabled=enabled,
)
+ self._fastmcp.add_tool(tool)
+ return tool
+
+ def _register_widget_resource(
+ self,
+ *,
+ template_uri: str,
+ html: str,
+ title: str,
+ mime_type: str,
+ widget_description: str | None,
+ widget_prefers_border: bool,
+ widget_csp_resources: Sequence[str] | None,
+ widget_csp_connect: Sequence[str] | None,
+ resource_annotations: dict[str, Any] | None,
+ ) -> None:
+ """Register widget HTML as an MCP resource."""
+ # Build resource metadata
+ resource_meta = build_widget_resource_meta(
+ title=title,
+ widget_description=widget_description,
+ widget_prefers_border=widget_prefers_border,
+ widget_csp_resources=widget_csp_resources,
+ widget_csp_connect=widget_csp_connect,
+ )
+
+ # Merge resource annotations
+ merged_resource_annotations = merge_resource_annotations(resource_annotations)
+
+ # Register the resource using FastMCP's resource decorator
+ @self._fastmcp.resource(
+ uri=template_uri,
+ name=title,
+ description=f"{title} widget markup",
+ mime_type=mime_type,
+ annotations=merged_resource_annotations,
+ meta=resource_meta,
+ )
+ def widget_html_resource() -> str:
+ return html
+
+ def _wrap_widget_function(self, fn: AnyFunction) -> AnyFunction:
+ """Wrap a widget function to auto-transform return values.
+
+ The wrapper intercepts the function's return value and transforms it
+ to the OpenAI format: {"content": [...], "structuredContent": {...}}
+
+ Supports sync and async functions.
+ """
+ if asyncio.iscoroutinefunction(fn):
+
+ @functools.wraps(fn)
+ async def async_wrapper(*args: Any, **kwargs: Any) -> dict[str, Any]:
+ result = await fn(*args, **kwargs)
+ return transform_widget_response(result)
+
+ return async_wrapper
+ else:
+
+ @functools.wraps(fn)
+ def sync_wrapper(*args: Any, **kwargs: Any) -> dict[str, Any]:
+ result = fn(*args, **kwargs)
+ return transform_widget_response(result)
+
+ return sync_wrapper
diff --git a/src/fastmcp/ui/openai/metadata.py b/src/fastmcp/ui/openai/metadata.py
new file mode 100644
index 000000000..542c5cd4b
--- /dev/null
+++ b/src/fastmcp/ui/openai/metadata.py
@@ -0,0 +1,265 @@
+"""OpenAI-specific metadata builders for widgets."""
+
+from __future__ import annotations
+
+from collections.abc import Mapping, Sequence
+from typing import Any
+
+# Default CSP resource domains for OpenAI widgets
+DEFAULT_CSP_RESOURCES: tuple[str, ...] = ("https://persistent.oaistatic.com",)
+
+# Default resource annotations for widget HTML
+DEFAULT_RESOURCE_ANNOTATIONS: dict[str, Any] = {
+ "readOnlyHint": True,
+ "idempotentHint": True,
+}
+
+# Default tool annotations for widget tools
+DEFAULT_TOOL_ANNOTATIONS: dict[str, Any] = {
+ "destructiveHint": False,
+ "openWorldHint": False,
+ "readOnlyHint": True,
+}
+
+
+def _normalize_sequence(
+ seq: Sequence[str] | None, default: Sequence[str]
+) -> list[str]:
+ """Normalize a sequence to a list, using default if None."""
+ return list(seq if seq is not None else default)
+
+
+def build_widget_resource_meta(
+ *,
+ title: str,
+ widget_description: str | None = None,
+ widget_prefers_border: bool = True,
+ widget_csp_resources: Sequence[str] | None = None,
+ widget_csp_connect: Sequence[str] | None = None,
+) -> dict[str, Any]:
+ """Build _meta dict for widget HTML resource.
+
+ Args:
+ title: Widget title
+ widget_description: Description of widget UI (defaults to "{title} widget UI.")
+ widget_prefers_border: Whether widget prefers a border
+ widget_csp_resources: CSP resource_domains list
+ widget_csp_connect: CSP connect_domains list
+
+ Returns:
+ Metadata dict with OpenAI widget configuration
+
+ Examples:
+ Build basic widget metadata:
+ ```python
+ meta = build_widget_resource_meta(title="Pizza Map")
+ # {
+ # "openai/widgetDescription": "Pizza Map widget UI.",
+ # "openai/widgetPrefersBorder": True,
+ # "openai/widgetCSP": {...}
+ # }
+ ```
+ """
+ return {
+ "openai/widgetDescription": widget_description
+ or f"{title} widget UI.",
+ "openai/widgetPrefersBorder": widget_prefers_border,
+ "openai/widgetCSP": {
+ "resource_domains": _normalize_sequence(
+ widget_csp_resources, DEFAULT_CSP_RESOURCES
+ ),
+ "connect_domains": _normalize_sequence(widget_csp_connect, ()),
+ },
+ }
+
+
+def build_embedded_widget_resource(
+ *,
+ template_uri: str,
+ html: str,
+ title: str,
+ mime_type: str = "text/html+skybridge",
+ widget_description: str | None = None,
+ widget_prefers_border: bool = True,
+ widget_csp_resources: Sequence[str] | None = None,
+ widget_csp_connect: Sequence[str] | None = None,
+) -> dict[str, Any]:
+ """Build embedded widget resource structure for tool metadata.
+
+ This creates the openai.com/widget embedded resource that ChatGPT
+ uses to render the widget.
+
+ Args:
+ template_uri: Widget template URI (e.g. "ui://widget/pizza-map.html")
+ html: Widget HTML content
+ title: Widget title
+ mime_type: MIME type for the widget HTML
+ widget_description: Description of widget UI
+ widget_prefers_border: Whether widget prefers a border
+ widget_csp_resources: CSP resource_domains list
+ widget_csp_connect: CSP connect_domains list
+
+ Returns:
+ Embedded resource structure for tool metadata
+
+ Examples:
+ Build embedded resource:
+ ```python
+ resource = build_embedded_widget_resource(
+ template_uri="ui://widget/map.html",
+ html="...
",
+ title="Map Widget"
+ )
+ ```
+ """
+ return {
+ "type": "resource",
+ "resource": {
+ "type": "text",
+ "uri": template_uri,
+ "mimeType": mime_type,
+ "text": html,
+ "title": title,
+ "_meta": build_widget_resource_meta(
+ title=title,
+ widget_description=widget_description,
+ widget_prefers_border=widget_prefers_border,
+ widget_csp_resources=widget_csp_resources,
+ widget_csp_connect=widget_csp_connect,
+ ),
+ },
+ }
+
+
+def build_widget_tool_meta(
+ *,
+ template_uri: str,
+ html: str,
+ title: str,
+ invoking: str | None = None,
+ invoked: str | None = None,
+ mime_type: str = "text/html+skybridge",
+ widget_description: str | None = None,
+ widget_prefers_border: bool = True,
+ widget_csp_resources: Sequence[str] | None = None,
+ widget_csp_connect: Sequence[str] | None = None,
+ widget_accessible: bool = True,
+ result_can_produce_widget: bool = True,
+ additional_meta: Mapping[str, Any] | None = None,
+) -> dict[str, Any]:
+ """Build _meta dict for widget tool.
+
+ Args:
+ template_uri: Widget template URI (e.g. "ui://widget/pizza-map.html")
+ html: Widget HTML content
+ title: Widget title
+ invoking: Status message shown while tool is executing
+ invoked: Status message shown after tool completes
+ mime_type: MIME type for the widget HTML
+ widget_description: Description of widget UI
+ widget_prefers_border: Whether widget prefers a border
+ widget_csp_resources: CSP resource_domains list
+ widget_csp_connect: CSP connect_domains list
+ widget_accessible: Whether widget is accessible
+ result_can_produce_widget: Whether result can produce a widget
+ additional_meta: Additional metadata to merge
+
+ Returns:
+ Metadata dict with OpenAI widget tool configuration
+
+ Examples:
+ Build widget tool metadata:
+ ```python
+ meta = build_widget_tool_meta(
+ template_uri="ui://widget/map.html",
+ html="...
",
+ title="Map Widget",
+ invoking="Loading map...",
+ invoked="Map loaded!"
+ )
+ ```
+ """
+ meta: dict[str, Any] = {
+ "openai.com/widget": build_embedded_widget_resource(
+ template_uri=template_uri,
+ html=html,
+ title=title,
+ mime_type=mime_type,
+ widget_description=widget_description,
+ widget_prefers_border=widget_prefers_border,
+ widget_csp_resources=widget_csp_resources,
+ widget_csp_connect=widget_csp_connect,
+ ),
+ "openai/outputTemplate": template_uri,
+ "openai/widgetAccessible": widget_accessible,
+ "openai/resultCanProduceWidget": result_can_produce_widget,
+ }
+
+ # Add invocation status messages if provided
+ if invoking is not None:
+ meta["openai/toolInvocation/invoking"] = invoking
+ if invoked is not None:
+ meta["openai/toolInvocation/invoked"] = invoked
+
+ # Merge additional metadata
+ if additional_meta:
+ meta.update(additional_meta)
+
+ return meta
+
+
+def merge_tool_annotations(
+ user_annotations: Mapping[str, Any] | None,
+) -> dict[str, Any]:
+ """Merge user annotations with default widget tool annotations.
+
+ Args:
+ user_annotations: User-provided annotations to merge
+
+ Returns:
+ Combined annotations dict with defaults and user overrides
+
+ Examples:
+ Merge annotations:
+ ```python
+ annotations = merge_tool_annotations({"customHint": True})
+ # {
+ # "destructiveHint": False,
+ # "openWorldHint": False,
+ # "readOnlyHint": True,
+ # "customHint": True
+ # }
+ ```
+ """
+ combined = dict(DEFAULT_TOOL_ANNOTATIONS)
+ if user_annotations:
+ combined.update(user_annotations)
+ return combined
+
+
+def merge_resource_annotations(
+ user_annotations: Mapping[str, Any] | None,
+) -> dict[str, Any]:
+ """Merge user annotations with default widget resource annotations.
+
+ Args:
+ user_annotations: User-provided annotations to merge
+
+ Returns:
+ Combined annotations dict with defaults and user overrides
+
+ Examples:
+ Merge annotations:
+ ```python
+ annotations = merge_resource_annotations({"customHint": True})
+ # {
+ # "readOnlyHint": True,
+ # "idempotentHint": True,
+ # "customHint": True
+ # }
+ ```
+ """
+ combined = dict(DEFAULT_RESOURCE_ANNOTATIONS)
+ if user_annotations:
+ combined.update(user_annotations)
+ return combined
diff --git a/src/fastmcp/ui/openai/response.py b/src/fastmcp/ui/openai/response.py
new file mode 100644
index 000000000..72a1e1645
--- /dev/null
+++ b/src/fastmcp/ui/openai/response.py
@@ -0,0 +1,135 @@
+"""Response transformation utilities for OpenAI widgets."""
+
+from __future__ import annotations
+
+from typing import Any
+
+WidgetToolResponse = dict[str, Any]
+
+
+def build_widget_tool_response(
+ response_text: str | None = None,
+ structured_content: dict[str, Any] | None = None,
+) -> WidgetToolResponse:
+ """Build a standardized OpenAI widget tool response.
+
+ This helper is available for advanced use cases, but the widget decorator
+ automatically transforms simple return values (str, dict, tuple) so you
+ typically don't need to call this directly.
+
+ Args:
+ response_text: Narrative text shown in the conversation
+ structured_content: Data passed to the widget JavaScript component
+
+ Returns:
+ Formatted response dict with content and structuredContent
+
+ Examples:
+ Build a response with both text and data:
+ ```python
+ return build_widget_tool_response(
+ response_text="Showing weather for Seattle",
+ structured_content={"city": "Seattle", "temp": 72}
+ )
+ ```
+
+ Data only (no narrative):
+ ```python
+ return build_widget_tool_response(
+ structured_content={"data": [1, 2, 3]}
+ )
+ ```
+ """
+ content = (
+ [
+ {
+ "type": "text",
+ "text": response_text,
+ }
+ ]
+ if response_text
+ else []
+ )
+
+ return {"content": content, "structuredContent": structured_content or {}}
+
+
+def transform_widget_response(result: Any) -> WidgetToolResponse:
+ """Auto-transform widget function return value to OpenAI format.
+
+ Supports three return patterns:
+ - str: Narrative text only
+ - dict: Structured data for widget (no narrative)
+ - tuple[str, dict]: Both narrative and data
+
+ Args:
+ result: The widget function's return value
+
+ Returns:
+ Formatted response dict with content and structuredContent
+
+ Raises:
+ TypeError: If return type is not str, dict, or tuple[str, dict]
+
+ Examples:
+ Transform a string:
+ ```python
+ result = transform_widget_response("Showing map")
+ # {"content": [{"type": "text", "text": "Showing map"}], "structuredContent": {}}
+ ```
+
+ Transform a dict:
+ ```python
+ result = transform_widget_response({"lat": 47.6, "lon": -122.3})
+ # {"content": [], "structuredContent": {"lat": 47.6, "lon": -122.3}}
+ ```
+
+ Transform a tuple:
+ ```python
+ result = transform_widget_response(("Showing map", {"lat": 47.6}))
+ # {"content": [{"type": "text", "text": "Showing map"}], "structuredContent": {"lat": 47.6}}
+ ```
+ """
+ if isinstance(result, str):
+ return build_widget_tool_response(
+ response_text=result if result else None,
+ structured_content=None,
+ )
+ elif isinstance(result, dict):
+ return build_widget_tool_response(
+ response_text=None,
+ structured_content=result,
+ )
+ elif isinstance(result, tuple):
+ if len(result) != 2:
+ raise TypeError(
+ f"Widget function returned tuple of length {len(result)}, expected 2 (text, data). "
+ f"Return either: str (text only), dict (data only), or tuple[str, dict] (both)"
+ )
+
+ text, data = result
+
+ if text is not None and not isinstance(text, str):
+ raise TypeError(
+ f"First element of tuple must be str or None, got {type(text).__name__}. "
+ f"Return format: tuple[str | None, dict[str, Any]]"
+ )
+
+ if data is not None and not isinstance(data, dict):
+ raise TypeError(
+ f"Second element of tuple must be dict or None, got {type(data).__name__}. "
+ f"Return format: tuple[str | None, dict[str, Any]]"
+ )
+
+ return build_widget_tool_response(
+ response_text=text,
+ structured_content=data,
+ )
+ else:
+ raise TypeError(
+ f"Widget function must return str, dict, or tuple[str, dict], got {type(result).__name__}. "
+ f"Examples:\n"
+ f" - return 'Showing map' # Text only\n"
+ f" - return {{'lat': 47.6}} # Data only\n"
+ f" - return ('Showing map', {{'lat': 47.6}}) # Both"
+ )
diff --git a/tests/ui/test_openai_widget.py b/tests/ui/test_openai_widget.py
index 70be88a54..946a242b0 100644
--- a/tests/ui/test_openai_widget.py
+++ b/tests/ui/test_openai_widget.py
@@ -1,4 +1,4 @@
-"""Tests for OpenAI widget decorator."""
+"""Tests for OpenAI widget decorator (basic usage)."""
from fastmcp import FastMCP
@@ -7,7 +7,11 @@ async def test_widget_decorator_basic():
"""Test basic widget decorator usage."""
app = FastMCP("test")
- @app.ui.openai.widget
+ @app.ui.openai.widget(
+ identifier="my_widget",
+ template_uri="ui://widget/test.html",
+ html="Test Widget
",
+ )
def my_widget(x: int) -> str:
return f"Result: {x}"
@@ -18,10 +22,14 @@ def my_widget(x: int) -> str:
async def test_widget_decorator_with_name():
- """Test widget decorator with custom name."""
+ """Test widget decorator with custom identifier."""
app = FastMCP("test")
- @app.ui.openai.widget("custom_widget")
+ @app.ui.openai.widget(
+ identifier="custom_widget",
+ template_uri="ui://widget/custom.html",
+ html="Custom
",
+ )
def my_function(x: int) -> str:
return f"Result: {x}"
@@ -30,24 +38,16 @@ def my_function(x: int) -> str:
assert tools["custom_widget"].name == "custom_widget"
-async def test_widget_decorator_with_name_kwarg():
- """Test widget decorator with name as keyword argument."""
- app = FastMCP("test")
-
- @app.ui.openai.widget(name="named_widget")
- def my_function(x: int) -> str:
- return f"Result: {x}"
-
- tools = await app.get_tools()
- assert "named_widget" in tools
- assert tools["named_widget"].name == "named_widget"
-
-
async def test_widget_decorator_with_description():
"""Test widget decorator with description."""
app = FastMCP("test")
- @app.ui.openai.widget(description="This is a test widget")
+ @app.ui.openai.widget(
+ identifier="my_widget",
+ template_uri="ui://widget/test.html",
+ html="Test
",
+ description="This is a test widget",
+ )
def my_widget(x: int) -> str:
return f"Result: {x}"
@@ -60,7 +60,12 @@ async def test_widget_decorator_with_title():
"""Test widget decorator with title."""
app = FastMCP("test")
- @app.ui.openai.widget(title="My Widget Title")
+ @app.ui.openai.widget(
+ identifier="my_widget",
+ template_uri="ui://widget/test.html",
+ html="Test
",
+ title="My Widget Title",
+ )
def my_widget(x: int) -> str:
return f"Result: {x}"
@@ -73,10 +78,16 @@ async def test_widget_can_be_called():
"""Test that registered widgets can be called as tools."""
app = FastMCP("test")
- @app.ui.openai.widget
- def calculator(x: int, y: int) -> int:
- return x + y
+ @app.ui.openai.widget(
+ identifier="calculator",
+ template_uri="ui://widget/calc.html",
+ html="Calc
",
+ )
+ def calculator(x: int, y: int) -> dict:
+ return {"result": x + y}
# Call the tool through the tool manager
result = await app._tool_manager.call_tool("calculator", {"x": 5, "y": 3})
- assert result.content[0].text == "8" # type: ignore[attr-defined]
+
+ # The result should be auto-transformed
+ assert '"structuredContent":{"result":8}' in result.content[0].text # type: ignore[attr-defined]
diff --git a/tests/ui/test_openai_widget_full.py b/tests/ui/test_openai_widget_full.py
new file mode 100644
index 000000000..73e727a06
--- /dev/null
+++ b/tests/ui/test_openai_widget_full.py
@@ -0,0 +1,357 @@
+"""Comprehensive tests for OpenAI widget decorator with full integration."""
+
+import pytest
+
+from fastmcp import FastMCP
+from fastmcp.ui.openai import build_widget_tool_response
+
+
+async def test_widget_with_dict_return():
+ """Test widget that returns dict (structured data only)."""
+ app = FastMCP("test")
+
+ @app.ui.openai.widget(
+ identifier="test-widget",
+ template_uri="ui://widget/test.html",
+ html="Test
",
+ )
+ def test_widget(value: str) -> dict:
+ return {"value": value}
+
+ # Tool should be registered
+ tools = await app.get_tools()
+ assert "test-widget" in tools
+
+ # Call the tool
+ result = await app._tool_manager.call_tool("test-widget", {"value": "hello"})
+
+ # Should be transformed to OpenAI format (compact JSON)
+ assert '"content":[]' in result.content[0].text # type: ignore[attr-defined]
+ assert '"structuredContent":{"value":"hello"}' in result.content[0].text # type: ignore[attr-defined]
+
+
+async def test_widget_with_str_return():
+ """Test widget that returns str (text only)."""
+ app = FastMCP("test")
+
+ @app.ui.openai.widget(
+ identifier="text-widget",
+ template_uri="ui://widget/text.html",
+ html="Text
",
+ )
+ def text_widget() -> str:
+ return "Hello world"
+
+ # Call the tool
+ result = await app._tool_manager.call_tool("text-widget", {})
+
+ # Should be transformed to OpenAI format with text in content (compact JSON)
+ assert '"type":"text"' in result.content[0].text # type: ignore[attr-defined]
+ assert '"text":"Hello world"' in result.content[0].text # type: ignore[attr-defined]
+ assert '"content":[{' in result.content[0].text # type: ignore[attr-defined]
+
+
+async def test_widget_with_tuple_return():
+ """Test widget that returns tuple (text and data)."""
+ app = FastMCP("test")
+
+ @app.ui.openai.widget(
+ identifier="combo-widget",
+ template_uri="ui://widget/combo.html",
+ html="Combo
",
+ )
+ def combo_widget(x: int) -> tuple[str, dict]:
+ return (f"Processing {x}", {"value": x, "doubled": x * 2})
+
+ # Call the tool
+ result = await app._tool_manager.call_tool("combo-widget", {"x": 5})
+
+ # Should have both content and structuredContent (compact JSON)
+ response_text = result.content[0].text # type: ignore[attr-defined]
+ assert '"text":"Processing 5"' in response_text
+ assert '"structuredContent":{"value":5,"doubled":10}' in response_text
+
+
+async def test_widget_with_async_function():
+ """Test widget decorator works with async functions."""
+ app = FastMCP("test")
+
+ @app.ui.openai.widget(
+ identifier="async-widget",
+ template_uri="ui://widget/async.html",
+ html="Async
",
+ )
+ async def async_widget(data: str) -> dict:
+ # Simulate async operation
+ import asyncio
+ await asyncio.sleep(0.001)
+ return {"processed": data.upper()}
+
+ # Call the tool
+ result = await app._tool_manager.call_tool("async-widget", {"data": "test"})
+
+ # Should be transformed correctly (compact JSON)
+ assert '"structuredContent":{"processed":"TEST"}' in result.content[0].text # type: ignore[attr-defined]
+
+
+async def test_widget_resource_registration():
+ """Test that widget HTML is registered as a resource."""
+ app = FastMCP("test")
+
+ @app.ui.openai.widget(
+ identifier="widget-with-resource",
+ template_uri="ui://widget/test-resource.html",
+ html="Widget HTML
",
+ title="Test Resource Widget",
+ )
+ def widget_func() -> dict:
+ return {}
+
+ # Resource should be registered
+ resources = await app.get_resources()
+ assert "ui://widget/test-resource.html" in resources
+
+ # Read the resource
+ resource = resources["ui://widget/test-resource.html"]
+ assert resource.mime_type == "text/html+skybridge"
+ assert resource.name == "Test Resource Widget"
+
+
+async def test_widget_metadata():
+ """Test that widget has correct OpenAI metadata."""
+ app = FastMCP("test")
+
+ @app.ui.openai.widget(
+ identifier="meta-widget",
+ template_uri="ui://widget/meta.html",
+ html="Meta
",
+ invoking="Loading widget...",
+ invoked="Widget loaded!",
+ )
+ def meta_widget() -> dict:
+ return {}
+
+ # Get the tool
+ tools = await app.get_tools()
+ tool = tools["meta-widget"]
+
+ # Check OpenAI metadata
+ assert tool.meta is not None
+ assert "openai/outputTemplate" in tool.meta
+ assert tool.meta["openai/outputTemplate"] == "ui://widget/meta.html"
+ assert tool.meta["openai/toolInvocation/invoking"] == "Loading widget..."
+ assert tool.meta["openai/toolInvocation/invoked"] == "Widget loaded!"
+ assert tool.meta["openai/widgetAccessible"] is True
+ assert tool.meta["openai/resultCanProduceWidget"] is True
+
+
+async def test_widget_csp_configuration():
+ """Test widget CSP configuration in metadata."""
+ app = FastMCP("test")
+
+ @app.ui.openai.widget(
+ identifier="csp-widget",
+ template_uri="ui://widget/csp.html",
+ html="CSP
",
+ widget_csp_resources=["https://custom.com", "https://another.com"],
+ widget_csp_connect=["wss://websocket.com"],
+ )
+ def csp_widget() -> dict:
+ return {}
+
+ # Get the resource to check its metadata
+ resources = await app.get_resources()
+ resource = resources["ui://widget/csp.html"]
+
+ # Check CSP in resource metadata
+ assert resource.meta is not None
+ assert "openai/widgetCSP" in resource.meta
+ csp = resource.meta["openai/widgetCSP"]
+ assert "https://custom.com" in csp["resource_domains"]
+ assert "https://another.com" in csp["resource_domains"]
+ assert "wss://websocket.com" in csp["connect_domains"]
+
+
+async def test_widget_with_title_and_description():
+ """Test widget with custom title and description."""
+ app = FastMCP("test")
+
+ @app.ui.openai.widget(
+ identifier="titled-widget",
+ template_uri="ui://widget/titled.html",
+ html="Title
",
+ title="My Custom Title",
+ description="Custom description for the widget",
+ )
+ def titled_widget() -> dict:
+ return {}
+
+ # Get the tool
+ tools = await app.get_tools()
+ tool = tools["titled-widget"]
+
+ assert tool.title == "My Custom Title"
+ assert tool.description == "Custom description for the widget"
+
+
+async def test_widget_with_tags():
+ """Test widget with tags."""
+ app = FastMCP("test")
+
+ @app.ui.openai.widget(
+ identifier="tagged-widget",
+ template_uri="ui://widget/tagged.html",
+ html="Tags
",
+ tags={"visualization", "data"},
+ )
+ def tagged_widget() -> dict:
+ return {}
+
+ # Get the tool
+ tools = await app.get_tools()
+ tool = tools["tagged-widget"]
+
+ assert tool.tags == {"visualization", "data"}
+
+
+async def test_widget_with_enabled_false():
+ """Test widget can be disabled."""
+ app = FastMCP("test")
+
+ @app.ui.openai.widget(
+ identifier="disabled-widget",
+ template_uri="ui://widget/disabled.html",
+ html="Disabled
",
+ enabled=False,
+ )
+ def disabled_widget() -> dict:
+ return {}
+
+ # Get the tool
+ tools = await app.get_tools()
+ tool = tools["disabled-widget"]
+
+ assert tool.enabled is False
+
+
+async def test_widget_with_custom_identifier():
+ """Test widget with custom identifier different from function name."""
+ app = FastMCP("test")
+
+ @app.ui.openai.widget(
+ identifier="custom-id",
+ template_uri="ui://widget/custom.html",
+ html="Custom
",
+ )
+ def some_function_name() -> dict:
+ return {"test": "data"}
+
+ # Should be registered with custom identifier
+ tools = await app.get_tools()
+ assert "custom-id" in tools
+ assert "some_function_name" not in tools
+
+
+async def test_widget_annotations():
+ """Test widget has correct default annotations."""
+ app = FastMCP("test")
+
+ @app.ui.openai.widget(
+ identifier="annotated-widget",
+ template_uri="ui://widget/annotated.html",
+ html="Annotated
",
+ )
+ def annotated_widget() -> dict:
+ return {}
+
+ # Get the tool
+ tools = await app.get_tools()
+ tool = tools["annotated-widget"]
+
+ # Check default widget annotations
+ assert tool.annotations is not None
+ assert tool.annotations.destructiveHint is False
+ assert tool.annotations.readOnlyHint is True
+
+
+async def test_build_widget_tool_response_helper():
+ """Test the build_widget_tool_response helper function."""
+ # Text only
+ result1 = build_widget_tool_response(response_text="Hello")
+ assert result1["content"] == [{"type": "text", "text": "Hello"}]
+ assert result1["structuredContent"] == {}
+
+ # Data only
+ result2 = build_widget_tool_response(structured_content={"key": "value"})
+ assert result2["content"] == []
+ assert result2["structuredContent"] == {"key": "value"}
+
+ # Both
+ result3 = build_widget_tool_response(
+ response_text="Processing...",
+ structured_content={"status": "done"}
+ )
+ assert result3["content"] == [{"type": "text", "text": "Processing..."}]
+ assert result3["structuredContent"] == {"status": "done"}
+
+
+async def test_widget_missing_required_params():
+ """Test that widget decorator validates required parameters."""
+ app = FastMCP("test")
+
+ with pytest.raises(TypeError, match="missing required keyword argument: 'template_uri'"):
+ @app.ui.openai.widget(
+ html="Missing URI
",
+ )
+ def bad_widget1() -> dict:
+ return {}
+
+ with pytest.raises(TypeError, match="missing required keyword argument: 'html'"):
+ @app.ui.openai.widget(
+ template_uri="ui://widget/test.html",
+ )
+ def bad_widget2() -> dict:
+ return {}
+
+
+async def test_widget_invalid_return_type():
+ """Test that widget validates return types at runtime."""
+ app = FastMCP("test")
+
+ @app.ui.openai.widget(
+ identifier="bad-return-widget",
+ template_uri="ui://widget/bad.html",
+ html="Bad
",
+ )
+ def bad_return_widget() -> int: # Invalid return type
+ return 42
+
+ # Calling the tool should raise an error during response transformation
+ try:
+ await app._tool_manager.call_tool("bad-return-widget", {})
+ assert False, "Should have raised TypeError"
+ except Exception as e:
+ # The error gets wrapped, so check the error message
+ assert "must return str, dict, or tuple" in str(e)
+
+
+async def test_widget_with_exclude_args():
+ """Test widget with excluded arguments."""
+ app = FastMCP("test")
+
+ @app.ui.openai.widget(
+ identifier="exclude-args-widget",
+ template_uri="ui://widget/exclude.html",
+ html="Exclude
",
+ exclude_args=["secret_param"],
+ )
+ def exclude_args_widget(public_param: str, secret_param: str = "hidden") -> dict:
+ return {"public": public_param, "secret": secret_param}
+
+ # Get the tool
+ tools = await app.get_tools()
+ tool = tools["exclude-args-widget"]
+
+ # Check that secret_param is excluded from schema
+ assert "secret_param" not in tool.parameters["properties"]
+ assert "public_param" in tool.parameters["properties"]
From 92212397b9ef4bf7f9bcce0d1d841eff38c4de3d Mon Sep 17 00:00:00 2001
From: Adam Azzam <33043305+AAAZZAM@users.noreply.github.com>
Date: Mon, 20 Oct 2025 12:06:26 -0400
Subject: [PATCH 3/5] Update manager.py
---
src/fastmcp/ui/openai/manager.py | 4 ++++
1 file changed, 4 insertions(+)
diff --git a/src/fastmcp/ui/openai/manager.py b/src/fastmcp/ui/openai/manager.py
index 8f9fb399c..243e696d0 100644
--- a/src/fastmcp/ui/openai/manager.py
+++ b/src/fastmcp/ui/openai/manager.py
@@ -429,6 +429,8 @@ async def async_wrapper(*args: Any, **kwargs: Any) -> dict[str, Any]:
result = await fn(*args, **kwargs)
return transform_widget_response(result)
+ # Override return annotation to reflect actual return type
+ async_wrapper.__annotations__["return"] = dict[str, Any]
return async_wrapper
else:
@@ -437,4 +439,6 @@ def sync_wrapper(*args: Any, **kwargs: Any) -> dict[str, Any]:
result = fn(*args, **kwargs)
return transform_widget_response(result)
+ # Override return annotation to reflect actual return type
+ sync_wrapper.__annotations__["return"] = dict[str, Any]
return sync_wrapper
From 0c9d897c36b47502d63c1aa91b1592626573b5fe Mon Sep 17 00:00:00 2001
From: Adam Azzam <33043305+AAAZZAM@users.noreply.github.com>
Date: Wed, 22 Oct 2025 12:45:05 -0400
Subject: [PATCH 4/5] name -> identified
---
examples/widget_test/README.md | 135 ++++++++++++++++++++++++++++
src/fastmcp/ui/openai/manager.py | 40 ++++-----
src/fastmcp/ui/openai/metadata.py | 7 +-
tests/ui/test_openai_widget.py | 12 +--
tests/ui/test_openai_widget_full.py | 34 +++----
5 files changed, 181 insertions(+), 47 deletions(-)
create mode 100644 examples/widget_test/README.md
diff --git a/examples/widget_test/README.md b/examples/widget_test/README.md
new file mode 100644
index 000000000..61a4a129c
--- /dev/null
+++ b/examples/widget_test/README.md
@@ -0,0 +1,135 @@
+# OpenAI Widget Demo
+
+This directory contains a demonstration of FastMCP's OpenAI widget support using the Pizzaz mapping library.
+
+## What's Here
+
+- **`pizzaz_server.py`**: Demo server with three widget examples showing different return patterns
+- **`test_widgets.py`**: Test script that validates the auto-transformation works correctly
+
+## Setup
+
+This uses a local virtual environment with an editable install of FastMCP:
+
+```bash
+# Already done, but for reference:
+cd examples/widget_test
+uv venv
+source .venv/bin/activate # On Windows: .venv\Scripts\activate
+uv pip install -e ../..
+```
+
+## Running the Demo
+
+### Test the Widgets Locally
+
+```bash
+source .venv/bin/activate
+python test_widgets.py
+```
+
+This will test all three widget patterns:
+- **dict return**: Structured data only (no narrative text)
+- **str return**: Narrative text only (no structured data)
+- **tuple[str, dict] return**: Both narrative text and structured data
+
+### Run the Server
+
+```bash
+source .venv/bin/activate
+python pizzaz_server.py
+```
+
+The server will start on `http://0.0.0.0:8080`.
+
+### Inspect the Server
+
+```bash
+source .venv/bin/activate
+fastmcp inspect pizzaz_server.py
+```
+
+Use `--format fastmcp` to see the full JSON including OpenAI metadata.
+
+## Testing with ChatGPT
+
+To test the widgets with ChatGPT:
+
+1. Expose your server with ngrok:
+ ```bash
+ ngrok http 8080
+ ```
+
+2. In ChatGPT, go to Settings → Developer Mode and add your server
+
+3. Try prompts like:
+ - "Show me a pizza map for pepperoni"
+ - "Track pizza order 12345"
+ - "What's the pizza status?"
+
+## Widget Examples
+
+### 1. Pizza Map (dict return)
+
+Returns structured data only, which the widget renders:
+
+```python
+@app.ui.openai.widget(
+ identifier="pizza-map",
+ template_uri="ui://widget/pizza-map.html",
+ html=PIZZAZ_HTML,
+ invoking="Hand-tossing a map",
+ invoked="Served a fresh map",
+)
+def show_pizza_map(topping: str) -> dict:
+ return {"pizza_topping": topping.strip()}
+```
+
+### 2. Pizza Tracker (tuple return)
+
+Returns both narrative text and structured data:
+
+```python
+@app.ui.openai.widget(
+ identifier="pizza-tracker",
+ template_uri="ui://widget/pizza-tracker.html",
+ html=PIZZAZ_HTML,
+ invoking="Tracking your pizza",
+ invoked="Pizza located!",
+)
+def track_pizza(order_id: str) -> tuple[str, dict]:
+ narrative = f"Tracking pizza order {order_id}..."
+ data = {"order_id": order_id, "status": "out_for_delivery"}
+ return narrative, data
+```
+
+### 3. Pizza Status (str return)
+
+Returns text only, no structured data:
+
+```python
+@app.ui.openai.widget(
+ identifier="pizza-status",
+ template_uri="ui://widget/pizza-status.html",
+ html=PIZZAZ_HTML,
+)
+def pizza_status() -> str:
+ return "Pizza ovens are hot and ready! 🍕"
+```
+
+## How It Works
+
+The `@app.ui.openai.widget` decorator automatically:
+
+1. **Registers the HTML** as an MCP resource with MIME type `text/html+skybridge`
+2. **Adds OpenAI metadata** to the tool including:
+ - `openai/outputTemplate`: Points to the widget HTML
+ - `openai/toolInvocation/invoking` and `invoked`: Status messages
+ - `openai.com/widget`: Embedded widget resource
+ - CSP configuration for security
+3. **Auto-transforms return values** to OpenAI format:
+ - `dict` → `{"content": [], "structuredContent": {...}}`
+ - `str` → `{"content": [{"type": "text", "text": "..."}], "structuredContent": {}}`
+ - `tuple[str, dict]` → Both content and structuredContent
+
+You don't need to manually call `build_widget_tool_response()` or `register_decorated_widgets()` - it all happens automatically!
diff --git a/src/fastmcp/ui/openai/manager.py b/src/fastmcp/ui/openai/manager.py
index 243e696d0..71ea089d8 100644
--- a/src/fastmcp/ui/openai/manager.py
+++ b/src/fastmcp/ui/openai/manager.py
@@ -34,7 +34,7 @@ def widget(
self,
name_or_fn: AnyFunction,
*,
- identifier: str | None = None,
+ name: str | None = None,
template_uri: str,
html: str,
title: str | None = None,
@@ -61,7 +61,7 @@ def widget(
self,
name_or_fn: str | None = None,
*,
- identifier: str | None = None,
+ name: str | None = None,
template_uri: str,
html: str,
title: str | None = None,
@@ -87,7 +87,7 @@ def widget(
self,
name_or_fn: str | AnyFunction | None = None,
*,
- identifier: str | None = None,
+ name: str | None = None,
template_uri: str | None = None,
html: str | None = None,
title: str | None = None,
@@ -122,15 +122,15 @@ def widget(
- `tuple[str, dict]`: Both narrative text and structured data
This decorator supports multiple calling patterns:
- - @server.ui.openai.widget(identifier=..., template_uri=..., html=...)
- - @server.ui.openai.widget("name", identifier=..., template_uri=..., html=...)
+ - @server.ui.openai.widget(name=..., template_uri=..., html=...)
+ - @server.ui.openai.widget("name", template_uri=..., html=...)
Args:
name_or_fn: Either a function (direct decoration) or string name
- identifier: Tool name (defaults to function name or name_or_fn if str)
+ name: Tool name (defaults to function name or name_or_fn if str)
template_uri: Widget template URI (e.g. "ui://widget/pizza-map.html")
html: Widget HTML content (must include div and script tags)
- title: Widget display title (defaults to identifier)
+ title: Widget display title (defaults to name)
description: Tool description (defaults to function docstring)
invoking: Status message while tool executes (e.g. "Hand-tossing a map")
invoked: Status message after tool completes (e.g. "Served a fresh map")
@@ -155,7 +155,7 @@ def widget(
Register a widget with data only:
```python
@app.ui.openai.widget(
- identifier="pizza-map",
+ name="pizza-map",
template_uri="ui://widget/pizza-map.html",
html='
',
invoking="Hand-tossing a map",
@@ -169,7 +169,7 @@ def show_pizza_map(topping: str) -> dict:
Register a widget with both text and data:
```python
@app.ui.openai.widget(
- identifier="weather",
+ name="weather",
template_uri="ui://widget/weather.html",
html='
...'
)
@@ -180,7 +180,7 @@ def show_weather(city: str) -> tuple[str, dict]:
Register a widget with text only:
```python
@app.ui.openai.widget(
- identifier="status",
+ name="status",
template_uri="ui://widget/status.html",
html='
...'
)
@@ -209,16 +209,16 @@ def show_status() -> str:
)
)
- # Determine the widget identifier and function based on calling pattern
+ # Determine the widget name and function based on calling pattern
if inspect.isroutine(name_or_fn):
# Case 1: @widget(template_uri=..., html=...) - function passed directly
fn = name_or_fn
- widget_identifier = identifier or fn.__name__
+ widget_name = name or fn.__name__
# Register immediately and return
return self._register_widget(
fn=fn,
- identifier=widget_identifier,
+ identifier=widget_name,
template_uri=template_uri,
html=html,
title=title,
@@ -242,15 +242,15 @@ def show_status() -> str:
elif isinstance(name_or_fn, str):
# Case 2: @widget("custom_name", template_uri=..., html=...)
- if identifier is not None:
+ if name is not None:
raise TypeError(
"Cannot specify both a name as first argument and as keyword argument. "
- f"Use either @widget('{name_or_fn}', ...) or @widget(identifier='{identifier}', ...), not both."
+ f"Use either @widget('{name_or_fn}', ...) or @widget(name='{name}', ...), not both."
)
- widget_identifier = name_or_fn
+ widget_name = name_or_fn
elif name_or_fn is None:
- # Case 3: @widget(identifier="name", template_uri=..., html=...)
- widget_identifier = identifier
+ # Case 3: @widget(name="name", template_uri=..., html=...)
+ widget_name = name
else:
raise TypeError(
f"First argument to @widget must be a function, string, or None, got {type(name_or_fn)}"
@@ -258,10 +258,10 @@ def show_status() -> str:
# Return decorator that will be applied to the function
def decorator(fn: AnyFunction) -> FunctionTool:
- actual_identifier = widget_identifier or fn.__name__
+ actual_name = widget_name or fn.__name__
return self._register_widget(
fn=fn,
- identifier=actual_identifier,
+ identifier=actual_name,
template_uri=template_uri,
html=html,
title=title,
diff --git a/src/fastmcp/ui/openai/metadata.py b/src/fastmcp/ui/openai/metadata.py
index 542c5cd4b..1a0e37ad1 100644
--- a/src/fastmcp/ui/openai/metadata.py
+++ b/src/fastmcp/ui/openai/metadata.py
@@ -61,8 +61,7 @@ def build_widget_resource_meta(
```
"""
return {
- "openai/widgetDescription": widget_description
- or f"{title} widget UI.",
+ "openai/widgetDescription": widget_description or f"{title} widget UI.",
"openai/widgetPrefersBorder": widget_prefers_border,
"openai/widgetCSP": {
"resource_domains": _normalize_sequence(
@@ -94,7 +93,7 @@ def build_embedded_widget_resource(
html: Widget HTML content
title: Widget title
mime_type: MIME type for the widget HTML
- widget_description: Description of widget UI
+ widget_description: Description of widget UI (defaults to "{title} widget UI.")
widget_prefers_border: Whether widget prefers a border
widget_csp_resources: CSP resource_domains list
widget_csp_connect: CSP connect_domains list
@@ -156,7 +155,7 @@ def build_widget_tool_meta(
invoking: Status message shown while tool is executing
invoked: Status message shown after tool completes
mime_type: MIME type for the widget HTML
- widget_description: Description of widget UI
+ widget_description: Description of widget UI (defaults to "{title} widget UI.")
widget_prefers_border: Whether widget prefers a border
widget_csp_resources: CSP resource_domains list
widget_csp_connect: CSP connect_domains list
diff --git a/tests/ui/test_openai_widget.py b/tests/ui/test_openai_widget.py
index 946a242b0..cd9e972a9 100644
--- a/tests/ui/test_openai_widget.py
+++ b/tests/ui/test_openai_widget.py
@@ -8,7 +8,7 @@ async def test_widget_decorator_basic():
app = FastMCP("test")
@app.ui.openai.widget(
- identifier="my_widget",
+ name="my_widget",
template_uri="ui://widget/test.html",
html="Test Widget
",
)
@@ -22,11 +22,11 @@ def my_widget(x: int) -> str:
async def test_widget_decorator_with_name():
- """Test widget decorator with custom identifier."""
+ """Test widget decorator with custom name."""
app = FastMCP("test")
@app.ui.openai.widget(
- identifier="custom_widget",
+ name="custom_widget",
template_uri="ui://widget/custom.html",
html="Custom
",
)
@@ -43,7 +43,7 @@ async def test_widget_decorator_with_description():
app = FastMCP("test")
@app.ui.openai.widget(
- identifier="my_widget",
+ name="my_widget",
template_uri="ui://widget/test.html",
html="Test
",
description="This is a test widget",
@@ -61,7 +61,7 @@ async def test_widget_decorator_with_title():
app = FastMCP("test")
@app.ui.openai.widget(
- identifier="my_widget",
+ name="my_widget",
template_uri="ui://widget/test.html",
html="Test
",
title="My Widget Title",
@@ -79,7 +79,7 @@ async def test_widget_can_be_called():
app = FastMCP("test")
@app.ui.openai.widget(
- identifier="calculator",
+ name="calculator",
template_uri="ui://widget/calc.html",
html="Calc
",
)
diff --git a/tests/ui/test_openai_widget_full.py b/tests/ui/test_openai_widget_full.py
index 73e727a06..69ada5b8f 100644
--- a/tests/ui/test_openai_widget_full.py
+++ b/tests/ui/test_openai_widget_full.py
@@ -11,7 +11,7 @@ async def test_widget_with_dict_return():
app = FastMCP("test")
@app.ui.openai.widget(
- identifier="test-widget",
+ name="test-widget",
template_uri="ui://widget/test.html",
html="Test
",
)
@@ -35,7 +35,7 @@ async def test_widget_with_str_return():
app = FastMCP("test")
@app.ui.openai.widget(
- identifier="text-widget",
+ name="text-widget",
template_uri="ui://widget/text.html",
html="Text
",
)
@@ -56,7 +56,7 @@ async def test_widget_with_tuple_return():
app = FastMCP("test")
@app.ui.openai.widget(
- identifier="combo-widget",
+ name="combo-widget",
template_uri="ui://widget/combo.html",
html="Combo
",
)
@@ -77,7 +77,7 @@ async def test_widget_with_async_function():
app = FastMCP("test")
@app.ui.openai.widget(
- identifier="async-widget",
+ name="async-widget",
template_uri="ui://widget/async.html",
html="Async
",
)
@@ -99,7 +99,7 @@ async def test_widget_resource_registration():
app = FastMCP("test")
@app.ui.openai.widget(
- identifier="widget-with-resource",
+ name="widget-with-resource",
template_uri="ui://widget/test-resource.html",
html="Widget HTML
",
title="Test Resource Widget",
@@ -122,7 +122,7 @@ async def test_widget_metadata():
app = FastMCP("test")
@app.ui.openai.widget(
- identifier="meta-widget",
+ name="meta-widget",
template_uri="ui://widget/meta.html",
html="Meta
",
invoking="Loading widget...",
@@ -150,7 +150,7 @@ async def test_widget_csp_configuration():
app = FastMCP("test")
@app.ui.openai.widget(
- identifier="csp-widget",
+ name="csp-widget",
template_uri="ui://widget/csp.html",
html="CSP
",
widget_csp_resources=["https://custom.com", "https://another.com"],
@@ -177,7 +177,7 @@ async def test_widget_with_title_and_description():
app = FastMCP("test")
@app.ui.openai.widget(
- identifier="titled-widget",
+ name="titled-widget",
template_uri="ui://widget/titled.html",
html="Title
",
title="My Custom Title",
@@ -199,7 +199,7 @@ async def test_widget_with_tags():
app = FastMCP("test")
@app.ui.openai.widget(
- identifier="tagged-widget",
+ name="tagged-widget",
template_uri="ui://widget/tagged.html",
html="Tags
",
tags={"visualization", "data"},
@@ -219,7 +219,7 @@ async def test_widget_with_enabled_false():
app = FastMCP("test")
@app.ui.openai.widget(
- identifier="disabled-widget",
+ name="disabled-widget",
template_uri="ui://widget/disabled.html",
html="Disabled
",
enabled=False,
@@ -234,19 +234,19 @@ def disabled_widget() -> dict:
assert tool.enabled is False
-async def test_widget_with_custom_identifier():
- """Test widget with custom identifier different from function name."""
+async def test_widget_with_custom_name():
+ """Test widget with custom name different from function name."""
app = FastMCP("test")
@app.ui.openai.widget(
- identifier="custom-id",
+ name="custom-id",
template_uri="ui://widget/custom.html",
html="Custom
",
)
def some_function_name() -> dict:
return {"test": "data"}
- # Should be registered with custom identifier
+ # Should be registered with custom name
tools = await app.get_tools()
assert "custom-id" in tools
assert "some_function_name" not in tools
@@ -257,7 +257,7 @@ async def test_widget_annotations():
app = FastMCP("test")
@app.ui.openai.widget(
- identifier="annotated-widget",
+ name="annotated-widget",
template_uri="ui://widget/annotated.html",
html="Annotated
",
)
@@ -319,7 +319,7 @@ async def test_widget_invalid_return_type():
app = FastMCP("test")
@app.ui.openai.widget(
- identifier="bad-return-widget",
+ name="bad-return-widget",
template_uri="ui://widget/bad.html",
html="Bad
",
)
@@ -340,7 +340,7 @@ async def test_widget_with_exclude_args():
app = FastMCP("test")
@app.ui.openai.widget(
- identifier="exclude-args-widget",
+ name="exclude-args-widget",
template_uri="ui://widget/exclude.html",
html="Exclude
",
exclude_args=["secret_param"],
From f6ab2eef6d1be8d3c43835740b7661e240235e2e Mon Sep 17 00:00:00 2001
From: Adam Azzam <33043305+AAAZZAM@users.noreply.github.com>
Date: Wed, 22 Oct 2025 13:05:42 -0400
Subject: [PATCH 5/5] examples
---
examples/widget_test/README.md | 6 +-
examples/widget_test/pizzaz_server.py | 100 ++++++++++++++++++++++++++
2 files changed, 103 insertions(+), 3 deletions(-)
create mode 100644 examples/widget_test/pizzaz_server.py
diff --git a/examples/widget_test/README.md b/examples/widget_test/README.md
index 61a4a129c..768c73002 100644
--- a/examples/widget_test/README.md
+++ b/examples/widget_test/README.md
@@ -75,7 +75,7 @@ Returns structured data only, which the widget renders:
```python
@app.ui.openai.widget(
- identifier="pizza-map",
+ name="pizza-map",
template_uri="ui://widget/pizza-map.html",
html=PIZZAZ_HTML,
invoking="Hand-tossing a map",
@@ -91,7 +91,7 @@ Returns both narrative text and structured data:
```python
@app.ui.openai.widget(
- identifier="pizza-tracker",
+ name="pizza-tracker",
template_uri="ui://widget/pizza-tracker.html",
html=PIZZAZ_HTML,
invoking="Tracking your pizza",
@@ -109,7 +109,7 @@ Returns text only, no structured data:
```python
@app.ui.openai.widget(
- identifier="pizza-status",
+ name="pizza-status",
template_uri="ui://widget/pizza-status.html",
html=PIZZAZ_HTML,
)
diff --git a/examples/widget_test/pizzaz_server.py b/examples/widget_test/pizzaz_server.py
new file mode 100644
index 000000000..649be00a6
--- /dev/null
+++ b/examples/widget_test/pizzaz_server.py
@@ -0,0 +1,100 @@
+"""Pizzaz Widget Demo Server
+
+This demonstrates FastMCP's OpenAI widget support using the Pizzaz mapping library.
+The widget renders an interactive map when you ask about pizza toppings.
+
+Example usage:
+ Show me a pizza map for pepperoni
+ Create a pizza map with mushrooms
+"""
+
+from fastmcp import FastMCP
+
+app = FastMCP("pizzaz-demo")
+
+# Pizzaz widget HTML with the library loaded from OpenAI's CDN
+PIZZAZ_HTML = """
+
+
+
+"""
+
+
+@app.ui.openai.widget(
+ name="pizza-map",
+ template_uri="ui://widget/pizza-map.html",
+ html=PIZZAZ_HTML,
+ title="Pizza Map",
+ description="Show an interactive pizza map for a given topping",
+ invoking="Hand-tossing a map",
+ invoked="Served a fresh map",
+ widget_csp_resources=["https://persistent.oaistatic.com"],
+)
+def show_pizza_map(topping: str) -> dict:
+ """Show an interactive pizza map for the given topping.
+
+ Args:
+ topping: The pizza topping to map (e.g., "pepperoni", "mushrooms")
+
+ Returns:
+ Structured data for the widget to render
+ """
+ return {
+ "pizza_topping": topping.strip(),
+ "map_type": "delicious",
+ }
+
+
+@app.ui.openai.widget(
+ name="pizza-tracker",
+ template_uri="ui://widget/pizza-tracker.html",
+ html=PIZZAZ_HTML,
+ title="Pizza Tracker",
+ description="Track a pizza order with real-time updates",
+ invoking="Tracking your pizza",
+ invoked="Pizza located!",
+ widget_csp_resources=["https://persistent.oaistatic.com"],
+)
+def track_pizza(order_id: str) -> tuple[str, dict]:
+ """Track a pizza order by order ID.
+
+ This demonstrates returning both narrative text and structured data.
+
+ Args:
+ order_id: The pizza order ID to track
+
+ Returns:
+ Tuple of (narrative text, structured data for widget)
+ """
+ narrative = f"Tracking pizza order {order_id}. Your pizza is on the way!"
+ data = {
+ "order_id": order_id,
+ "status": "out_for_delivery",
+ "estimated_time": "15 minutes",
+ "driver_location": {"lat": 47.6062, "lng": -122.3321},
+ }
+ return narrative, data
+
+
+@app.ui.openai.widget(
+ name="pizza-status",
+ template_uri="ui://widget/pizza-status.html",
+ html=PIZZAZ_HTML,
+ title="Pizza Status",
+ description="Get the current status of your pizza",
+ widget_csp_resources=["https://persistent.oaistatic.com"],
+)
+def pizza_status() -> str:
+ """Get a simple status message about pizza availability.
+
+ This demonstrates returning text only (no structured data).
+
+ Returns:
+ Status message text
+ """
+ return "Pizza ovens are hot and ready! 🍕 We can make any topping you'd like."
+
+
+if __name__ == "__main__":
+ # Run with HTTP transport for testing with ChatGPT
+ app.run(transport="http", host="0.0.0.0", port=8080)