v0.16.0
Added
-
Tools link to interactive views.
ui=UIToolMeta(resource_uri="ui://…")
onregister_service_tool/register_selector_tool/
register_chain_tool(and the@service_tool/@selector_tool
decorators) emits_meta.uion the tool'stools/listentry, so an MCP
host renders that tool's result inside the view instead of showing raw JSON.
This closes the MCP Apps round trip started byregister_ui_resource.- The render payload is the
structuredContentthe tool already emits —
no second serialisation path, pagination envelope included. Atools/call
the view makes arrives at the ordinary endpoint, sopermission_classes,
per-bindingMCPPermissions and rate limits all apply unchanged. visibility(UIVisibility.MODEL/APP) declares who may call the tool.
Host-enforced — a host must not offer the model a tool whose visibility
omitsMODEL— so this server declares the field and does not filter
tools/liston it.- Three ways a link can be wrong are refused at registration, because all
three fail identically at runtime: a view that silently never renders.
(1)resource_urinames no view on this server — so a view must be
registered before the tool linking to it; (2) the tool doesn't emit
structuredContent, checked against the effective value so a project
that disabled it globally is caught too; (3) bothui=and a"ui"key in
meta=, which would silently overwrite each other.
- The render payload is the
-
ClientCapabilities.extensions— a client'sinitializenow round-trips
the protocol extensions it advertises (MCP Apps arrives as
io.modelcontextprotocol/ui). Parsed for introspection only: advertisement
is one-directional, client → server, the spec defines no matching server
capability, and nothing gates on it — remembering it per session would mean a
breaking change to the pluggableSessionStoreProtocol, for metadata
non-supporting clients are required to ignore anyway. -
Interactive views — the server half of
MCP Apps. A tool can
declare an HTML view that an MCP host renders inline in the chat.
MCPServer.register_ui_resource(name=…, uri="ui://…", template_name=…)
serves the document with thetext/html;profile=mcp-appmime type and a
_metabundle built from the typedUIResourceMeta— CSP origins (UICsp),
browserUIPermissions, publisherdomain, border preference.- We declare; hosts render. The sandboxed iframe, CSP enforcement and
theui/*postMessage bridge belong to the host and are deliberately not
implemented here. Apps is an extension over base MCP2025-11-25, which
this package already speaks, so there is no protocol bump, no transport
change and no new capability to advertise. - A view is an ordinary resource. It shares one URI namespace with data
resources (a collision raises as always), appears inresources/list, and
honourspermissions=/always_listed. Views are unguarded by
default: the MCP session is already authenticated, a view is a static
asset rather than tenant data, and hosts may prefetch one before any tool
call. - Exactly one content source —
template_name=(a Django template, rendered
per read so an edit needs no restart),html=, or a zero-argument
selector=. The template renders with no context: because hosts
prefetch and cache views, a view is a shell that hydrates itself from tool
results, and rendering a queryset into it would leak data across that
cache. meta=stays available for other extensions. Passing both the typedui=
and that same key insidemeta=raises at registration rather than letting
one silently win — the symptom would be a view that never renders.
- We declare; hosts render. The sandboxed iframe, CSP enforcement and
-
ResourceEncoding— a resource declares how its body is encoded.
register_resource(..., encoding=ResourceEncoding.TEXT)returns the
selector's value verbatim instead of JSON-encoding it. This fixes a live
bug for any non-JSON resource:mime_type=has always accepted anything,
but both read handlers JSON-encoded unconditionally, so a Markdown, CSV,
plain-text or HTML resource came back as a quoted JSON string literal
rather than the document. Declared rather than sniffed frommime_type, so
advertising a new type never silently changes the body. ATEXTresource
whose selector returns a non-strcomes back as a JSON-RPC error on the read
rather than raising through the transport. -
Generic
_metapassthrough on the wire types and every registration
surface. The base MCP protocol gives most wire objects a free-form_meta
object — the open extension namespace, distinct from the closed
annotationshint bundle — and the package had no way to populate it. Pass
meta=toregister_service_tool/register_selector_tool/
register_chain_tool/register_resource/register_prompt, to the
@service_tool/@selector_tool/@resource/@promptdecorators, or
to aToolDefinition/SelectorDefaults/ServiceDefaults, and the
bundle lands on the binding (binding.meta) and is emitted verbatim under
the"_meta"key of that binding's listing entry (tools/list,
resources/list,resources/templates/list,prompts/list) and — for a
resource — on thecontentsblockresources/readreturns, on both the sync
and async transports. Omitted entirely when empty, so existing payloads are
byte-identical. Keys are passed through untouched: nothing is validated,
reserved, or rewritten. Contributions are combined bymerge_meta(shallow,
later wins), the single seam a future feature injects its own key through.
On thetools/callresult envelope_metais per-call rather than
per-binding, so it is abuild_tool_result(..., meta=…)argument.
Changed
resources/readrenders and encodes through one shared builder. The sync
and async read handlers are full parallel implementations, not wrappers, so
the serializer/encoding step now lives in
output.build_resource_contents.build_resource_contentsand the two
transports cannot drift apart on it. No wire change.