Skip to content

Commit 5278ca7

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"] anywhere. - Capability#client_supports_ui? instance helper for tools that want to degrade for hosts not advertising the extension. key-presence check on capabilities.extensions["io.modelcontextprotocol/ui"]. - weather dashboard resource template at ui://weather/dashboard with self-contained HTML. csp connectDomains and prefersBorder are emitted on the resource content (resources/read), following the apps.mdx canonical examples. - 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 since Class.new(ActionMCP::Tool) fires the inherited hook before abstract! can run, briefly leaking the unnamed class under the "" key. matches the snapshot/restore pattern in resource_templates_registry_test.rb.
1 parent cc323f7 commit 5278ca7

6 files changed

Lines changed: 221 additions & 0 deletions

File tree

lib/action_mcp.rb

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,8 @@ 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+
MIME_TYPE_APP_HTML = "text/html;profile=mcp-app" # MCP Apps UI resources (ext-apps, draft 2026-01-26)
4446
class << self
4547
# Returns a Rack-compatible application for serving MCP requests
4648
# @return [#call] A Rack application that can be used with `run ActionMCP.server`

lib/action_mcp/capability.rb

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

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

35+
# Returns true when the connected client advertises the MCP Apps UI extension.
36+
def client_supports_ui?
37+
extensions = session&.client_capabilities&.dig("extensions")
38+
extensions.is_a?(Hash) && extensions.key?("io.modelcontextprotocol/ui")
39+
end
40+
3541
# use _capability_name or default_capability_name
3642
def self.capability_name
3743
_capability_name || default_capability_name

lib/action_mcp/tool.rb

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

