Skip to content

Commit 4113d32

Browse files
committed
feat: add MCP Apps support (renders_ui DSL, weather dashboard demo)
per ext-apps draft spec (2026-01-26). closes #202. - ActionMCP::MIME_TYPE_APP_HTML constant for text/html;profile=mcp-app. - renders_ui "ui://..." class macro on Tool with optional visibility kwarg. validates URI scheme and visibility values at class-load time. emits nested _meta.ui.resourceUri only. no legacy flat _meta["ui/resourceUri"] key anywhere, pinned by a negative test. - Capability#client_supports_ui? instance helper for tools that want to degrade for hosts not advertising the extension. key-presence check, not value truthiness. the spec requires mimeTypes inside the value but validating that payload is not this helper's job. - weather dashboard resource template at ui://weather/dashboard with self-contained HTML, csp connectDomains, and prefersBorder. the demo intentionally emits _meta.ui on both the listing entry and the content item so both wire placements stay covered by tests. per spec, content-level takes precedence. - existing weather tool gets renders_ui. text and structured output are preserved. per apps.mdx, tools/list filtering by visibility is host-side; no server-side filter is added. ToolsRegistry.items is snapshotted and restored in setup/teardown for the new test file. Class.new(ActionMCP::Tool) fires the inherited hook before abstract! can run, which briefly leaks the unnamed class under the "" key; the snapshot/restore pattern neutralizes it and matches the existing approach in resource_template_test.rb.
1 parent b759280 commit 4113d32

6 files changed

Lines changed: 214 additions & 0 deletions

File tree

lib/action_mcp.rb

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,11 @@ class StructuredContentValidationError < StandardError; end
4141

4242
LATEST_VERSION = SUPPORTED_VERSIONS.first.freeze
4343
DEFAULT_PROTOCOL_VERSION = "2025-06-18" # Default to previous stable version for backwards compatibility
44+
45+
# MCP Apps extension (ext-apps, draft 2026-01-26).
46+
# Canonical MIME type for UI resources rendered by host-side sandboxes.
47+
# See ext-apps apps.mdx, Resource Content Types section.
48+
MIME_TYPE_APP_HTML = "text/html;profile=mcp-app"
4449
class << self
4550
# Returns a Rack-compatible application for serving MCP requests
4651
# @return [#call] A Rack application that can be used with `run ActionMCP.server`

lib/action_mcp/capability.rb

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,21 @@ def session
3232

3333
delegate :session_data, to: :session, allow_nil: true
3434

35+
# Returns true when the current client advertises the MCP Apps UI extension
36+
# (`capabilities.extensions["io.modelcontextprotocol/ui"]` in the initialize
37+
# request). Returns false when there is no session, no stored capabilities,
38+
# or the extension key is absent. Never raises.
39+
#
40+
# We check key presence rather than value truthiness. An empty hash under
41+
# the extension key still counts as "supported". The spec requires
42+
# `mimeTypes` inside the value (ext-apps apps.mdx, Client<>Server Capability
43+
# Negotiation section), but validating that payload is not this helper's
44+
# responsibility.
45+
def client_supports_ui?
46+
extensions = session&.client_capabilities&.dig("extensions")
47+
extensions.is_a?(Hash) && extensions.key?("io.modelcontextprotocol/ui")
48+
end
49+
3550
# use _capability_name or default_capability_name
3651
def self.capability_name
3752
_capability_name || default_capability_name

lib/action_mcp/tool.rb

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,45 @@ def meta(data = nil)
187187
end
188188
end
189189

