Skip to content
Merged
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
16 changes: 10 additions & 6 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ PATH
base64 (~> 0.2)
json-jwt (~> 1.16)
mail (~> 2.8)
mcp (~> 0.14)
mcp (~> 0.25.0)
puma (~> 6.0)
rack (~> 3.0)
securerandom
Expand Down Expand Up @@ -73,6 +73,7 @@ GEM
faraday (>= 1, < 3)
faraday-net_http (3.4.4)
net-http (~> 0.5)
hana (1.3.7)
hashdiff (1.2.1)
i18n (1.14.8)
concurrent-ruby (~> 1.0)
Expand All @@ -85,9 +86,11 @@ GEM
bindata
faraday (~> 2.0)
faraday-follow_redirects
json-schema (6.2.0)
addressable (~> 2.8)
bigdecimal (>= 3.1, < 5)
json_schemer (2.5.0)
bigdecimal
hana (~> 1.3)
regexp_parser (~> 2.0)
simpleidn (~> 0.2)
language_server-protocol (3.17.0.5)
lint_roller (1.1.0)
logger (1.7.0)
Expand All @@ -97,8 +100,8 @@ GEM
net-imap
net-pop
net-smtp
mcp (0.14.0)
json-schema (>= 4.1)
mcp (0.25.0)
json_schemer (>= 2.4)
mini_mime (1.1.5)
minitest (6.0.5)
drb (~> 2.0)
Expand Down Expand Up @@ -182,6 +185,7 @@ GEM
simplecov (~> 0.19)
simplecov-html (0.13.2)
simplecov_json_formatter (0.1.4)
simpleidn (0.2.3)
sinatra (4.2.1)
logger (>= 1.6.0)
mustermann (~> 3.0)
Expand Down
13 changes: 12 additions & 1 deletion lib/mail_mcp/app.rb
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,11 @@ class App < Sinatra::Base
tools: MCP_TOOLS,
server_context: server_context
)
transport = MCP::Server::Transports::StreamableHTTPTransport.new(mcp_server, stateless: true)
transport = MCP::Server::Transports::StreamableHTTPTransport.new(
mcp_server,
stateless: true,
allowed_hosts: allowed_mcp_hosts
)
status_code, resp_headers, body = transport.call(env)
halt status_code, resp_headers, body
end
Expand Down Expand Up @@ -220,6 +224,13 @@ def base_url
ENV.fetch("BASE_URL")
end

# The MCP transport's DNS-rebinding protection only accepts loopback `Host` values
# out of the box. This server is reached at its public hostname, so allow-list it —
# a bare host name matches any port.
def allowed_mcp_hosts
[URI.parse(base_url).host]
end

def decode_client_id!(client_id)
JwtService.decode_client_id(client_id.to_s)
rescue JwtService::Error => e
Expand Down
6 changes: 5 additions & 1 deletion mail_mcp.gemspec
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,11 @@ Gem::Specification.new do |spec|
spec.add_dependency "base64", "~> 0.2"
spec.add_dependency "json-jwt", "~> 1.16"
spec.add_dependency "mail", "~> 2.8"
spec.add_dependency "mcp", "~> 0.14"
# Pinned to a single minor: this gem is installed directly (`gem install mail_mcp`),
# so its Gemfile.lock is bypassed and any mcp release would otherwise be picked up at
# install time. 0.23 added the DNS-rebinding Host check plus the `allowed_hosts:`
# option needed to accept our public hostname; earlier versions reject that option.
spec.add_dependency "mcp", "~> 0.25.0"
spec.add_dependency "puma", "~> 6.0"
spec.add_dependency "rack", "~> 3.0"
spec.add_dependency "securerandom"
Expand Down
31 changes: 30 additions & 1 deletion spec/mail_mcp/app_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -181,11 +181,14 @@ def issue_code
MailMCP::JwtService.issue(creds)
end

let(:mcp_host) { URI.parse(ENV.fetch("BASE_URL")).host }

let(:mcp_headers) do
{
"CONTENT_TYPE" => "application/json",
"HTTP_ACCEPT" => "application/json, text/event-stream",
"HTTP_AUTHORIZATION" => "Bearer #{access_token}"
"HTTP_AUTHORIZATION" => "Bearer #{access_token}",
"HTTP_HOST" => mcp_host
}
end

Expand All @@ -208,6 +211,32 @@ def mcp_request(method, params = {}, id: 1)
])
end

it "dispatches tools/call to the tool using credentials from the bearer token" do
imap_client = instance_spy(MailMCP::ImapClient)
allow(MailMCP::ImapClient).to receive(:connect).and_yield(imap_client)
allow(imap_client).to receive(:list_mailboxes).and_return(%w[INBOX Sent])

post "/mcp", mcp_request("tools/call", { name: "list_mailboxes", arguments: {} }), mcp_headers

expect(last_response.status).to eq(200)
result = JSON.parse(last_response.body).fetch("result")
expect(result["isError"]).to be(false)
expect(result.dig("content", 0, "text")).to include("INBOX", "Sent")
end

it "accepts the public host from BASE_URL, including with an explicit port" do
post "/mcp", mcp_request("tools/list"), mcp_headers.merge("HTTP_HOST" => "#{mcp_host}:8443")

expect(last_response.status).to eq(200)
end

it "rejects a Host header that is not the public host (DNS rebinding)" do
post "/mcp", mcp_request("tools/list"), mcp_headers.merge("HTTP_HOST" => "evil.example.com")

expect(last_response.status).to eq(403)
expect(last_response.body).to include("Invalid Host header")
end

it "returns 401 for an invalid Bearer token" do
post "/mcp", mcp_request("tools/list"),
mcp_headers.merge("HTTP_AUTHORIZATION" => "Bearer invalid.token.here")
Expand Down
Loading