Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,46 @@ On the client, pass extensions through `connect`:
client.connect(capabilities: { extensions: { "com.example/feature" => {} } })
```

### MCP Apps (SEP-1865)

MCP Apps is a Final extension (negotiated via the Capability Extensions mechanism above) that lets a server ship interactive
HTML user interfaces which the host renders for tool results. On the server side the extension is a thin convention,
and `MCP::Apps` provides the vocabulary and helpers:

```ruby
capabilities = MCP::Server::Capabilities.new
capabilities.support_tools
capabilities.support_resources
capabilities.support_extensions(MCP::Apps.capability) # { "io.modelcontextprotocol/ui" => { mimeTypes: [...] } }

server = MCP::Server.new(
name: "weather_server",
capabilities: capabilities,
# UI templates are ordinary resources with a `ui://` URI and the `text/html;profile=mcp-app` MIME type.
resources: [MCP::Apps.ui_resource(uri: "ui://weather-server/dashboard", name: "weather_dashboard")],
)

server.resources_read_handler do |params|
[{ uri: params[:uri], mimeType: MCP::Apps::RESOURCE_MIME_TYPE, text: "<html>...</html>" }]
end

# Link the tool to its template via `_meta.ui.resourceUri` (pass `legacy: true` to also
# emit the older flat `"ui/resourceUri"` alias for hosts that predate the Final spec).
server.define_tool(
name: "get_weather",
meta: MCP::Apps.tool_meta(resource_uri: "ui://weather-server/dashboard"),
) do |server_context:|
# The extension is optional: always return a meaningful text result, and use
# `MCP::Apps.client_supports?` when UI-capable clients should get richer structured content.
MCP::Apps.client_supports?(server.client_capabilities) # => true when the host declared the extension
MCP::Tool::Response.new([{ type: "text", text: "Sunny, 22 degrees Celsius" }])
end
```

Everything else the extension defines (the sandboxed iframe, the `ui/*` postMessage bridge, consent for UI-initiated actions)
is the HOST's responsibility; a server only ever receives ordinary `resources/read` and `tools/call` requests.
See the [MCP Apps specification](https://github.com/modelcontextprotocol/ext-apps/blob/main/specification/2026-01-26/apps.mdx).

### Server Context and Configuration Block Data

#### `server_context`
Expand Down
1 change: 1 addition & 0 deletions lib/mcp.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

module MCP
autoload :Annotations, "mcp/annotations"
autoload :Apps, "mcp/apps"
autoload :Cancellation, "mcp/cancellation"
autoload :CancelledError, "mcp/cancelled_error"
autoload :Client, "mcp/client"
Expand Down
109 changes: 109 additions & 0 deletions lib/mcp/apps.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
# frozen_string_literal: true

module MCP
# Server-side helpers for the MCP Apps extension (SEP-1865, Extensions Track, Final):
# interactive user interfaces delivered as `ui://` HTML template resources that hosts
# render for tool results. The extension is negotiated per SEP-2133 through
# `capabilities.extensions` on both sides; a server declares {EXTENSION_ID}, registers
# UI templates as ordinary resources, and links tools to templates via `_meta`.
#
# Everything else defined by the extension (the sandboxed iframe, the `ui/*`
# postMessage bridge methods, consent for UI-initiated calls) is HOST responsibility:
# a server only ever receives ordinary `resources/read` and `tools/call` requests.
#
# Because the extension is optional, a UI-enabled tool MUST still return a meaningful
# text-only result for clients that did not declare the capability;
# use {Apps.client_supports?} to branch.
#
# @example Declaring an Apps-enabled server
# capabilities = MCP::Server::Capabilities.new
# capabilities.support_tools
# capabilities.support_resources
# capabilities.support_extensions(MCP::Apps.capability)
# server = MCP::Server.new(
# name: "weather_server",
# capabilities: capabilities,
# resources: [MCP::Apps.ui_resource(uri: "ui://weather-server/dashboard", name: "weather_dashboard")],
# )
# server.define_tool(
# name: "get_weather",
# meta: MCP::Apps.tool_meta(resource_uri: "ui://weather-server/dashboard"),
# ) { |server_context:|
# ...
# }
#
# https://github.com/modelcontextprotocol/ext-apps/blob/main/specification/2026-01-26/apps.mdx
module Apps
# Reverse-DNS extension identifier (note: `/ui`, not `/apps`), shared wire vocabulary with
# the reference `@modelcontextprotocol/ext-apps` package and the Python SDK's `Apps` extension.
EXTENSION_ID = "io.modelcontextprotocol/ui"
# UI template resources MUST use this parameterized MIME type.
RESOURCE_MIME_TYPE = "text/html;profile=mcp-app"
# UI template resource URIs MUST use this scheme.
URI_SCHEME = "ui://"
# Legacy flat `_meta` key linking a tool to its template; the canonical shape is
# the nested `_meta.ui.resourceUri`. {Apps.tool_meta} can emit both.
RESOURCE_URI_META_KEY = "ui/resourceUri"

extend self

# The `capabilities.extensions` fragment advertising Apps support. Pass to
# `MCP::Server::Capabilities.new(extensions: ...)` or `support_extensions(...)`,
# or merge into a client's `connect(capabilities: { extensions: ... })`.
def capability(mime_types: [RESOURCE_MIME_TYPE])
{ EXTENSION_ID => { mimeTypes: mime_types } }
end

# Builds an `MCP::Resource` for a UI template, enforcing the spec's MUSTs:
# a `ui://` URI and the `text/html;profile=mcp-app` MIME type by default.
def ui_resource(uri:, name:, mime_type: RESOURCE_MIME_TYPE, **rest)
unless uri.is_a?(String) && uri.start_with?(URI_SCHEME)
raise ArgumentError, "MCP Apps template URIs must start with #{URI_SCHEME.inspect} (got #{uri.inspect})"
end

Resource.new(uri: uri, name: name, mime_type: mime_type, **rest)
end

# Builds the tool `_meta` linking a tool to its UI template, merged non-destructively into
# caller-supplied `meta`. `visibility` restricts who sees the tool (an array of `"model"` / `"app"`).
# `legacy: true` also writes the flat `"ui/resourceUri"` alias, matching the reference server helper
# that keeps both key shapes in sync for older hosts.
def tool_meta(resource_uri:, visibility: nil, meta: nil, legacy: false)
unless resource_uri.is_a?(String) && resource_uri.start_with?(URI_SCHEME)
raise ArgumentError, "resource_uri must start with #{URI_SCHEME.inspect} (got #{resource_uri.inspect})"
end

ui_entry = { resourceUri: resource_uri }
ui_entry[:visibility] = visibility if visibility

merged = (meta || {}).merge(ui: ui_entry)
merged[RESOURCE_URI_META_KEY.to_sym] = resource_uri if legacy
merged
end

# Whether the client declared Apps support for `mime_type` in its `capabilities.extensions`
# (symbol or string keys). UI-enabled tools use this to fall back to a text-only result for
# clients without the extension.
def client_supports?(client_capabilities, mime_type: RESOURCE_MIME_TYPE)
extensions = read_key(client_capabilities, :extensions)
declaration = read_key(extensions, EXTENSION_ID)
return false unless declaration.is_a?(Hash)

mime_types = read_key(declaration, :mimeTypes)

# A declaration without mimeTypes advertises the extension without narrowing.
return true if mime_types.nil?

mime_types.is_a?(Array) && mime_types.include?(mime_type)
end

private

def read_key(hash, key)
return unless hash.is_a?(Hash)

value = hash[key.to_sym]
value.nil? ? hash[key.to_s] : value
end
end
end
69 changes: 69 additions & 0 deletions test/mcp/apps_test.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
# frozen_string_literal: true

require "test_helper"

module MCP
class AppsTest < ActiveSupport::TestCase
test "exposes the SEP-1865 wire vocabulary" do
# The exact strings are shared with the reference ext-apps package and the Python SDK.
assert_equal "io.modelcontextprotocol/ui", Apps::EXTENSION_ID
assert_equal "text/html;profile=mcp-app", Apps::RESOURCE_MIME_TYPE
assert_equal "ui://", Apps::URI_SCHEME
assert_equal "ui/resourceUri", Apps::RESOURCE_URI_META_KEY
end

test ".capability builds the extensions fragment" do
assert_equal({ "io.modelcontextprotocol/ui" => { mimeTypes: ["text/html;profile=mcp-app"] } }, Apps.capability)
assert_equal({ "io.modelcontextprotocol/ui" => { mimeTypes: ["text/html"] } }, Apps.capability(mime_types: ["text/html"]))
end

test ".ui_resource builds a Resource with the spec defaults and validates the scheme" do
resource = Apps.ui_resource(uri: "ui://weather/dashboard", name: "dashboard", description: "Weather UI")

assert_instance_of Resource, resource
assert_equal "ui://weather/dashboard", resource.uri
assert_equal "text/html;profile=mcp-app", resource.mime_type
assert_equal "Weather UI", resource.description
assert_raises(ArgumentError) do
Apps.ui_resource(uri: "file:///dashboard.html", name: "dashboard")
end
end

test ".tool_meta links a tool to its template without clobbering caller meta" do
meta = Apps.tool_meta(
resource_uri: "ui://weather/dashboard",
visibility: ["model", "app"],
meta: { custom: "value" },
)

assert_equal({ custom: "value", ui: { resourceUri: "ui://weather/dashboard", visibility: ["model", "app"] } }, meta)
assert_raises(ArgumentError) do
Apps.tool_meta(resource_uri: "https://example.com")
end
end

test ".tool_meta emits the legacy flat key when requested" do
meta = Apps.tool_meta(resource_uri: "ui://weather/dashboard", legacy: true)

assert_equal "ui://weather/dashboard", meta.dig(:ui, :resourceUri)
assert_equal "ui://weather/dashboard", meta[:"ui/resourceUri"]
end

test ".client_supports? checks the declared extension and mime types" do
declared = { extensions: { "io.modelcontextprotocol/ui" => { mimeTypes: ["text/html;profile=mcp-app"] } } }
string_keys = {
"extensions" => { "io.modelcontextprotocol/ui" => { "mimeTypes" => ["text/html;profile=mcp-app"] } },
}
without_mime_types = { extensions: { "io.modelcontextprotocol/ui" => {} } }
other_mime_type = { extensions: { "io.modelcontextprotocol/ui" => { mimeTypes: ["text/html"] } } }

assert Apps.client_supports?(declared)
assert Apps.client_supports?(string_keys)
assert Apps.client_supports?(without_mime_types)
refute Apps.client_supports?(other_mime_type)
refute Apps.client_supports?({})
refute Apps.client_supports?(nil)
refute Apps.client_supports?({ extensions: {} })
end
end
end
75 changes: 75 additions & 0 deletions test/mcp/server_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3096,5 +3096,80 @@ def server_context
assert_equal "b", second_result[:resourceTemplates][0][:name]
assert_nil second_result[:nextCursor]
end

test "an MCP Apps server advertises the extension and serves ui:// templates" do
# SEP-1865: the extension rides the SEP-2133 `capabilities.extensions` machinery,
# UI templates are ordinary resources, and tools link to them via `_meta.ui.resourceUri`.
dashboard = Apps.ui_resource(uri: "ui://weather/dashboard", name: "weather_dashboard")
capabilities = Server::Capabilities.new
capabilities.support_tools
capabilities.support_resources
capabilities.support_extensions(Apps.capability)
server = Server.new(name: "apps_test", capabilities: capabilities, resources: [dashboard])
server.define_tool(
name: "get_weather",
meta: Apps.tool_meta(resource_uri: "ui://weather/dashboard"),
) do
Tool::Response.new([{ type: "text", text: "Sunny" }])
end

server.resources_read_handler do |params|
[{ uri: params[:uri], mimeType: Apps::RESOURCE_MIME_TYPE, text: "<html>dashboard</html>" }]
end

init_response = server.handle({
jsonrpc: "2.0",
method: "initialize",
id: 1,
params: {
protocolVersion: "2025-11-25",
clientInfo: { name: "host", version: "1.0" },
capabilities: { extensions: Apps.capability },
},
})

tools_response = server.handle({
jsonrpc: "2.0",
method: "tools/list",
id: 2,
})

read_response = server.handle({
jsonrpc: "2.0",
method: "resources/read",
id: 3,
params: { uri: "ui://weather/dashboard" },
})

assert_equal(
{ mimeTypes: [Apps::RESOURCE_MIME_TYPE] },
init_response.dig(:result, :capabilities, :extensions, Apps::EXTENSION_ID),
)
tool_entry = tools_response.dig(:result, :tools, 0)

assert_equal "ui://weather/dashboard", tool_entry.dig(:_meta, :ui, :resourceUri)
assert_equal "<html>dashboard</html>", read_response.dig(:result, :contents, 0, :text)
# The server can gate the text-only fallback on the client's declaration.
assert Apps.client_supports?(server.client_capabilities)
end

test "an MCP Apps tool falls back to text for clients without the extension" do
capabilities = Server::Capabilities.new
capabilities.support_extensions(Apps.capability)
server = Server.new(name: "apps_test", capabilities: capabilities)

server.handle({
jsonrpc: "2.0",
method: "initialize",
id: 1,
params: {
protocolVersion: "2025-11-25",
clientInfo: { name: "plain_client", version: "1.0" },
capabilities: {},
},
})

refute Apps.client_supports?(server.client_capabilities)
end
end
end