190+
# Declares that this tool renders an MCP Apps UI resource. Host reads
191+
# `_meta.ui.resourceUri` from the tool listing and renders the referenced
192+
# `ui://` resource in a sandboxed iframe. See ext-apps apps.mdx.
193+
#
194+
# @param resource_uri [String] URI starting with `ui://`
195+
# @param visibility [Array<Symbol, String>, nil] subset of [:model, :app].
196+
# When omitted, no visibility key is emitted and the host applies the
197+
# spec default.
198+
def renders_ui(resource_uri, visibility: nil)
199+
unless resource_uri.is_a?(String) && resource_uri.match?(%r{\Aui://\S+\z})
200+
raise ArgumentError,
201+
"renders_ui requires a String URI starting with ui://, got: #{resource_uri.inspect}"
202+
end
203+
204+
ui_meta = { resourceUri: resource_uri }
205+
206+
if visibility
207+
unless visibility.is_a?(Array) && !visibility.empty?
208+
raise ArgumentError,
209+
"renders_ui visibility must be a non-empty Array, got: #{visibility.inspect}"
210+
end
211+
212+
normalized = visibility.map(&:to_s)
213+
invalid = normalized - %w[model app]
214+
unless invalid.empty?
215+
raise ArgumentError,
216+
"renders_ui visibility values must be one of model, app - got: #{invalid.inspect}"
217+
end
218+
if normalized.uniq != normalized
219+
raise ArgumentError,
220+
"renders_ui visibility contains duplicates: #{visibility.inspect}"
221+
end
222+
223+
ui_meta[:visibility] = normalized
224+
end
225+
226+
self._meta = _meta.merge(ui: ui_meta)
227+
end
228+
190229
# Marks this tool as requiring consent before execution
191230
def requires_consent!
192231
self._requires_consent = true

test/action_mcp/mcp_apps_test.rb

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
# frozen_string_literal: true
2+
3+
require "test_helper"
4+
5+
module ActionMCP
6+
class McpAppsTest < ActiveSupport::TestCase
7+
def setup
8+
@original_tool_items = ActionMCP::ToolsRegistry.items.dup
9+
end
10+
11+
def teardown
12+
ActionMCP::ToolsRegistry.instance_variable_set(:@items, @original_tool_items)
13+
end
14+
15+
def build_tool(&block)
16+
klass = Class.new(ActionMCP::Tool)
17+
klass.define_singleton_method(:name) { "RendersUiTempTool#{SecureRandom.hex(4)}" }
18+
klass.abstract!
19+
klass.tool_name "renders_ui_temp_#{SecureRandom.hex(4)}"
20+
klass.description "temp"
21+
klass.class_eval(&block) if block
22+
klass
23+
end
24+
25+
test "renders_ui serializes resourceUri under _meta.ui" do
26+
klass = build_tool { renders_ui "ui://widgets/panel", visibility: %i[model app] }
27+
28+
assert_equal(
29+
{ ui: { resourceUri: "ui://widgets/panel", visibility: [ "model", "app" ] } },
30+
klass.to_h[:_meta]
31+
)
32+
end
33+
34+
test "renders_ui rejects non-ui:// URI" do
35+
assert_raises(ArgumentError) { build_tool { renders_ui "http://widgets/panel" } }
36+
assert_raises(ArgumentError) { build_tool { renders_ui "" } }
37+
end
38+
39+
class StubSession
40+
attr_reader :client_capabilities
41+
42+
def initialize(capabilities)
43+
@client_capabilities = capabilities
44+
end
45+
end
46+
47+
class CapabilityProbe < ActionMCP::Capability
48+
end
49+
50+
test "client_supports_ui? is true when the extension key is present" do
51+
caps = { "extensions" => { "io.modelcontextprotocol/ui" => {} } }
52+
instance = CapabilityProbe.new.with_context(session: StubSession.new(caps))
53+
54+
assert instance.client_supports_ui?
55+
end
56+
57+
test "client_supports_ui? is false when the extension key is absent" do
58+
instance = CapabilityProbe.new.with_context(session: StubSession.new("tools" => {}))
59+
60+
refute instance.client_supports_ui?
61+
end
62+
63+
test "weather tool declares renders_ui pointing at the dashboard" do
64+
assert_equal "ui://weather/dashboard", WeatherTool.to_h.dig(:_meta, :ui, :resourceUri)
65+
end
66+
67+
test "weather dashboard resolve returns HTML content with _meta" do
68+
instance = WeatherDashboardTemplate.new({})
69+
response = instance.call
70+
71+
refute response.error?
72+
content = response.contents.first
73+
assert_equal ActionMCP::MIME_TYPE_APP_HTML, content.mime_type
74+
refute_empty content.text
75+
assert content._meta[:ui][:prefersBorder]
76+
end
77+
end
78+
end
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
# frozen_string_literal: true
2+
3+
class WeatherDashboardTemplate < ApplicationMCPResTemplate
4+
description "Interactive weather dashboard UI for the weather tool"
5+
uri_template "ui://weather/dashboard"
6+
mime_type ActionMCP::MIME_TYPE_APP_HTML
7+
8+
# The spec (ext-apps apps.mdx, Metadata Location section) allows _meta.ui on
9+
# both the listing entry and the content item, with content-level taking
10+
# precedence. We intentionally emit both so the demo exercises each placement
11+
# and the wire shape stays visible to the tests.
12+
meta ui: {
13+
csp: { connectDomains: %w[api.openweathermap.org] },
14+
prefersBorder: true
15+
}
16+
17+
HTML = <<~HTML
18+
<!doctype html>
19+
<html lang="en">
20+
<head>
21+
<meta charset="utf-8">
22+
<title>Weather</title>
23+
<style>
24+
:root { color-scheme: light dark; font-family: system-ui, sans-serif; }
25+
body { margin: 0; padding: 1.25rem; }
26+
.card { max-width: 28rem; padding: 1rem 1.25rem; border-radius: 0.75rem;
27+
background: color-mix(in srgb, currentColor 6%, transparent); }
28+
h1 { margin: 0 0 0.5rem; font-size: 1.25rem; }
29+
.temp { font-size: 3rem; font-weight: 600; line-height: 1; }
30+
.row { display: flex; gap: 1rem; margin-top: 0.75rem; font-size: 0.9rem;
31+
opacity: 0.8; }
32+
.icon { font-size: 2.5rem; }
33+
</style>
34+
</head>
35+
<body>
36+
<div class="card" role="region" aria-label="Weather summary">
37+
<h1 id="loc">Weather</h1>
38+
<div class="icon" aria-hidden="true">\u2600\ufe0f</div>
39+
<div class="temp"><span id="temp">--</span>&deg;</div>
40+
<div class="row">
41+
<div>Humidity <span id="hum">--</span>%</div>
42+
<div>Wind <span id="wind">--</span> km/h</div>
43+
</div>
44+
</div>
45+
<script>
46+
(function () {
47+
function set(id, v) { var el = document.getElementById(id); if (el) el.textContent = v; }
48+
window.addEventListener("message", function (event) {
49+
var data = event.data && event.data.result;
50+
if (!data || !data.current) return;
51+
if (data.metadata && data.metadata.location_found) set("loc", data.metadata.location_found);
52+
set("temp", Math.round(data.current.temperature));
53+
set("hum", data.current.humidity);
54+
set("wind", data.current.wind_speed);
55+
});
56+
}());
57+
</script>
58+
</body>
59+
</html>
60+
HTML
61+
62+
def resolve
63+
ActionMCP::Content::Resource.new(
64+
self.class.uri_template,
65+
ActionMCP::MIME_TYPE_APP_HTML,
66+
text: HTML,
67+
_meta: {
68+
ui: {
69+
csp: { connectDomains: %w[api.openweathermap.org] },
70+
prefersBorder: true
71+
}
72+
}
73+
)
74+
end
75+
end

test/dummy/app/mcp/tools/weather_tool.rb

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ class WeatherTool < ApplicationMCPTool
44
tool_name "weather"
55
description "Get weather information for a location with structured output"
66

7+
renders_ui "ui://weather/dashboard"
8+
79
# Input properties (existing pattern)
810
property :location, type: "string", required: true, description: "City name or coordinates"
911
property :units, type: "string", default: "celsius", enum: [ "celsius", "fahrenheit" ], description: "Temperature units"

0 commit comments

Comments
 (0)