Skip to content

apps

MCP Apps extension (io.modelcontextprotocol/ui).

MCP Apps lets a tool carry a reference to an interactive UI: the tool's _meta.ui.resourceUri points at a ui:// resource (an HTML document served with the text/html;profile=mcp-app MIME type) that the host renders in a sandboxed iframe. See https://modelcontextprotocol.io/specification/draft/extensions/apps and the ext-apps spec for the wire format, and SEP-2133 for the extension framework.

This is a self-contained, additive Extension: it contributes tools and resources and advertises the capability, but does not intercept any core method. A server opts in by passing an Apps instance to MCPServer(extensions=[...]).

apps = Apps()

@apps.tool(resource_uri="ui://clock/app.html", description="Current time")
def get_time(ctx: Context) -> str:
    return datetime.now(timezone.utc).isoformat()

apps.add_html_resource("ui://clock/app.html", CLOCK_HTML)

mcp = MCPServer("clock", extensions=[apps])

Per SEP-2133, an extension MUST degrade gracefully: a UI-enabled tool should still return meaningful text for clients that did not negotiate Apps. Use client_supports_apps(ctx) to branch on the client's advertised support. (The SDK keeps Apps in-core under mcp.server.apps rather than a separate package; the TypeScript and C# SDKs ship it as a standalone package.)

EXTENSION_ID module-attribute

EXTENSION_ID = 'io.modelcontextprotocol/ui'

The MCP Apps extension identifier (the shipped TS/C# constant).

APP_MIME_TYPE module-attribute

APP_MIME_TYPE = 'text/html;profile=mcp-app'

MIME type for a ui:// app resource.

Visibility module-attribute

Visibility = Literal['model', 'app']

Where a UI-bound tool is surfaced (_meta.ui.visibility).

ResourcePermissions

Bases: BaseModel

Iframe permissions a ui:// resource requests (_meta.ui.permissions).

Source code in src/mcp/server/apps.py
55
56
57
58
59
60
61
62
63
class ResourcePermissions(BaseModel):
    """Iframe permissions a `ui://` resource requests (`_meta.ui.permissions`)."""

    model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True)

    camera: dict[str, Any] | None = None
    microphone: dict[str, Any] | None = None
    geolocation: dict[str, Any] | None = None
    clipboard_write: dict[str, Any] | None = None

ResourceCsp

Bases: BaseModel

Content-Security-Policy domains for a ui:// resource (_meta.ui.csp).

Source code in src/mcp/server/apps.py
66
67
68
69
70
71
72
73
74
class ResourceCsp(BaseModel):
    """Content-Security-Policy domains for a `ui://` resource (`_meta.ui.csp`)."""

    model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True)

    connect_domains: list[str] | None = None
    resource_domains: list[str] | None = None
    frame_domains: list[str] | None = None
    base_uri_domains: list[str] | None = None

Apps

Bases: Extension

The MCP Apps extension: bind tools to ui:// UI resources.

Register UI-bound tools with @apps.tool(resource_uri=...) and their HTML with add_html_resource(...), then pass the instance to MCPServer(extensions=[apps]).

