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
2 changes: 2 additions & 0 deletions lib/action_mcp.rb
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ class StructuredContentValidationError < StandardError; end

LATEST_VERSION = SUPPORTED_VERSIONS.first.freeze
DEFAULT_PROTOCOL_VERSION = "2025-06-18" # Default to previous stable version for backwards compatibility

MIME_TYPE_APP_HTML = "text/html;profile=mcp-app" # MCP Apps UI resources (ext-apps, draft 2026-01-26)
class << self
# Returns a Rack-compatible application for serving MCP requests
# @return [#call] A Rack application that can be used with `run ActionMCP.server`
Expand Down
6 changes: 6 additions & 0 deletions lib/action_mcp/capability.rb
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,12 @@ def session

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

# Returns true when the connected client advertises the MCP Apps UI extension.
def client_supports_ui?
extensions = session&.client_capabilities&.dig("extensions")
extensions.is_a?(Hash) && extensions.key?("io.modelcontextprotocol/ui")
end

# use _capability_name or default_capability_name
def self.capability_name
_capability_name || default_capability_name
Expand Down
25 changes: 25 additions & 0 deletions lib/action_mcp/tool.rb
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,31 @@ def meta(data = nil)
end
end

# Declares the UI resource this tool renders. Merges a `ui:` entry into
# `_meta` so the tool listing advertises the dashboard.
#
# @param resource_uri [String] a `ui://` URI
# @param visibility [Array<Symbol, String>, nil] subset of `[:model, :app]`
def renders_ui(resource_uri, visibility: nil)
unless resource_uri.is_a?(String) && resource_uri.match?(%r{\Aui://\S+\z})
raise ArgumentError, "renders_ui requires a ui:// URI, got: #{resource_uri.inspect}"
end

ui_meta = { resourceUri: resource_uri }

if visibility
normalized = Array(visibility).map(&:to_s)
invalid = normalized - %w[model app]
if invalid.any?
raise ArgumentError, "renders_ui visibility must be model and/or app, got: #{visibility.inspect}"
end

ui_meta[:visibility] = normalized
end

self._meta = _meta.merge(ui: ui_meta)
end

# Marks this tool as requiring consent before execution
def requires_consent!
self._requires_consent = true
Expand Down
87 changes: 87 additions & 0 deletions test/action_mcp/mcp_apps_test.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
# frozen_string_literal: true

require "test_helper"

module ActionMCP
class McpAppsTest < ActiveSupport::TestCase
include ActionMCP::TestHelper

test "weather tool declares renders_ui pointing at the dashboard" do
assert_equal({ ui: { resourceUri: "ui://weather/dashboard" } }, WeatherTool.to_h[:_meta])
end

test "renders_ui never emits the deprecated flat ui/resourceUri key" do
meta = WeatherTool.to_h[:_meta]
refute meta.key?("ui/resourceUri")
refute meta.key?(:"ui/resourceUri")
end

test "renders_ui serializes resourceUri and visibility under _meta.ui" do
assert_equal(
{ resourceUri: "ui://demo/panel", visibility: [ "model", "app" ] },
RendersUiDemoTool.to_h.dig(:_meta, :ui)
)
end

test "renders_ui composes with meta on orthogonal keys" do
assert_equal "bar", RendersUiDemoTool.to_h.dig(:_meta, :foo)
end

test "weather dashboard resolves to HTML content with content-level _meta.ui" do
response = resolve_mcp_resource("ui://weather/dashboard")
content = response.contents.first

assert_equal MIME_TYPE_APP_HTML, content.mime_type
refute_empty content.text
assert_equal({ csp: { connectDomains: [ "api.openweathermap.org" ] }, prefersBorder: true },
content.meta[:ui])
end

test "renders_ui rejects non-String URI" do
assert_raises(ArgumentError) { abstract_tool.renders_ui :"ui://widgets/panel" }
assert_raises(ArgumentError) { abstract_tool.renders_ui nil }
end

test "renders_ui rejects non-ui:// URI" do
assert_raises(ArgumentError) { abstract_tool.renders_ui "http://widgets/panel" }
assert_raises(ArgumentError) { abstract_tool.renders_ui "" }
end

test "renders_ui rejects unknown visibility values" do
assert_raises(ArgumentError) do
abstract_tool.renders_ui "ui://widgets/panel", visibility: %i[model agent]
end
end

test "client_supports_ui? is true when the extension key is present" do
assert capability_for(extensions: { "io.modelcontextprotocol/ui" => {} }).client_supports_ui?
end

test "client_supports_ui? is false when the extension key is absent" do
refute capability_for(extensions: { "tools" => {} }).client_supports_ui?
end

test "client_supports_ui? is false when there is no session" do
refute Capability.new.client_supports_ui?
end

test "client_supports_ui? is false when client_capabilities is nil" do
session = Session.new(protocol_version: "2025-06-18")
session.client_capabilities = nil

refute Capability.new.with_context(session: session).client_supports_ui?
end

private

def abstract_tool
Class.new(Tool) { abstract! }
end

def capability_for(extensions:)
session = Session.new(protocol_version: "2025-06-18")
session.client_capabilities = { "extensions" => extensions }
Capability.new.with_context(session: session)
end
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
# frozen_string_literal: true

class WeatherDashboardTemplate < ApplicationMCPResTemplate
description "Interactive weather dashboard UI for the weather tool"
uri_template "ui://weather/dashboard"
mime_type ActionMCP::MIME_TYPE_APP_HTML

HTML = <<~HTML
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Weather</title>
<style>
:root { color-scheme: light dark; font-family: system-ui, sans-serif; }
body { margin: 0; padding: 1.25rem; }
.card { max-width: 28rem; padding: 1rem 1.25rem; border-radius: 0.75rem;
background: color-mix(in srgb, currentColor 6%, transparent); }
h1 { margin: 0 0 0.5rem; font-size: 1.25rem; }
.temp { font-size: 3rem; font-weight: 600; line-height: 1; }
.row { display: flex; gap: 1rem; margin-top: 0.75rem; font-size: 0.9rem;
opacity: 0.8; }
.icon { font-size: 2.5rem; }
</style>
</head>
<body>
<div class="card" role="region" aria-label="Weather summary">
<h1 id="loc">Weather</h1>
<div class="icon" aria-hidden="true">&#x2600;</div>
<div class="temp"><span id="temp">--</span>&deg;</div>
<div class="row">
<div>Humidity <span id="hum">--</span>%</div>
<div>Wind <span id="wind">--</span> km/h</div>
</div>
</div>
<script>
(function () {
function set(id, v) { var el = document.getElementById(id); if (el) el.textContent = v; }
window.addEventListener("message", function (event) {
var data = event.data && event.data.result;
if (!data || !data.current) return;
if (data.metadata && data.metadata.location_found) set("loc", data.metadata.location_found);
set("temp", Math.round(data.current.temperature));
set("hum", data.current.humidity);
set("wind", data.current.wind_speed);
});
}());
</script>
</body>
</html>
HTML

# Per ext-apps apps.mdx, csp/permissions/prefersBorder live on the resource
# content (`resources/read`), not on the listing entry. The class-level
# `meta` macro emits to the listing only, so we set these via the Resource
# constructor instead.
def resolve
ActionMCP::Content::Resource.new(
self.class.uri_template,
ActionMCP::MIME_TYPE_APP_HTML,
text: HTML,
meta: {
ui: {
csp: { connectDomains: %w[api.openweathermap.org] },
prefersBorder: true
}
}
)
end
end
13 changes: 13 additions & 0 deletions test/dummy/app/mcp/tools/renders_ui_demo_tool.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# frozen_string_literal: true

class RendersUiDemoTool < ApplicationMCPTool
tool_name "renders_ui_demo"
description "Demo tool exercising renders_ui with visibility and meta composition"

meta foo: "bar"
renders_ui "ui://demo/panel", visibility: %i[model app]

def perform
render text: "demo"
end
end
2 changes: 2 additions & 0 deletions test/dummy/app/mcp/tools/weather_tool.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ class WeatherTool < ApplicationMCPTool
tool_name "weather"
description "Get weather information for a location with structured output"

renders_ui "ui://weather/dashboard"

# Input properties (existing pattern)
property :location, type: "string", required: true, description: "City name or coordinates"
property :units, type: "string", default: "celsius", enum: [ "celsius", "fahrenheit" ], description: "Temperature units"
Expand Down