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
18 changes: 17 additions & 1 deletion mcp/lib/ruby_ui/mcp/rack_app.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,29 @@
module RubyUI
module MCP
class RackApp
# StreamableHTTPTransport's DNS-rebinding protection validates the `Host` header
# against an allow list that defaults to loopback only (127.0.0.1, ::1, localhost),
# so without this every request to the production site rejects with
# "Forbidden: Invalid Host header". Extend via RUBY_UI_MCP_ALLOWED_HOSTS
# (comma-separated) for any additional hostnames the site is served from.
DEFAULT_ALLOWED_HOSTS = ["rubyui.com", "www.rubyui.com"].freeze

def self.call(env)
(@instance ||= new).call(env)
end

def self.allowed_hosts
extra = ENV["RUBY_UI_MCP_ALLOWED_HOSTS"].to_s.split(",").map(&:strip).reject(&:empty?)
(DEFAULT_ALLOWED_HOSTS + extra).uniq
end

def initialize(registry: RubyUI::MCP.registry)
server = RubyUI::MCP::Server.build(registry: registry)
@transport = ::MCP::Server::Transports::StreamableHTTPTransport.new(server, stateless: true)
@transport = ::MCP::Server::Transports::StreamableHTTPTransport.new(
server,
stateless: true,
allowed_hosts: self.class.allowed_hosts
)
end

def call(env)
Expand Down
65 changes: 65 additions & 0 deletions mcp/test/rack_app_test.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
# frozen_string_literal: true

require "test_helper"
require "rack/mock_request"
require "ruby_ui/mcp/rack_app"

class RackAppTest < Minitest::Test
def setup
registry = RubyUI::MCP::Registry.load(TestSupport::FIXTURE_PATH)
@app = RubyUI::MCP::RackApp.new(registry: registry)
end

def test_allows_production_host
response = get("rubyui.com")
refute_invalid_host(response)
end

def test_allows_www_production_host
response = get("www.rubyui.com")
refute_invalid_host(response)
end

def test_allows_loopback_host
response = get("localhost")
refute_invalid_host(response)
end

def test_rejects_dns_rebinding_host
response = get("evil.example.com")
assert_equal 403, response.status
assert_includes response.body, "Invalid Host header"
end

def test_default_allowed_hosts_are_exactly_production_hosts
assert_equal ["rubyui.com", "www.rubyui.com"], RubyUI::MCP::RackApp::DEFAULT_ALLOWED_HOSTS
end

def test_allowed_hosts_extends_via_env_var
with_env("RUBY_UI_MCP_ALLOWED_HOSTS" => "staging.rubyui.com, other.example.com") do
assert_equal(
["rubyui.com", "www.rubyui.com", "staging.rubyui.com", "other.example.com"],
RubyUI::MCP::RackApp.allowed_hosts
)
end
end

private

def get(host)
Rack::MockRequest.new(@app).get("/", "HTTP_HOST" => host)
end

def refute_invalid_host(response)
refute_equal 403, response.status
refute_includes response.body, "Invalid Host header"
end

def with_env(vars)
previous = vars.keys.to_h { |k| [k, ENV[k]] }
vars.each { |k, v| ENV[k] = v }
yield
ensure
previous.each { |k, v| ENV[k] = v }
end
end