Source code in src/mcp/server/apps.py
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
class Apps(Extension):
    """The MCP Apps extension: bind tools to `ui://` UI resources.

    Register UI-bound tools with `@apps.tool(resource_uri=...)` and their HTML
    with `add_html_resource(...)`, then pass the instance to
    `MCPServer(extensions=[apps])`.
    """

    identifier = EXTENSION_ID

    def __init__(self) -> None:
        self._tools: list[tuple[ToolBinding, str]] = []  # (binding, bound resource_uri)
        self._resources: list[ResourceBinding] = []

    def tool(
        self,
        *,
        resource_uri: str,
        visibility: Sequence[Visibility] | None = None,
        meta: dict[str, Any] | None = None,
        **tool_kwargs: Any,
    ) -> Callable[[_CallableT], _CallableT]:
        """Decorator registering a tool bound to a `ui://` resource.

        Stamps `_meta.ui.resourceUri` (and `_meta.ui.visibility` when given) on the
        tool. `tool_kwargs` are forwarded to `MCPServer.add_tool` (name, title,
        description, annotations, ...); pass `meta=` to merge extra `_meta` keys
        alongside the `ui` entry.

        Args:
            resource_uri: The `ui://` URI of the UI resource this tool renders.
            visibility: Where the tool is surfaced (`["model", "app"]`).
            meta: Additional `_meta` keys to merge with the `ui` entry.

        Raises:
            ValueError: If `resource_uri` does not use the `ui://` scheme, or
                `meta` carries a `"ui"` key (the decorator owns `_meta["ui"]`).
        """
        _require_ui_scheme(resource_uri)
        if meta and "ui" in meta:
            raise ValueError("Apps.tool() owns _meta['ui']; pass resource_uri=/visibility= instead of a 'ui' meta key")
        ui: dict[str, Any] = {"resourceUri": resource_uri}
        if visibility is not None:
            ui["visibility"] = list(visibility)

        def decorator(fn: _CallableT) -> _CallableT:
            binding = ToolBinding(fn=fn, meta={**(meta or {}), "ui": ui}, kwargs=tool_kwargs)
            self._tools.append((binding, resource_uri))
            return fn

        return decorator

    def add_html_resource(
        self,
        uri: str,
        html: str,
        *,
        name: str | None = None,
        title: str | None = None,
        description: str | None = None,
        csp: ResourceCsp | None = None,
        permissions: ResourcePermissions | None = None,
        domain: str | None = None,
        prefers_border: bool | None = None,
    ) -> None:
        """Register a `ui://` HTML resource served as `text/html;profile=mcp-app`.

        `csp`, `permissions`, `domain`, and `prefers_border` populate the
        resource's `_meta.ui` per the ext-apps spec.

        Args:
            uri: The `ui://` URI; a tool references it via `resource_uri`.
            html: The HTML document the host renders.

        Raises:
            ValueError: If `uri` does not use the `ui://` scheme.
        """
        ui: dict[str, Any] = {}
        if csp is not None:
            ui["csp"] = csp.model_dump(by_alias=True, exclude_none=True)
        if permissions is not None:
            ui["permissions"] = permissions.model_dump(by_alias=True, exclude_none=True)
        if domain is not None:
            ui["domain"] = domain
        if prefers_border is not None:
            ui["prefersBorder"] = prefers_border
        self.add_resource(
            TextResource(
                uri=uri,
                name=name or uri,
                title=title,
                description=description,
                mime_type=APP_MIME_TYPE,
                meta={"ui": ui} if ui else None,
                text=html,
            )
        )

    def add_resource(self, resource: Resource) -> None:
        """Register a pre-built `ui://` resource.

        The escape hatch for resources `add_html_resource` cannot express (e.g. a
        `FileResource` serving HTML from disk). A resource without an explicit
        `mime_type` is served as `text/html;profile=mcp-app` — hosts will not
        render a `ui://` resource under any other MIME type, so an explicit
        mismatch is rejected.

        Raises:
            ValueError: If the resource URI does not use the `ui://` scheme, or
                its explicit `mime_type` is not `text/html;profile=mcp-app`.
        """
        _require_ui_scheme(resource.uri)
        if "mime_type" not in resource.model_fields_set:
            resource = resource.model_copy(update={"mime_type": APP_MIME_TYPE})
        elif resource.mime_type != APP_MIME_TYPE:
            raise ValueError(f"MCP Apps resources are served as {APP_MIME_TYPE!r}, got {resource.mime_type!r}")
        self._resources.append(ResourceBinding(resource=resource))

    def tools(self) -> Sequence[ToolBinding]:
        """The bound tools.

        Raises:
            ValueError: If a tool's `resource_uri` has no matching resource
                registered on this instance — a tool advertising a
                `_meta.ui.resourceUri` that 404s on `resources/read` is a
                misconfiguration, caught when the server consumes the extension.
        """
        registered = {binding.resource.uri for binding in self._resources}
        for tool, uri in self._tools:
            if uri not in registered:
                raise ValueError(
                    f"Apps tool {tool.fn.__name__!r} binds resource_uri {uri!r}, but no such resource "
                    "is registered; add it with add_html_resource() or add_resource()"
                )
        return [tool for tool, _ in self._tools]

    def resources(self) -> Sequence[ResourceBinding]:
        return self._resources

tool