190+
# Declares the UI resource this tool renders. Merges a `ui:` entry into
191+
# `_meta` so the tool listing advertises the dashboard.
192+
#
193+
# @param resource_uri [String] a `ui://` URI
194+
# @param visibility [Array<Symbol, String>, nil] subset of `[:model, :app]`
195+
def renders_ui(resource_uri, visibility: nil)
196+
unless resource_uri.is_a?(String) && resource_uri.match?(%r{\Aui://\S+\z})
197+
raise ArgumentError, "renders_ui requires a ui:// URI, got: #{resource_uri.inspect}"
198+
end
199+
200+
ui_meta = { resourceUri: resource_uri }
201+
202+
if visibility
203+
normalized = Array(visibility).map(&:to_s)
204+
invalid = normalized - %w[model app]
205+
if invalid.any?
206+
raise ArgumentError, "renders_ui visibility must be model and/or app, got: #{visibility.inspect}"
207+
end
208+
209+
ui_meta[:visibility] = normalized
210+
end
211+
212+
self._meta = _meta.merge(ui: ui_meta)
213+
end
214+
190215
# Marks this tool as requiring consent before execution
191216
def requires_consent!
192217
self._requires_consent = true

test/action_mcp/mcp_apps_test.rb

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
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 omits visibility key when not provided" do
35+
klass = build_tool { renders_ui "ui://widgets/panel" }
36+
37+
assert_equal({ ui: { resourceUri: "ui://widgets/panel" } }, klass.to_h[:_meta])
38+
end
39+
40+
test "renders_ui rejects non-String URI" do
41+
assert_raises(ArgumentError) { build_tool { renders_ui :"ui://widgets/panel" } }
42+
assert_raises(ArgumentError) { build_tool { renders_ui nil } }
43+
end
44+
45+
test "renders_ui rejects non-ui:// URI" do
46+
assert_raises(ArgumentError) { build_tool { renders_ui "http://widgets/panel" } }
47+
assert_raises(ArgumentError) { build_tool { renders_ui "" } }
48+
end
49+
50+
test "renders_ui rejects unknown visibility values" do
51+
assert_raises(ArgumentError) do
52+
build_tool { renders_ui "ui://widgets/panel", visibility: %i[model agent] }
53+
end
54+
end
55+
56+
test "renders_ui composes with meta on orthogonal keys" do
57+
klass = build_tool do
58+
meta foo: "bar"
59+
renders_ui "ui://widgets/panel"
60+
end
61+
62+
meta = klass.to_h[:_meta]
63+
assert_equal "bar", meta[:foo]
64+
assert_equal "ui://widgets/panel", meta[:ui][:resourceUri]
65+
end
66+
67+
class StubSession
68+
attr_reader :client_capabilities
69+
70+
def initialize(capabilities)
71+
@client_capabilities = capabilities
72+
end
73+
end
74+
75+
class CapabilityProbe < ActionMCP::Capability
76+
end
77+
78+
test "client_supports_ui? is true when the extension key is present" do
79+
caps = { "extensions" => { "io.modelcontextprotocol/ui" => {} } }
80+
instance = CapabilityProbe.new.with_context(session: StubSession.new(caps))
81+
82+
assert instance.client_supports_ui?
83+
end
84+
85+
test "client_supports_ui? is false when the extension key is absent" do
86+
instance = CapabilityProbe.new.with_context(session: StubSession.new("tools" => {}))
87+
88+
refute instance.client_supports_ui?
89+
end
90+
91+
test "client_supports_ui? is false when there is no session" do
92+
refute CapabilityProbe.new.client_supports_ui?
93+
end
94+
95+
test "client_supports_ui? is false when client_capabilities is nil" do
96+
instance = CapabilityProbe.new.with_context(session: StubSession.new(nil))
97+
98+
refute instance.client_supports_ui?
99+
end
100+
101+
test "weather tool declares renders_ui pointing at the dashboard" do
102+
assert_equal "ui://weather/dashboard", WeatherTool.to_h.dig(:_meta, :ui, :resourceUri)
103+
end
104+
105+
test "weather dashboard resolve returns HTML content with _meta" do
106+
instance = WeatherDashboardTemplate.new({})
107+
response = instance.call
108+
109+
refute response.error?
110+
content = response.contents.first
111+
assert_equal ActionMCP::MIME_TYPE_APP_HTML, content.mime_type
112+
refute_empty content.text
113+
assert content.meta[:ui][:prefersBorder]
114+
end
115+
end
116+
end
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
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+
HTML = <<~HTML
9+
<!doctype html>
10+
<html lang="en">
11+
<head>
12+
<meta charset="utf-8">
13+
<title>Weather</title>
14+
<style>
15+
:root { color-scheme: light dark; font-family: system-ui, sans-serif; }
16+
body { margin: 0; padding: 1.25rem; }
17+
.card { max-width: 28rem; padding: 1rem 1.25rem; border-radius: 0.75rem;
18+
background: color-mix(in srgb, currentColor 6%, transparent); }
19+
h1 { margin: 0 0 0.5rem; font-size: 1.25rem; }
20+
.temp { font-size: 3rem; font-weight: 600; line-height: 1; }
21+
.row { display: flex; gap: 1rem; margin-top: 0.75rem; font-size: 0.9rem;
22+
opacity: 0.8; }
23+
.icon { font-size: 2.5rem; }
24+
</style>
25+
</head>
26+
<body>
27+
<div class="card" role="region" aria-label="Weather summary">
28+
<h1 id="loc">Weather</h1>
29+
<div class="icon" aria-hidden="true">&#x2600;</div>
30+
<div class="temp"><span id="temp">--</span>&deg;</div>
31+
<div class="row">
32+
<div>Humidity <span id="hum">--</span>%</div>
33+
<div>Wind <span id="wind">--</span> km/h</div>
34+
</div>
35+
</div>
36+
<script>
37+
(function () {
38+
function set(id, v) { var el = document.getElementById(id); if (el) el.textContent = v; }
39+
window.addEventListener("message", function (event) {
40+
var data = event.data && event.data.result;
41+
if (!data || !data.current) return;
42+
if (data.metadata && data.metadata.location_found) set("loc", data.metadata.location_found);
43+
set("temp", Math.round(data.current.temperature));
44+
set("hum", data.current.humidity);
45+
set("wind", data.current.wind_speed);
46+
});
47+
}());
48+
</script>
49+
</body>
50+
</html>
51+
HTML
52+
53+
# Per ext-apps apps.mdx, csp/permissions/prefersBorder live on the resource
54+
# content (`resources/read`), not on the listing entry. The class-level
55+
# `meta` macro emits to the listing only, so we set these via the Resource
56+
# constructor instead.
57+
def resolve
58+
ActionMCP::Content::Resource.new(
59+
self.class.uri_template,
60+
ActionMCP::MIME_TYPE_APP_HTML,
61+
text: HTML,
62+
meta: {
63+
ui: {
64+
csp: { connectDomains: %w[api.openweathermap.org] },
65+
prefersBorder: true
66+
}
67+
}
68+
)
69+
end
70+
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)