tool(
    *,
    resource_uri: str,
    visibility: Sequence[Visibility] | None = None,
    meta: dict[str, Any] | None = None,
    **tool_kwargs: Any
) -> Callable[[_CallableT], _CallableT]

Decorator registering a tool bound to a ui:// resource.

Stamps _meta.ui.resourceUri (and _meta.ui.visibility when given) on the tool. tool_kwargs are forwarded to MCPServer.add_tool (name, title, description, annotations, ...); pass meta= to merge extra _meta keys alongside the ui entry.

Parameters:

Name Type Description Default
resource_uri str

The ui:// URI of the UI resource this tool renders.

required
visibility Sequence[Visibility] | None

Where the tool is surfaced (["model", "app"]).

None
meta dict[str, Any] | None

Additional _meta keys to merge with the ui entry.

None

Raises:

Type Description
ValueError

If resource_uri does not use the ui:// scheme, or meta carries a "ui" key (the decorator owns _meta["ui"]).

Source code in src/mcp/server/apps.py
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
def tool(
    self,
    *,
    resource_uri: str,
    visibility: Sequence[Visibility] | None = None,
    meta: dict[str, Any] | None = None,
    **tool_kwargs: Any,
) -> Callable[[_CallableT], _CallableT]:
    """Decorator registering a tool bound to a `ui://` resource.

    Stamps `_meta.ui.resourceUri` (and `_meta.ui.visibility` when given) on the
    tool. `tool_kwargs` are forwarded to `MCPServer.add_tool` (name, title,
    description, annotations, ...); pass `meta=` to merge extra `_meta` keys
    alongside the `ui` entry.

    Args:
        resource_uri: The `ui://` URI of the UI resource this tool renders.
        visibility: Where the tool is surfaced (`["model", "app"]`).
        meta: Additional `_meta` keys to merge with the `ui` entry.

    Raises:
        ValueError: If `resource_uri` does not use the `ui://` scheme, or
            `meta` carries a `"ui"` key (the decorator owns `_meta["ui"]`).
    """
    _require_ui_scheme(resource_uri)
    if meta and "ui" in meta:
        raise ValueError("Apps.tool() owns _meta['ui']; pass resource_uri=/visibility= instead of a 'ui' meta key")
    ui: dict[str, Any] = {"resourceUri": resource_uri}
    if visibility is not None:
        ui["visibility"] = list(visibility)

    def decorator(fn: _CallableT) -> _CallableT:
        binding = ToolBinding(fn=fn, meta={**(meta or {}), "ui": ui}, kwargs=tool_kwargs)
        self._tools.append((binding, resource_uri))
        return fn

    return decorator

add_html_resource

add_html_resource(
    uri: str,
    html: str,
    *,
    name: str | None = None,
    title: str | None = None,
    description: str | None = None,
    csp: ResourceCsp | None = None,
    permissions: ResourcePermissions | None = None,
    domain: str | None = None,
    prefers_border: bool | None = None
) -> None

Register a ui:// HTML resource served as text/html;profile=mcp-app.

csp, permissions, domain, and prefers_border populate the resource's _meta.ui per the ext-apps spec.

Parameters:

Name Type Description Default
uri str

The ui:// URI; a tool references it via resource_uri.

required
html str

The HTML document the host renders.

required

Raises:

Type Description
ValueError

If uri does not use the ui:// scheme.

Source code in src/mcp/server/apps.py
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
def add_html_resource(
    self,
    uri: str,
    html: str,
    *,
    name: str | None = None,
    title: str | None = None,
    description: str | None = None,
    csp: ResourceCsp | None = None,
    permissions: ResourcePermissions | None = None,
    domain: str | None = None,
    prefers_border: bool | None = None,
) -> None:
    """Register a `ui://` HTML resource served as `text/html;profile=mcp-app`.

    `csp`, `permissions`, `domain`, and `prefers_border` populate the
    resource's `_meta.ui` per the ext-apps spec.

    Args:
        uri: The `ui://` URI; a tool references it via `resource_uri`.
        html: The HTML document the host renders.

    Raises:
        ValueError: If `uri` does not use the `ui://` scheme.
    """
    ui: dict[str, Any] = {}
    if csp is not None:
        ui["csp"] = csp.model_dump(by_alias=True, exclude_none=True)
    if permissions is not None:
        ui["permissions"] = permissions.model_dump(by_alias=True, exclude_none=True)
    if domain is not None:
        ui["domain"] = domain
    if prefers_border is not None:
        ui["prefersBorder"] = prefers_border
    self.add_resource(
        TextResource(
            uri=uri,
            name=name or uri,
            title=title,
            description=description,
            mime_type=APP_MIME_TYPE,
            meta={"ui": ui} if ui else None,
            text=html,
        )
    )

add_resource

add_resource(resource: Resource) -> None

Register a pre-built ui:// resource.

The escape hatch for resources add_html_resource cannot express (e.g. a FileResource serving HTML from disk). A resource without an explicit mime_type is served as text/html;profile=mcp-app — hosts will not render a ui:// resource under any other MIME type, so an explicit mismatch is rejected.

Raises:

Type Description
ValueError

If the resource URI does not use the ui:// scheme, or its explicit mime_type is not text/html;profile=mcp-app.

Source code in src/mcp/server/apps.py
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
def add_resource(self, resource: Resource) -> None:
    """Register a pre-built `ui://` resource.

    The escape hatch for resources `add_html_resource` cannot express (e.g. a
    `FileResource` serving HTML from disk). A resource without an explicit
    `mime_type` is served as `text/html;profile=mcp-app` — hosts will not
    render a `ui://` resource under any other MIME type, so an explicit
    mismatch is rejected.

    Raises:
        ValueError: If the resource URI does not use the `ui://` scheme, or
            its explicit `mime_type` is not `text/html;profile=mcp-app`.
    """
    _require_ui_scheme(resource.uri)
    if "mime_type" not in resource.model_fields_set:
        resource = resource.model_copy(update={"mime_type": APP_MIME_TYPE})
    elif resource.mime_type != APP_MIME_TYPE:
        raise ValueError(f"MCP Apps resources are served as {APP_MIME_TYPE!r}, got {resource.mime_type!r}")
    self._resources.append(ResourceBinding(resource=resource))

tools

tools() -> Sequence[ToolBinding]

The bound tools.

Raises:

Type Description
ValueError

If a tool's resource_uri has no matching resource registered on this instance — a tool advertising a _meta.ui.resourceUri that 404s on resources/read is a misconfiguration, caught when the server consumes the extension.

Source code in src/mcp/server/apps.py
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
def tools(self) -> Sequence[ToolBinding]:
    """The bound tools.

    Raises:
        ValueError: If a tool's `resource_uri` has no matching resource
            registered on this instance — a tool advertising a
            `_meta.ui.resourceUri` that 404s on `resources/read` is a
            misconfiguration, caught when the server consumes the extension.
    """
    registered = {binding.resource.uri for binding in self._resources}
    for tool, uri in self._tools:
        if uri not in registered:
            raise ValueError(
                f"Apps tool {tool.fn.__name__!r} binds resource_uri {uri!r}, but no such resource "
                "is registered; add it with add_html_resource() or add_resource()"
            )
    return [tool for tool, _ in self._tools]

client_supports_apps

client_supports_apps(
    ctx: Context[Any] | ServerRequestContext[Any, Any],
) -> bool

Whether the connected client negotiated MCP Apps support.

Returns True only when the client advertised the extension AND listed the text/html;profile=mcp-app MIME type in its settings, so a UI-enabled tool can fall back to text-only output otherwise.

Source code in src/mcp/server/apps.py
217
218
219
220
221
222
223
224
225
226
227
228
229
230
def client_supports_apps(ctx: Context[Any] | ServerRequestContext[Any, Any]) -> bool:
    """Whether the connected client negotiated MCP Apps support.

    Returns `True` only when the client advertised the extension AND listed the
    `text/html;profile=mcp-app` MIME type in its settings, so a UI-enabled tool
    can fall back to text-only output otherwise.
    """
    capabilities = _client_capabilities(ctx)
    extensions = capabilities.extensions if capabilities else None
    settings = extensions.get(EXTENSION_ID) if extensions else None
    if settings is None:
        return False
    mime_types = settings.get("mimeTypes")
    return isinstance(mime_types, list | tuple) and APP_MIME_TYPE in mime_types