From e7d0a352a92c7827bd4430fa779f9edd08bcb659 Mon Sep 17 00:00:00 2001 From: Ali Hamdi Ali Fadel Date: Fri, 10 Jul 2026 12:03:32 +0300 Subject: [PATCH 01/22] feat(mcp): add doorkeeper/mcp gems and McpOauth::CONFIG foundation Lays the OAuth/MCP groundwork: doorkeeper (>= 5.9.1, fixes a public-client revocation bypass) and the official mcp gem for the upcoming remote MCP server. McpOauth::CONFIG is a boot-time, frozen, env-aware source of truth for the issuer/host/resource identifiers so later routing, transport, and RFC 8707 audience checks never derive them from request headers. Also filters :code and :content from logs, since MCP tool calls and the OAuth code/PKCE exchange can leak authorization secrets and full paste bodies. Co-Authored-By: Claude Fable 5 --- Gemfile | 7 ++++ Gemfile.lock | 18 +++++++++ .../initializers/filter_parameter_logging.rb | 7 +++- config/initializers/mcp_oauth.rb | 37 +++++++++++++++++++ test/config/filter_parameter_logging_test.rb | 26 +++++++++++++ test/config/mcp_oauth_test.rb | 30 +++++++++++++++ 6 files changed, 124 insertions(+), 1 deletion(-) create mode 100644 config/initializers/mcp_oauth.rb create mode 100644 test/config/filter_parameter_logging_test.rb create mode 100644 test/config/mcp_oauth_test.rb diff --git a/Gemfile b/Gemfile index b944d5c..233f517 100644 --- a/Gemfile +++ b/Gemfile @@ -74,6 +74,13 @@ gem "meta-tags", "~> 2.23" # locales -- e.g. Arabic messages for blank / too_long / taken. gem "rails-i18n", "~> 8.0" +# OAuth 2.1 authorization server for the MCP server. 5.9.1+ fixes a +# public-client revocation bypass. +gem "doorkeeper", ">= 5.9.1" + +# Official MCP Ruby SDK, used to implement the remote MCP server. +gem "mcp", "~> 0.23" + # Trust Cloudflare's IP ranges so remote_ip (and the per-IP rate limits) see # real client addresses behind the CF proxy. Production-only: the gem fetches # the ranges over the network, which dev and test have no use for. diff --git a/Gemfile.lock b/Gemfile.lock index 5d9f14e..bc45428 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -120,6 +120,8 @@ GEM debug (1.11.1) irb (~> 1.10) reline (>= 0.3.8) + doorkeeper (5.9.3) + railties (>= 5) dotenv (3.2.0) drb (2.2.3) ed25519 (1.4.0) @@ -132,6 +134,7 @@ GEM raabro (~> 1.4) globalid (1.4.0) activesupport (>= 6.1) + hana (1.3.7) i18n (1.15.2) concurrent-ruby (~> 1.0) io-console (0.8.2) @@ -143,6 +146,11 @@ GEM jsbundling-rails (1.3.1) railties (>= 6.0.0) json (2.20.0) + json_schemer (2.5.0) + bigdecimal + hana (~> 1.3) + regexp_parser (~> 2.0) + simpleidn (~> 0.2) kamal (2.12.0) activesupport (>= 7.0) base64 (~> 0.2) @@ -168,6 +176,8 @@ GEM net-smtp marcel (1.2.1) matrix (0.4.3) + mcp (0.23.0) + json_schemer (>= 2.4) meta-tags (2.23.0) actionpack (>= 6.0.0) mini_mime (1.1.5) @@ -325,6 +335,7 @@ GEM rexml (~> 3.2, >= 3.2.5) rubyzip (>= 1.2.2, < 4.0) websocket (~> 1.0) + simpleidn (0.2.3) solid_cache (1.0.10) activejob (>= 7.2) activerecord (>= 7.2) @@ -397,8 +408,10 @@ DEPENDENCIES commonmarker (~> 2.0) cssbundling-rails debug + doorkeeper (>= 5.9.1) jsbundling-rails kamal + mcp (~> 0.23) meta-tags (~> 2.23) pg (~> 1.1) propshaft @@ -455,6 +468,7 @@ CHECKSUMS cssbundling-rails (1.4.3) sha256=53aecd5a7d24ac9c8fcd92975acd0e830fead4ee4583d3d3d49bb64651946e41 date (3.5.1) sha256=750d06384d7b9c15d562c76291407d89e368dda4d4fff957eb94962d325a0dc0 debug (1.11.1) sha256=2e0b0ac6119f2207a6f8ac7d4a73ca8eb4e440f64da0a3136c30343146e952b6 + doorkeeper (5.9.3) sha256=e6d120235bd134494bd02d08e8063c994fc1a58a681285077e671529bfc5d90b dotenv (3.2.0) sha256=e375b83121ea7ca4ce20f214740076129ab8514cd81378161f11c03853fe619d drb (2.2.3) sha256=0b00d6fdb50995fe4a45dea13663493c841112e4068656854646f418fda13373 ed25519 (1.4.0) sha256=16e97f5198689a154247169f3453ef4cfd3f7a47481fde0ae33206cdfdcac506 @@ -463,11 +477,13 @@ CHECKSUMS et-orbi (1.4.0) sha256=6c7e3c90779821f9e3b324c5e96fda9767f72995d6ae435b96678a4f3e2de8bc fugit (1.12.2) sha256=643f2bf28db263bd400cbf8e0dd8b76b2c9b94bdb130e12d2394de04d9c20e5e globalid (1.4.0) sha256=037f12fbf1d9d7a014d501c2d5c77356fd4ddd96d7a7991d6700bba96706f427 + hana (1.3.7) sha256=5425db42d651fea08859811c29d20446f16af196308162894db208cac5ce9b0d i18n (1.15.2) sha256=00f9eb62412fe593b2a65a97daa75300d37abb8f7202ec748e94b6d46a9dd1b5 io-console (0.8.2) sha256=d6e3ae7a7cc7574f4b8893b4fca2162e57a825b223a177b7afa236c5ef9814cc irb (1.18.0) sha256=de9454a0703a54704b9811a5ef31a60c86949fbf4013fcf244fabc7c775248e3 jsbundling-rails (1.3.1) sha256=0fa03f6d051c694cbf55a022d8be53399879f2c4cf38b2968f86379c62b1c2ca json (2.20.0) sha256=9362bc6e55a952b056abf9167cf053358181c904cb70cd6eee0808ea830fc32b + json_schemer (2.5.0) sha256=2f01fb4cce721a4e08dd068fc2030cffd0702a7f333f1ea2be6e8991f00ae396 kamal (2.12.0) sha256=c51d1ab085e515470f98d0c0f043637122b5ebf76e8b610cb1fbbed0b7f9b8fa language_server-protocol (3.17.0.5) sha256=fd1e39a51a28bf3eec959379985a72e296e9f9acfce46f6a79d31ca8760803cc lint_roller (1.1.0) sha256=2c0c845b632a7d172cb849cc90c1bce937a28c5c8ccccb50dfd46a485003cc87 @@ -476,6 +492,7 @@ CHECKSUMS mail (2.9.0) sha256=6fa6673ecd71c60c2d996260f9ee3dd387d4673b8169b502134659ece6d34941 marcel (1.2.1) sha256=1678e9360e32f9eafa917c80029e2f6d10b2715c66a4b87b6d0da9b9cd1f859f matrix (0.4.3) sha256=a0d5ab7ddcc1973ff690ab361b67f359acbb16958d1dc072b8b956a286564c5b + mcp (0.23.0) sha256=3667ee167384778cd81721799dc8060f4a792f63b23ba0804005afd29d7259bc meta-tags (2.23.0) sha256=ffe78b5bee398de4ff5ac3316f5a786049538a651643b8476def06c3acc762c1 mini_mime (1.1.5) sha256=8681b7e2e4215f2a159f9400b5816d85e9d8c6c6b491e96a12797e798f8bccef minitest (6.0.6) sha256=153ea36d1d987a62942382b61075745042a2b3123b1cd48f4c3675af9cc7d6f1 @@ -539,6 +556,7 @@ CHECKSUMS rubyzip (3.4.0) sha256=6de39bc9eba302b635a476d16c9e16b0872ad24517c2f98f2b3a7ea23caff57b securerandom (0.4.1) sha256=cc5193d414a4341b6e225f0cb4446aceca8e50d5e1888743fac16987638ea0b1 selenium-webdriver (4.45.0) sha256=ecac65a4df86ac6f7d707e6dcbacaa9c08b6cf2b966babecfb9653c5aa13e2d1 + simpleidn (0.2.3) sha256=08ce96f03fa1605286be22651ba0fc9c0b2d6272c9b27a260bc88be05b0d2c29 solid_cache (1.0.10) sha256=bc05a2fb3ac78a6f43cbb5946679cf9db67dd30d22939ededc385cb93e120d41 solid_queue (1.4.0) sha256=e6a18d196f0b27cb6e3c77c5b31258b05fb634f8ed64fb1866ed164047216c2a sshkit (1.25.0) sha256=c8c6543cdb60f91f1d277306d585dd11b6a064cb44eab0972827e4311ff96744 diff --git a/config/initializers/filter_parameter_logging.rb b/config/initializers/filter_parameter_logging.rb index c0b717f..2ee84d5 100644 --- a/config/initializers/filter_parameter_logging.rb +++ b/config/initializers/filter_parameter_logging.rb @@ -4,5 +4,10 @@ # Use this to limit dissemination of sensitive information. # See the ActiveSupport::ParameterFilter documentation for supported notations and behaviors. Rails.application.config.filter_parameters += [ - :passw, :email, :secret, :token, :_key, :crypt, :salt, :certificate, :otp, :ssn, :cvv, :cvc + :passw, :email, :secret, :token, :_key, :crypt, :salt, :certificate, :otp, :ssn, :cvv, :cvc, + # OAuth authorization codes and PKCE code_verifier/code_challenge (partial match on :code). + :code, + # MCP JSON-RPC tool calls carry the paste body as `content` -- up to 2 MB and + # possibly password-protected, so keep it out of the logs. + :content ] diff --git a/config/initializers/mcp_oauth.rb b/config/initializers/mcp_oauth.rb new file mode 100644 index 0000000..c5d0fc3 --- /dev/null +++ b/config/initializers/mcp_oauth.rb @@ -0,0 +1,37 @@ +# Be sure to restart your server when you modify this file. +# +# Canonical, trusted configuration for the MCP OAuth authorization/resource +# server. This is intentionally boot-time and env-aware -- NEVER derive these +# values from request headers (Host, X-Forwarded-*, etc.), since that would +# let an attacker forge the issuer/resource identity used in token audience +# checks (RFC 8707) and discovery documents. +# +# Routes (host constraints), the MCP transport (allowed_hosts), discovery +# JSON documents, and audience validation all read from McpOauth::CONFIG, so +# its shape is load-bearing -- don't change the keys without updating those. +module McpOauth + default_issuer = + case Rails.env + when "production" + "https://pastehtml.dev" + when "test" + # Matches Rails' integration-test default host so route constraints + # keyed on CONFIG[:host] work in tests. + "http://www.example.com" + else + "http://localhost:3000" + end + + issuer = (ENV["MCP_OAUTH_ISSUER"].presence || default_issuer).freeze + + default_host = URI(issuer).host + + host = (ENV["MCP_OAUTH_HOST"].presence || default_host).freeze + + CONFIG = { + issuer: issuer, + resource_uri: "#{issuer}/mcp".freeze, + host: host, + protected_resource_metadata_url: "#{issuer}/.well-known/oauth-protected-resource".freeze + }.freeze +end diff --git a/test/config/filter_parameter_logging_test.rb b/test/config/filter_parameter_logging_test.rb new file mode 100644 index 0000000..610b53e --- /dev/null +++ b/test/config/filter_parameter_logging_test.rb @@ -0,0 +1,26 @@ +require "test_helper" + +class FilterParameterLoggingTest < ActiveSupport::TestCase + test "filters OAuth codes and MCP tool-call content" do + # Rails' `config.precompile_filter_parameters` (on by default) rewrites + # config.filter_parameters in place into compiled Regexp objects the + # first time any request is dispatched, so depending on test order the + # raw :code / :content symbols may already be folded into a Regexp by + # the time this runs. Compare against the stringified form instead of + # asserting on the raw array so this doesn't depend on that timing. + described = Rails.application.config.filter_parameters.map(&:to_s).join("|") + + assert_match(/code/, described) + assert_match(/content/, described) + end + + test "actually redacts code, code_verifier, and content values" do + filter = ActiveSupport::ParameterFilter.new(Rails.application.config.filter_parameters) + + filtered = filter.filter(code: "x", code_verifier: "y", content: "z") + + assert_equal ActiveSupport::ParameterFilter::FILTERED, filtered[:code] + assert_equal ActiveSupport::ParameterFilter::FILTERED, filtered[:code_verifier] + assert_equal ActiveSupport::ParameterFilter::FILTERED, filtered[:content] + end +end diff --git a/test/config/mcp_oauth_test.rb b/test/config/mcp_oauth_test.rb new file mode 100644 index 0000000..18e6ea5 --- /dev/null +++ b/test/config/mcp_oauth_test.rb @@ -0,0 +1,30 @@ +require "test_helper" + +class McpOauthTest < ActiveSupport::TestCase + test "CONFIG is a frozen hash with frozen string values" do + assert_predicate McpOauth::CONFIG, :frozen? + + McpOauth::CONFIG.each_value do |value| + assert_predicate value, :frozen? + end + end + + test "CONFIG has exactly the four expected keys" do + assert_equal %i[issuer resource_uri host protected_resource_metadata_url].sort, McpOauth::CONFIG.keys.sort + end + + test "CONFIG uses the www.example.com defaults in the test environment" do + assert_equal "http://www.example.com", McpOauth::CONFIG[:issuer] + assert_equal "www.example.com", McpOauth::CONFIG[:host] + end + + test "resource_uri is derived from the issuer" do + assert McpOauth::CONFIG[:resource_uri].start_with?(McpOauth::CONFIG[:issuer]) + assert McpOauth::CONFIG[:resource_uri].end_with?("/mcp") + end + + test "protected_resource_metadata_url is derived from the issuer" do + assert McpOauth::CONFIG[:protected_resource_metadata_url].start_with?(McpOauth::CONFIG[:issuer]) + assert_equal "#{McpOauth::CONFIG[:issuer]}/.well-known/oauth-protected-resource", McpOauth::CONFIG[:protected_resource_metadata_url] + end +end From b946ee5baadd5fa6e8c2ee790ddc72b11a7501f0 Mon Sep 17 00:00:00 2001 From: Ali Hamdi Ali Fadel Date: Fri, 10 Jul 2026 12:28:16 +0300 Subject: [PATCH 02/22] feat(oauth): add Doorkeeper authorization server with RFC 8707 resource binding The MCP OAuth core (plan section 6.2/6.6): Doorkeeper 5.9.3 configured as an authorization-code + PKCE(S256)-only server for public clients, apex-host routed, with hand-rolled RFC 8707 resource-indicator enforcement. - Migration: NULLable application secret (public clients), `dynamic` DCR flag, PKCE columns, `resource` on grants AND tokens, `last_used_at`; the `previous_refresh_token` column is deliberately absent so Doorkeeper's feature detection gives immediate refresh rotation (no grace window). - Initializer: session-cookie resource_owner_authenticator that resumes the authorize URL via session[:return_to_after_authenticating]; hashed token secrets; header-only bearer tokens; mcp:read default / mcp:write optional scopes; custom_access_token_attributes carries `resource` through the grant -> token -> refresh chain. - Oauth::AuthorizationsController / Oauth::TokensController subclasses require exactly one `resource` parameter (raw query/body parse catches repeats Rails params would collapse), compare scheme/host case-insensitively with a byte-exact path, persist only the canonical McpOauth::CONFIG[:resource_uri], and reject with RFC 8707 invalid_target. - Consent screen in the app layout: hidden `resource` round-trip, scope labels (EN + AR), and "unverified dynamically registered client" + redirect-host labeling for DCR-minted clients. - Routes: use_doorkeeper under the apex-host constraint, applications controller skipped, authorized_applications kept for the later connected-agents screen. Co-Authored-By: Claude Fable 5 --- .../oauth/resource_indicator_enforcement.rb | 62 +++++ .../oauth/authorizations_controller.rb | 32 +++ app/controllers/oauth/tokens_controller.rb | 19 ++ .../_authorization_params.html.erb | 13 + .../doorkeeper/authorizations/error.html.erb | 14 + .../doorkeeper/authorizations/new.html.erb | 65 +++++ config/initializers/doorkeeper.rb | 62 +++++ config/locales/ar.yml | 24 ++ config/locales/en.yml | 27 ++ config/routes.rb | 17 ++ ...20260710091356_create_doorkeeper_tables.rb | 98 +++++++ db/schema.rb | 53 +++- test/fixtures/oauth_applications.yml | 24 ++ .../oauth_authorization_flow_test.rb | 241 ++++++++++++++++++ .../oauth_resource_indicator_test.rb | 208 +++++++++++++++ 15 files changed, 958 insertions(+), 1 deletion(-) create mode 100644 app/controllers/concerns/oauth/resource_indicator_enforcement.rb create mode 100644 app/controllers/oauth/authorizations_controller.rb create mode 100644 app/controllers/oauth/tokens_controller.rb create mode 100644 app/views/doorkeeper/authorizations/_authorization_params.html.erb create mode 100644 app/views/doorkeeper/authorizations/error.html.erb create mode 100644 app/views/doorkeeper/authorizations/new.html.erb create mode 100644 config/initializers/doorkeeper.rb create mode 100644 db/migrate/20260710091356_create_doorkeeper_tables.rb create mode 100644 test/fixtures/oauth_applications.yml create mode 100644 test/integration/oauth_authorization_flow_test.rb create mode 100644 test/integration/oauth_resource_indicator_test.rb diff --git a/app/controllers/concerns/oauth/resource_indicator_enforcement.rb b/app/controllers/concerns/oauth/resource_indicator_enforcement.rb new file mode 100644 index 0000000..cdc05c2 --- /dev/null +++ b/app/controllers/concerns/oauth/resource_indicator_enforcement.rb @@ -0,0 +1,62 @@ +# Hand-rolled RFC 8707 (Resource Indicators) enforcement -- Doorkeeper has no +# native support. The MCP spec requires audience-bound tokens, and this +# authorization server exists solely for the MCP endpoint, so both OAuth +# endpoints demand EXACTLY ONE `resource` parameter naming it: +# +# - Missing, repeated, or array-style `resource` values are rejected with the +# RFC 8707 `invalid_target` error (each controller renders its own shape). +# - The comparison follows RFC 3986 semantics: scheme and host are +# case-insensitive, the path is byte-exact. `HTTPS://HOST/mcp` passes, +# `/MCP` fails. +# - Callers persist only the canonical McpOauth::CONFIG[:resource_uri] -- +# never the client's spelling -- so the /mcp audience check can compare +# byte-exactly against stored values. +module Oauth + module ResourceIndicatorEnforcement + private + def enforce_resource_indicator + supplied = raw_resource_values + return if supplied.length == 1 && canonical_resource?(supplied.first) + + reject_invalid_target + end + + # Rails params collapse repeated keys (`resource=a&resource=b` becomes + # just "b"), which would let a doubled parameter slip through looking + # valid. Rack::Utils.parse_query keeps repeats as arrays, so parse the + # raw query string and form body instead. `resource[]=...` array params + # arrive under the raw key "resource[]" and therefore count as zero + # `resource` values, rejecting that shape too. + def raw_resource_values + [ request.query_string, url_encoded_body ].flat_map do |raw| + Array(Rack::Utils.parse_query(raw.to_s)["resource"]) + end + end + + def url_encoded_body + # OAuth requests are application/x-www-form-urlencoded (RFC 6749); + # anything else (e.g. multipart) contributes no resource values and + # fails the exactly-one requirement. + return "" unless request.media_type == "application/x-www-form-urlencoded" + + # raw_post caches the body and rewinds the input, so the params + # parsing that Doorkeeper relies on still sees the full body. + request.raw_post + end + + def canonical_resource?(value) + supplied = URI.parse(value.to_s) + canonical = URI.parse(McpOauth::CONFIG[:resource_uri]) + + supplied.scheme.to_s.casecmp?(canonical.scheme) && + supplied.host.to_s.casecmp?(canonical.host) && + supplied.port == canonical.port && + supplied.path == canonical.path && + supplied.query.nil? && + supplied.fragment.nil? && + supplied.userinfo.nil? + rescue URI::InvalidURIError + false + end + end +end diff --git a/app/controllers/oauth/authorizations_controller.rb b/app/controllers/oauth/authorizations_controller.rb new file mode 100644 index 0000000..b50aa44 --- /dev/null +++ b/app/controllers/oauth/authorizations_controller.rb @@ -0,0 +1,32 @@ +# Doorkeeper's authorization endpoint plus mandatory RFC 8707 resource +# binding (wired up in config/routes.rb through use_doorkeeper's controllers +# mapping). Authentication comes first: the ApplicationController base (see +# Doorkeeper's base_controller config) redirects signed-out users to sign-in +# and resumes the full authorize URL afterwards, so resource validation only +# ever runs for signed-in users. +class Oauth::AuthorizationsController < Doorkeeper::AuthorizationsController + include Oauth::ResourceIndicatorEnforcement + + # Implicit layout lookup would otherwise stop at the gem's bundled + # layouts/doorkeeper/application before ever reaching the app's layout. + layout "application" + + before_action :enforce_resource_indicator, only: %i[new create] + + private + # Feeds Doorkeeper's PreAuthorization the CANONICAL resource spelling in + # place of the client's (already validated, case-insensitively equal) + # value, so the grant -- and every token derived from it -- stores exactly + # McpOauth::CONFIG[:resource_uri] and the /mcp audience check can compare + # byte-exactly. + def pre_auth_params + super.merge(resource: McpOauth::CONFIG[:resource_uri]) + end + + # Doorkeeper's convention for non-redirectable authorization errors + # (handle_auth_errors :render, the default): render the error page. + def reject_invalid_target + error_response = Doorkeeper::OAuth::ErrorResponse.new(name: :invalid_target, state: params[:state]) + render :error, locals: { error_response: error_response }, status: error_response.status + end +end diff --git a/app/controllers/oauth/tokens_controller.rb b/app/controllers/oauth/tokens_controller.rb new file mode 100644 index 0000000..d2272bf --- /dev/null +++ b/app/controllers/oauth/tokens_controller.rb @@ -0,0 +1,19 @@ +# Doorkeeper's token endpoint plus mandatory RFC 8707 resource validation for +# both grant types it serves (authorization_code and refresh_token). No +# canonicalization is needed here: the access token's `resource` is copied +# from the grant (or from the rotated-out token on refresh), which already +# stores the canonical value -- see Oauth::AuthorizationsController. +class Oauth::TokensController < Doorkeeper::TokensController + include Oauth::ResourceIndicatorEnforcement + + before_action :enforce_resource_indicator, only: :create + + private + # Standard OAuth token-endpoint error: 400 JSON with the RFC 8707 + # invalid_target error code. + def reject_invalid_target + error_response = Doorkeeper::OAuth::ErrorResponse.new(name: :invalid_target) + headers.merge!(error_response.headers) + render json: error_response.body, status: error_response.status + end +end diff --git a/app/views/doorkeeper/authorizations/_authorization_params.html.erb b/app/views/doorkeeper/authorizations/_authorization_params.html.erb new file mode 100644 index 0000000..549aadd --- /dev/null +++ b/app/views/doorkeeper/authorizations/_authorization_params.html.erb @@ -0,0 +1,13 @@ +<%# The pre-auth parameters the approve/deny POST must round-trip. `resource` + is the hand-rolled RFC 8707 addition; it always carries the canonical + resource URI (validation upstream guarantees the client asked for exactly + this resource, modulo scheme/host case). %> +<%= hidden_field_tag :client_id, @pre_auth.client.uid, id: nil %> +<%= hidden_field_tag :redirect_uri, @pre_auth.redirect_uri, id: nil %> +<%= hidden_field_tag :state, @pre_auth.state, id: nil %> +<%= hidden_field_tag :response_type, @pre_auth.response_type, id: nil %> +<%= hidden_field_tag :response_mode, @pre_auth.response_mode, id: nil %> +<%= hidden_field_tag :scope, @pre_auth.scope, id: nil %> +<%= hidden_field_tag :code_challenge, @pre_auth.code_challenge, id: nil %> +<%= hidden_field_tag :code_challenge_method, @pre_auth.code_challenge_method, id: nil %> +<%= hidden_field_tag :resource, McpOauth::CONFIG[:resource_uri], id: nil %> diff --git a/app/views/doorkeeper/authorizations/error.html.erb b/app/views/doorkeeper/authorizations/error.html.erb new file mode 100644 index 0000000..a720441 --- /dev/null +++ b/app/views/doorkeeper/authorizations/error.html.erb @@ -0,0 +1,14 @@ +<% set_meta_tags title: t(".title"), noindex: true %> + +
+

+ <%= t(".eyebrow") %> +

+

<%= t(".title") %>

+ +
+

+ <%= error_response.body[:error_description] %> +

+
+
diff --git a/app/views/doorkeeper/authorizations/new.html.erb b/app/views/doorkeeper/authorizations/new.html.erb new file mode 100644 index 0000000..755740f --- /dev/null +++ b/app/views/doorkeeper/authorizations/new.html.erb @@ -0,0 +1,65 @@ +<% set_meta_tags title: t(".title"), noindex: true %> + +
+

+ <%= t(".eyebrow") %> +

+

<%= t(".heading") %>

+ +
+ <% if @pre_auth.client.application.dynamic? %> + <%# DCR-minted clients pick their own name -- anyone can register as + "Codex". Lead with the unverified warning and the redirect host (the + one client-controlled value the flow actually verifies), and present + the self-asserted name only as such. %> +
+

+ <%= t(".unverified_client") %> +

+

+ <%= t(".redirect_host_html", host: tag.code(URI.parse(@pre_auth.redirect_uri).host, dir: "ltr", class: "font-mono font-semibold text-hero-blue")) %> +

+

+ <%= t(".self_asserted_name", name: @pre_auth.client.name) %> +

+
+ <% else %> +

+ <%= t(".prompt_html", client_name: tag.strong(@pre_auth.client.name)) %> +

+ <% end %> + + <% if @pre_auth.scopes.count > 0 %> +
+

<%= t(".able_to") %>

+
    + <% @pre_auth.scopes.each do |scope| %> +
  • + + <%= t(scope, scope: [:doorkeeper, :scopes]) %> +
  • + <% end %> +
+
+ <% end %> + +
+ <%# Both forms re-submit the whole pre-auth parameter set: the approve + POST is a fresh request, so anything missing here is missing from + the grant. That includes the hidden `resource` field -- always the + CANONICAL McpOauth::CONFIG[:resource_uri], never the client's + spelling -- which Doorkeeper's stock form would silently drop + (RFC 8707 round-trip). %> + <%= form_tag oauth_authorization_path, method: :post do %> + <%= render "authorization_params" %> + <%= submit_tag t(".authorize"), + class: "w-full rounded-md border-3 border-ink bg-hero-red px-4 py-3 font-display text-2xl tracking-wide text-white shadow-comic-sm hover:bg-hero-red/90 t-press" %> + <% end %> + <%= form_tag oauth_authorization_path, method: :delete do %> + <%= render "authorization_params" %> + <%= submit_tag t(".deny"), + class: "w-full rounded-md border-2 border-ink bg-white px-4 py-2 font-display text-xl tracking-wide text-ink shadow-comic-sm hover:bg-paper t-press" %> + <% end %> +
+
+
diff --git a/config/initializers/doorkeeper.rb b/config/initializers/doorkeeper.rb new file mode 100644 index 0000000..ac71c6c --- /dev/null +++ b/config/initializers/doorkeeper.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true + +# Doorkeeper is the OAuth 2.1-shaped authorization server for the MCP +# endpoint (see the MCP OAuth plan). It exists solely so MCP clients +# (Claude Code, Codex CLI, ...) can act as the signed-in user; every client is +# a PUBLIC client: PKCE S256 is forced and no client secret ever exists. +# +# RFC 8707 resource binding is hand-rolled on top (Doorkeeper has no native +# support): Oauth::AuthorizationsController / Oauth::TokensController require +# a single, canonical-matching `resource` parameter, and the +# custom_access_token_attributes option below persists the canonical value +# through the grant -> access token -> refresh chain. +Doorkeeper.configure do + orm :active_record + + resource_owner_authenticator do + Session.find_by(id: cookies.signed[Authentication::AUTH_COOKIE_NAME])&.user || begin + # Mirror Authentication#request_authentication: SessionsController#create + # resumes ONLY via session[:return_to_after_authenticating] -- a return_to + # query param would be silently ignored and the OAuth flow would die on + # the dashboard after login. start_new_session_for already carries this + # key across its reset_session call. + session[:return_to_after_authenticating] = request.fullpath + redirect_to(new_session_path) + end + end + + # Inherit the app's ApplicationController so the consent screen renders in + # the app layout with its helpers, locale switching, and the Authentication + # concern (whose require_authentication redirect makes login-resume work + # before the resource_owner_authenticator fallback is ever reached). + base_controller "ApplicationController" + + # Authorization code is the only first-class flow; use_refresh_token adds + # the refresh_token grant to the token endpoint. + grant_flows %w[authorization_code] + use_refresh_token + + # The MCP spec (2025-11-25) requires PKCE with S256 -- clients abort if + # "plain" is all the server advertises. + force_pkce + pkce_code_challenge_methods [ "S256" ] + + # Access/refresh tokens are digested at rest, matching the pht_ API keys' + # posture -- a leaked database dump yields no usable bearer tokens. + hash_token_secrets + + # Header-only bearer tokens. The default ALSO accepts access_token / + # bearer_token request params, which the MCP spec forbids. + access_token_methods :from_bearer_authorization + + default_scopes :"mcp:read" + optional_scopes :"mcp:write" + + access_token_expires_in 1.hour + + # Persists the RFC 8707 `resource` indicator: PreAuthorization slices it + # from the (already canonicalized) authorize params onto the grant, the + # token endpoint copies it from the grant onto the access token, and the + # refresh grant copies it from the rotated-out token onto its replacement. + custom_access_token_attributes [ :resource ] +end diff --git a/config/locales/ar.yml b/config/locales/ar.yml index 550e643..ad14b67 100644 --- a/config/locales/ar.yml +++ b/config/locales/ar.yml @@ -299,3 +299,27 @@ ar: folder: requires_user: يتطلب مالكًا مسجل الدخول invalid: يجب أن يكون مملوكًا لك + + # شاشة موافقة OAuth وأخطاؤها لخادم تفويض MCP — بالتوازي مع en.yml. + doorkeeper: + scopes: + "mcp:read": قراءة صفحاتك ومجلداتك + "mcp:write": نشر صفحاتك ومجلداتك وتحديثها + authorizations: + new: + title: تفويض الوصول + eyebrow: طلب اتصال + heading: هل تفوّض الوصول؟ + prompt_html: يريد %{client_name} الاتصال بحسابك على pastehtml.dev. + unverified_client: عميل مسجَّل ديناميكيًا غير موثَّق + redirect_host_html: سيستلم نتيجة تسجيل الدخول على %{host}. + self_asserted_name: يسمّي نفسه «%{name}» — هذا الاسم من ادعائه ولم يُتحقق منه. + able_to: "سيتمكن من:" + authorize: تفويض + deny: رفض + error: + title: فشل التفويض + eyebrow: خطأ OAuth + errors: + messages: + invalid_target: يجب أن يحدد معامل resource نقطة نهاية MCP لهذا الخادم مرة واحدة بالضبط. diff --git a/config/locales/en.yml b/config/locales/en.yml index 5e4bb77..bd02041 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -298,3 +298,30 @@ en: folder: requires_user: "requires a signed-in owner" invalid: "must belong to you" + + # OAuth consent screen + errors for the MCP authorization server. Doorkeeper + # ships its own English catalog for everything else (config/locales/en.yml in + # the gem); these keys are the app-authored subset the custom views and the + # hand-rolled RFC 8707 invalid_target error use. + doorkeeper: + scopes: + "mcp:read": "Read your pastes and folders" + "mcp:write": "Publish and update your pastes and folders" + authorizations: + new: + title: "Authorize access" + eyebrow: "Connection request" + heading: "Authorize access?" + prompt_html: "%{client_name} wants to connect to your pastehtml.dev account." + unverified_client: "Unverified dynamically registered client" + redirect_host_html: "It will receive the sign-in result at %{host}." + self_asserted_name: "It calls itself “%{name}” — that name is self-asserted, not verified." + able_to: "It will be able to:" + authorize: "Authorize" + deny: "Deny" + error: + title: "Authorization failed" + eyebrow: "OAuth error" + errors: + messages: + invalid_target: "The resource parameter must name the MCP endpoint of this server, exactly once." diff --git a/config/routes.rb b/config/routes.rb index 587b008..4fc7ae5 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -63,6 +63,23 @@ resources :folders, only: %i[ index create ] resources :pastes, only: %i[ create update ], param: :token end + + # OAuth authorization server for the MCP endpoint -- apex host ONLY (not + # merely "any non-paste host"), so issuer, audience, and cookies collapse + # to one canonical origin (prod: pastehtml.dev, dev: localhost, test: + # www.example.com). The custom controllers layer mandatory RFC 8707 + # resource-indicator enforcement onto Doorkeeper's stock endpoints. + # :applications is skipped (clients arrive via dynamic registration, not + # an admin UI); :authorized_applications stays -- it's the "connected + # agents" screen a later task links up and restyles. /mcp itself, the + # well-known discovery documents, and /oauth/register are later tasks. + constraints host: McpOauth::CONFIG[:host] do + use_doorkeeper scope: "oauth" do + controllers authorizations: "oauth/authorizations", + tokens: "oauth/tokens" + skip_controllers :applications + end + end get "p/:token", to: "pastes#show", as: :paste get "p/:token/password", to: "paste_passwords#new", as: :paste_password post "p/:token/password", to: "paste_passwords#create" diff --git a/db/migrate/20260710091356_create_doorkeeper_tables.rb b/db/migrate/20260710091356_create_doorkeeper_tables.rb new file mode 100644 index 0000000..137d7fa --- /dev/null +++ b/db/migrate/20260710091356_create_doorkeeper_tables.rb @@ -0,0 +1,98 @@ +# frozen_string_literal: true + +# Doorkeeper tables for the MCP OAuth authorization server, edited from the +# stock generator output for this app's decisions (see the MCP OAuth plan): +# +# - oauth_applications.secret is NULLable: every MCP client is a public +# (non-confidential) client and never receives a secret. +# - oauth_applications.dynamic marks applications minted through RFC 7591 +# Dynamic Client Registration (a later task adds the endpoint); it drives +# the "unverified client" consent labeling and inactivity cleanup. +# - oauth_access_grants carries the opt-in PKCE columns (code_challenge, +# code_challenge_method) -- S256 is mandatory for this server. +# - resource on grants AND tokens stores the canonical RFC 8707 resource +# indicator, persisted through the whole grant -> token -> refresh chain. +# - previous_refresh_token is deliberately ABSENT from oauth_access_tokens: +# Doorkeeper feature-detects that column (AccessToken.refresh_token_revoked_on_use?) +# and its absence is what makes refresh rotation immediate, with no +# grace window for the rotated-out token. +# - last_used_at supports inactivity-based cleanup (a later task bumps it, +# throttled, from the MCP endpoint). +class CreateDoorkeeperTables < ActiveRecord::Migration[8.1] + def change + create_table :oauth_applications do |t| + t.string :name, null: false + t.string :uid, null: false + # NULLable on purpose: public clients never get a secret. + t.string :secret, null: true + + t.text :redirect_uri, null: false + t.string :scopes, null: false, default: "" + t.boolean :confidential, null: false, default: true + # True for clients created via Dynamic Client Registration (RFC 7591). + t.boolean :dynamic, null: false, default: false + t.timestamps null: false + end + + add_index :oauth_applications, :uid, unique: true + + create_table :oauth_access_grants do |t| + t.references :resource_owner, null: false + t.references :application, null: false + t.string :token, null: false + t.integer :expires_in, null: false + t.text :redirect_uri, null: false + t.string :scopes, null: false, default: "" + t.datetime :created_at, null: false + t.datetime :revoked_at + + # PKCE (RFC 7636) -- their presence enables Doorkeeper's PKCE support. + t.string :code_challenge + t.string :code_challenge_method + + # Canonical RFC 8707 resource indicator this grant was issued for. + t.string :resource + end + + add_index :oauth_access_grants, :token, unique: true + add_foreign_key( + :oauth_access_grants, + :oauth_applications, + column: :application_id + ) + + create_table :oauth_access_tokens do |t| + t.references :resource_owner, index: true + t.references :application, null: false + + t.string :token, null: false + + t.string :refresh_token + t.integer :expires_in + t.string :scopes + t.datetime :created_at, null: false + t.datetime :revoked_at + + # Canonical RFC 8707 resource indicator, carried across refreshes. + t.string :resource + + # Bumped (throttled) on MCP use; drives inactivity-based cleanup. + t.datetime :last_used_at + + # NOTE: no previous_refresh_token column -- see the class comment. + end + + add_index :oauth_access_tokens, :token, unique: true + add_index :oauth_access_tokens, :refresh_token, unique: true + + add_foreign_key( + :oauth_access_tokens, + :oauth_applications, + column: :application_id + ) + + # Grants and tokens always belong to a signed-in user. + add_foreign_key :oauth_access_grants, :users, column: :resource_owner_id + add_foreign_key :oauth_access_tokens, :users, column: :resource_owner_id + end +end diff --git a/db/schema.rb b/db/schema.rb index b7eeeb8..c039e23 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.1].define(version: 2026_07_06_090004) do +ActiveRecord::Schema[8.1].define(version: 2026_07_10_091356) do # These are extensions that must be enabled in order to support this database enable_extension "pg_catalog.plpgsql" @@ -39,6 +39,53 @@ t.index ["user_id"], name: "index_folders_on_user_id" end + create_table "oauth_access_grants", force: :cascade do |t| + t.bigint "application_id", null: false + t.string "code_challenge" + t.string "code_challenge_method" + t.datetime "created_at", null: false + t.integer "expires_in", null: false + t.text "redirect_uri", null: false + t.string "resource" + t.bigint "resource_owner_id", null: false + t.datetime "revoked_at" + t.string "scopes", default: "", null: false + t.string "token", null: false + t.index ["application_id"], name: "index_oauth_access_grants_on_application_id" + t.index ["resource_owner_id"], name: "index_oauth_access_grants_on_resource_owner_id" + t.index ["token"], name: "index_oauth_access_grants_on_token", unique: true + end + + create_table "oauth_access_tokens", force: :cascade do |t| + t.bigint "application_id", null: false + t.datetime "created_at", null: false + t.integer "expires_in" + t.datetime "last_used_at" + t.string "refresh_token" + t.string "resource" + t.bigint "resource_owner_id" + t.datetime "revoked_at" + t.string "scopes" + t.string "token", null: false + t.index ["application_id"], name: "index_oauth_access_tokens_on_application_id" + t.index ["refresh_token"], name: "index_oauth_access_tokens_on_refresh_token", unique: true + t.index ["resource_owner_id"], name: "index_oauth_access_tokens_on_resource_owner_id" + t.index ["token"], name: "index_oauth_access_tokens_on_token", unique: true + end + + create_table "oauth_applications", force: :cascade do |t| + t.boolean "confidential", default: true, null: false + t.datetime "created_at", null: false + t.boolean "dynamic", default: false, null: false + t.string "name", null: false + t.text "redirect_uri", null: false + t.string "scopes", default: "", null: false + t.string "secret" + t.string "uid", null: false + t.datetime "updated_at", null: false + t.index ["uid"], name: "index_oauth_applications_on_uid", unique: true + end + create_table "paste_views", force: :cascade do |t| t.datetime "created_at", null: false t.string "ip_address_digest" @@ -94,6 +141,10 @@ add_foreign_key "api_keys", "folders" add_foreign_key "api_keys", "users" add_foreign_key "folders", "users" + add_foreign_key "oauth_access_grants", "oauth_applications", column: "application_id" + add_foreign_key "oauth_access_grants", "users", column: "resource_owner_id" + add_foreign_key "oauth_access_tokens", "oauth_applications", column: "application_id" + add_foreign_key "oauth_access_tokens", "users", column: "resource_owner_id" add_foreign_key "paste_views", "pastes" add_foreign_key "paste_views", "users" add_foreign_key "pastes", "folders" diff --git a/test/fixtures/oauth_applications.yml b/test/fixtures/oauth_applications.yml new file mode 100644 index 0000000..7012d67 --- /dev/null +++ b/test/fixtures/oauth_applications.yml @@ -0,0 +1,24 @@ +# MCP OAuth clients are always public (confidential: false) and never hold a +# secret -- CLI agents cannot keep one. `dynamic` marks clients minted through +# Dynamic Client Registration (RFC 7591), whose self-asserted names are +# untrusted and get the "unverified client" consent treatment. +_fixture: + model_class: Doorkeeper::Application + +mcp_client: + name: "Test MCP Client" + uid: "test-mcp-client-uid" + secret: null + redirect_uri: "http://127.0.0.1:7777/callback" + scopes: "mcp:read mcp:write" + confidential: false + dynamic: false + +dynamic_client: + name: "Codex" + uid: "dynamic-mcp-client-uid" + secret: null + redirect_uri: "http://localhost:33418/oauth/callback" + scopes: "mcp:read mcp:write" + confidential: false + dynamic: true diff --git a/test/integration/oauth_authorization_flow_test.rb b/test/integration/oauth_authorization_flow_test.rb new file mode 100644 index 0000000..3184deb --- /dev/null +++ b/test/integration/oauth_authorization_flow_test.rb @@ -0,0 +1,241 @@ +require "test_helper" + +# The Doorkeeper authorization-code + PKCE flow that MCP clients (Claude Code, +# Codex CLI, ...) drive: consent screen, code exchange, refresh rotation, and +# the token-hardening decisions (hashed secrets, header-only bearer tokens). +class OauthAuthorizationFlowTest < ActionDispatch::IntegrationTest + CODE_VERIFIER = "wXyVZ0m3basmgTt5c8sJVzXvUqAHQu7hMYJhZpJp4NM-example-verifier" + CANONICAL_RESOURCE = McpOauth::CONFIG[:resource_uri] + + test "signed-in user completes the full authorization-code + PKCE flow" do + sign_in_as users(:alice) + client = oauth_applications(:mcp_client) + + get "/oauth/authorize", params: authorize_params + assert_response :success + + # The consent form must round-trip the RFC 8707 resource indicator: the + # approve POST is a fresh request and the grant is minted from its params. + assert_select "form input[type=hidden][name=resource][value=?]", CANONICAL_RESOURCE + assert_select "input[type=hidden][name=code_challenge][value=?]", code_challenge + assert_includes response.body, client.name + + # Requested scopes are shown with human labels. + assert_includes response.body, I18n.t("doorkeeper.scopes.mcp:read") + assert_includes response.body, I18n.t("doorkeeper.scopes.mcp:write") + + code = approve_authorization! + grant = Doorkeeper::AccessGrant.order(:id).last + assert_equal CANONICAL_RESOURCE, grant.resource + assert_equal "S256", grant.code_challenge_method + + post "/oauth/token", params: token_params(code: code) + assert_response :success + + body = response.parsed_body + assert body["access_token"].present? + assert body["refresh_token"].present? + assert_equal "Bearer", body["token_type"] + assert_equal 1.hour.to_i, body["expires_in"] + assert_equal "mcp:read mcp:write", body["scope"] + + token = Doorkeeper::AccessToken.order(:id).last + assert_equal CANONICAL_RESOURCE, token.resource + assert_equal users(:alice).id, token.resource_owner_id + end + + test "unauthenticated authorize request resumes the full OAuth URL after sign-in" do + authorize_url = "/oauth/authorize?#{authorize_params.to_query}" + + get authorize_url + assert_response :see_other + assert_redirected_to new_session_path + + # SessionsController#create resumes ONLY via + # session[:return_to_after_authenticating] -- this must land back on the + # exact authorize URL, query string included, or the OAuth flow dies. + post session_url, params: { email_address: users(:alice).email_address, password: "password" } + assert_response :see_other + assert_equal authorize_url, URI.parse(response.location).then { |u| "#{u.path}?#{u.query}" } + + follow_redirect! + assert_response :success + assert_select "form input[type=hidden][name=resource][value=?]", CANONICAL_RESOURCE + end + + test "dynamically registered clients are labeled unverified with their redirect host" do + sign_in_as users(:alice) + client = oauth_applications(:dynamic_client) + + get "/oauth/authorize", params: authorize_params(client_id: client.uid, redirect_uri: client.redirect_uri) + assert_response :success + + # Anyone can register client_name: "Codex" via DCR -- the consent screen + # must lead with "unverified" plus the verifiable redirect host, never + # just the self-asserted name. + assert_includes response.body, I18n.t("doorkeeper.authorizations.new.unverified_client") + assert_includes response.body, "localhost" + end + + test "authorize request without a PKCE code challenge is rejected" do + sign_in_as users(:alice) + + get "/oauth/authorize", params: authorize_params(code_challenge: nil, code_challenge_method: nil) + assert_response :bad_request + assert_includes response.body, I18n.t("doorkeeper.errors.messages.invalid_request.invalid_code_challenge") + end + + test "authorize request with the plain PKCE method is rejected" do + sign_in_as users(:alice) + + get "/oauth/authorize", params: authorize_params(code_challenge: CODE_VERIFIER, code_challenge_method: "plain") + assert_response :bad_request + assert_includes response.body, + I18n.t("doorkeeper.errors.messages.invalid_code_challenge_method", challenge_methods: "S256", count: 1) + end + + test "token exchange without the code verifier is rejected" do + code = obtain_authorization_code + + post "/oauth/token", params: token_params(code: code).except(:code_verifier) + assert_response :bad_request + assert_equal "invalid_request", response.parsed_body["error"] + end + + test "token exchange with a wrong code verifier is rejected" do + code = obtain_authorization_code + + post "/oauth/token", params: token_params(code: code, code_verifier: "not-the-right-verifier-but-long-enough") + assert_response :bad_request + assert_equal "invalid_grant", response.parsed_body["error"] + end + + test "refresh rotates the token pair and the old refresh token dies immediately" do + first = exchange_code_for_token + + post "/oauth/token", params: refresh_params(first["refresh_token"]) + assert_response :success + + second = response.parsed_body + assert second["access_token"].present? + assert second["refresh_token"].present? + assert_not_equal first["access_token"], second["access_token"] + assert_not_equal first["refresh_token"], second["refresh_token"] + + # No previous_refresh_token column => no grace window: replaying the + # rotated-out refresh token must fail outright. + post "/oauth/token", params: refresh_params(first["refresh_token"]) + assert_response :bad_request + assert_equal "invalid_grant", response.parsed_body["error"] + end + + test "access and refresh tokens are stored hashed, not in plaintext" do + body = exchange_code_for_token + token = Doorkeeper::AccessToken.order(:id).last + + assert_not_equal body["access_token"], token.token + assert_not_equal body["refresh_token"], token.refresh_token + + # The plaintext still authenticates through the hashed lookup. + assert_equal token, Doorkeeper::AccessToken.by_token(body["access_token"]) + end + + test "bearer tokens are only accepted from the Authorization header, never request params" do + plaintext = exchange_code_for_token["access_token"] + + from_header = Doorkeeper::OAuth::Token.from_request( + request_with("HTTP_AUTHORIZATION" => "Bearer #{plaintext}"), + *Doorkeeper.config.access_token_methods + ) + assert_equal plaintext, from_header + + from_param = Doorkeeper::OAuth::Token.from_request( + request_with(params: { access_token: plaintext, bearer_token: plaintext }), + *Doorkeeper.config.access_token_methods + ) + assert_nil from_param + end + + test "omitting scope grants the default mcp:read scope" do + sign_in_as users(:alice) + + post "/oauth/authorize", params: authorize_params.except(:scope) + assert_response :redirect + + assert_equal "mcp:read", Doorkeeper::AccessGrant.order(:id).last.scopes.to_s + end + + private + def sign_in_as(user) + post session_url, params: { email_address: user.email_address, password: "password" } + end + + def code_challenge(verifier = CODE_VERIFIER) + Base64.urlsafe_encode64(Digest::SHA256.digest(verifier), padding: false) + end + + def authorize_params(**overrides) + { + client_id: oauth_applications(:mcp_client).uid, + redirect_uri: oauth_applications(:mcp_client).redirect_uri, + response_type: "code", + scope: "mcp:read mcp:write", + state: "opaque-client-state", + code_challenge: code_challenge, + code_challenge_method: "S256", + resource: CANONICAL_RESOURCE + }.merge(overrides).compact + end + + def token_params(code:, **overrides) + { + grant_type: "authorization_code", + client_id: oauth_applications(:mcp_client).uid, + redirect_uri: oauth_applications(:mcp_client).redirect_uri, + code: code, + code_verifier: CODE_VERIFIER, + resource: CANONICAL_RESOURCE + }.merge(overrides).compact + end + + def refresh_params(refresh_token) + { + grant_type: "refresh_token", + client_id: oauth_applications(:mcp_client).uid, + refresh_token: refresh_token, + resource: CANONICAL_RESOURCE + } + end + + def approve_authorization!(**overrides) + post "/oauth/authorize", params: authorize_params(**overrides) + assert_response :redirect + + location = URI.parse(response.location) + assert_equal "/callback", location.path + query = Rack::Utils.parse_query(location.query) + assert_equal "opaque-client-state", query["state"] + assert query["code"].present?, "expected an authorization code in #{response.location}" + query["code"] + end + + def obtain_authorization_code + sign_in_as users(:alice) + approve_authorization! + end + + def exchange_code_for_token + post "/oauth/token", params: token_params(code: obtain_authorization_code) + assert_response :success + response.parsed_body + end + + def request_with(params: nil, **env) + ActionDispatch::Request.new({ + "REQUEST_METHOD" => "GET", + "PATH_INFO" => "/mcp", + "QUERY_STRING" => params ? params.to_query : "", + "rack.input" => StringIO.new(+"") + }.merge(env)) + end +end diff --git a/test/integration/oauth_resource_indicator_test.rb b/test/integration/oauth_resource_indicator_test.rb new file mode 100644 index 0000000..4eaee7c --- /dev/null +++ b/test/integration/oauth_resource_indicator_test.rb @@ -0,0 +1,208 @@ +require "test_helper" + +# RFC 8707 resource-indicator enforcement. Doorkeeper has no native support, +# so this is hand-rolled: both OAuth endpoints require EXACTLY ONE `resource` +# parameter matching McpOauth::CONFIG[:resource_uri] (scheme/host +# case-insensitive, path byte-exact), and always persist the CANONICAL +# spelling on grants and tokens so the /mcp audience check can compare +# exactly. Repeats are checked on the raw query/body because Rails params +# collapse `resource=a&resource=b` into the last value. +class OauthResourceIndicatorTest < ActionDispatch::IntegrationTest + CODE_VERIFIER = "wXyVZ0m3basmgTt5c8sJVzXvUqAHQu7hMYJhZpJp4NM-example-verifier" + CANONICAL_RESOURCE = McpOauth::CONFIG[:resource_uri] + UPPERCASED_RESOURCE = begin + uri = URI.parse(CANONICAL_RESOURCE) + "#{uri.scheme.upcase}://#{uri.host.upcase}#{uri.path}" + end + + # --- Authorization endpoint ----------------------------------------------- + + test "authorize without a resource parameter is rejected with invalid_target" do + sign_in_as users(:alice) + + get "/oauth/authorize", params: authorize_params(resource: nil) + assert_invalid_target_page + end + + test "authorize with a repeated resource parameter is rejected" do + sign_in_as users(:alice) + + # Hand-built query string: Rails params would collapse the repeat, so the + # implementation must inspect the raw query to catch it. + query = authorize_params.to_query + "&resource=#{CGI.escape(CANONICAL_RESOURCE)}" + get "/oauth/authorize?#{query}" + assert_invalid_target_page + end + + test "authorize with an array resource parameter is rejected" do + sign_in_as users(:alice) + + get "/oauth/authorize?#{authorize_params(resource: nil).to_query}&resource[]=#{CGI.escape(CANONICAL_RESOURCE)}" + assert_invalid_target_page + end + + test "authorize with a path-cased resource variant is rejected" do + sign_in_as users(:alice) + + # Scheme and host compare case-insensitively but the path is byte-exact + # (RFC 3986): /MCP is a different resource than /mcp. + get "/oauth/authorize", params: authorize_params(resource: CANONICAL_RESOURCE.sub("/mcp", "/MCP")) + assert_invalid_target_page + end + + test "authorize with a foreign resource is rejected" do + sign_in_as users(:alice) + + get "/oauth/authorize", params: authorize_params(resource: "https://evil.example.com/mcp") + assert_invalid_target_page + end + + test "approving consent without a resource creates no grant" do + sign_in_as users(:alice) + + assert_no_difference "Doorkeeper::AccessGrant.count" do + post "/oauth/authorize", params: authorize_params(resource: nil) + end + assert_invalid_target_page + end + + test "an uppercase scheme and host variant is accepted and stored canonically" do + sign_in_as users(:alice) + + get "/oauth/authorize", params: authorize_params(resource: UPPERCASED_RESOURCE) + assert_response :success + + # The consent form must already carry the CANONICAL spelling -- never the + # client's -- so the approve POST persists the normalized value. + assert_select "form input[type=hidden][name=resource][value=?]", CANONICAL_RESOURCE + + post "/oauth/authorize", params: authorize_params(resource: UPPERCASED_RESOURCE) + assert_response :redirect + + grant = Doorkeeper::AccessGrant.order(:id).last + assert_equal CANONICAL_RESOURCE, grant.resource + end + + # --- Token endpoint -------------------------------------------------------- + + test "token exchange without a resource parameter is rejected with invalid_target" do + code = obtain_authorization_code + + assert_no_difference "Doorkeeper::AccessToken.count" do + post "/oauth/token", params: token_params(code: code, resource: nil) + end + + assert_response :bad_request + assert_equal "invalid_target", response.parsed_body["error"] + end + + test "token exchange with a repeated resource parameter is rejected" do + code = obtain_authorization_code + + body = token_params(code: code).to_query + "&resource=#{CGI.escape(CANONICAL_RESOURCE)}" + post "/oauth/token", params: body, + headers: { "Content-Type" => "application/x-www-form-urlencoded" } + + assert_response :bad_request + assert_equal "invalid_target", response.parsed_body["error"] + end + + test "token exchange with a mismatched resource is rejected" do + code = obtain_authorization_code + + post "/oauth/token", params: token_params(code: code, resource: "#{CANONICAL_RESOURCE}/other") + assert_response :bad_request + assert_equal "invalid_target", response.parsed_body["error"] + end + + test "uppercase resource spelling at both endpoints still yields a canonical token" do + code = obtain_authorization_code(resource: UPPERCASED_RESOURCE) + + post "/oauth/token", params: token_params(code: code, resource: UPPERCASED_RESOURCE) + assert_response :success + + token = Doorkeeper::AccessToken.order(:id).last + assert_equal CANONICAL_RESOURCE, token.resource + end + + test "refresh without a resource parameter is rejected" do + refresh_token = exchange_code_for_token["refresh_token"] + + post "/oauth/token", params: refresh_params(refresh_token).except(:resource) + assert_response :bad_request + assert_equal "invalid_target", response.parsed_body["error"] + end + + test "a refreshed token keeps the canonical resource" do + refresh_token = exchange_code_for_token["refresh_token"] + + post "/oauth/token", params: refresh_params(refresh_token) + assert_response :success + + refreshed = Doorkeeper::AccessToken.by_token(response.parsed_body["access_token"]) + assert_equal CANONICAL_RESOURCE, refreshed.resource + end + + private + def sign_in_as(user) + post session_url, params: { email_address: user.email_address, password: "password" } + end + + def code_challenge + Base64.urlsafe_encode64(Digest::SHA256.digest(CODE_VERIFIER), padding: false) + end + + def authorize_params(**overrides) + { + client_id: oauth_applications(:mcp_client).uid, + redirect_uri: oauth_applications(:mcp_client).redirect_uri, + response_type: "code", + scope: "mcp:read mcp:write", + state: "opaque-client-state", + code_challenge: code_challenge, + code_challenge_method: "S256", + resource: CANONICAL_RESOURCE + }.merge(overrides).compact + end + + def token_params(code:, **overrides) + { + grant_type: "authorization_code", + client_id: oauth_applications(:mcp_client).uid, + redirect_uri: oauth_applications(:mcp_client).redirect_uri, + code: code, + code_verifier: CODE_VERIFIER, + resource: CANONICAL_RESOURCE + }.merge(overrides).compact + end + + def refresh_params(refresh_token) + { + grant_type: "refresh_token", + client_id: oauth_applications(:mcp_client).uid, + refresh_token: refresh_token, + resource: CANONICAL_RESOURCE + } + end + + def obtain_authorization_code(**overrides) + sign_in_as users(:alice) + post "/oauth/authorize", params: authorize_params(**overrides) + assert_response :redirect + + query = Rack::Utils.parse_query(URI.parse(response.location).query) + assert query["code"].present?, "expected an authorization code in #{response.location}" + query["code"] + end + + def exchange_code_for_token + post "/oauth/token", params: token_params(code: obtain_authorization_code) + assert_response :success + response.parsed_body + end + + def assert_invalid_target_page + assert_response :bad_request + assert_includes response.body, I18n.t("doorkeeper.errors.messages.invalid_target") + end +end From ab0d47afe1fd8853917bb4c817db31ccd23398b3 Mon Sep 17 00:00:00 2001 From: Ali Hamdi Ali Fadel Date: Fri, 10 Jul 2026 12:33:25 +0300 Subject: [PATCH 03/22] feat(oauth): add RFC 9728 + RFC 8414 discovery endpoints WellKnownController serves the protected-resource metadata (both the root and /mcp-suffixed well-known forms) and authorization-server metadata MCP clients probe before login. Both documents are built solely from McpOauth::CONFIG, never request headers. AS metadata advertises registration_endpoint ahead of the DCR route landing (same release) and mandatory code_challenge_methods_supported, without which spec-compliant clients abort discovery. Co-Authored-By: Claude Fable 5 --- app/controllers/well_known_controller.rb | 43 +++++++++ config/routes.rb | 14 ++- .../controllers/well_known_controller_test.rb | 92 +++++++++++++++++++ 3 files changed, 147 insertions(+), 2 deletions(-) create mode 100644 app/controllers/well_known_controller.rb create mode 100644 test/controllers/well_known_controller_test.rb diff --git a/app/controllers/well_known_controller.rb b/app/controllers/well_known_controller.rb new file mode 100644 index 0000000..0b04fd4 --- /dev/null +++ b/app/controllers/well_known_controller.rb @@ -0,0 +1,43 @@ +# OAuth discovery documents for the MCP authorization/resource server: +# RFC 9728 (protected resource metadata) and RFC 8414 (authorization server +# metadata). Both are static JSON built exclusively from McpOauth::CONFIG -- +# never from request headers, since that's the trusted issuer/resource +# identity used in audience checks (see config/initializers/mcp_oauth.rb). +# +# ActionController::API on purpose, not ApplicationController: that base +# includes the Authentication concern, whose default before_action would +# redirect these public, session-less endpoints to the login page. MCP +# clients probe them before any login has happened. +class WellKnownController < ActionController::API + SCOPES_SUPPORTED = %w[mcp:read mcp:write].freeze + + # RFC 9728 -- GET /.well-known/oauth-protected-resource(/mcp) + def protected_resource + render json: { + resource: McpOauth::CONFIG[:resource_uri], + authorization_servers: [ McpOauth::CONFIG[:issuer] ], + scopes_supported: SCOPES_SUPPORTED, + bearer_methods_supported: %w[header] + } + end + + # RFC 8414 -- GET /.well-known/oauth-authorization-server + def authorization_server + issuer = McpOauth::CONFIG[:issuer] + + render json: { + issuer: issuer, + authorization_endpoint: "#{issuer}/oauth/authorize", + token_endpoint: "#{issuer}/oauth/token", + registration_endpoint: "#{issuer}/oauth/register", + revocation_endpoint: "#{issuer}/oauth/revoke", + grant_types_supported: %w[authorization_code refresh_token], + response_types_supported: %w[code], + # Mandatory per RFC 8414 / the MCP spec: clients abort discovery + # entirely if this field is missing. + code_challenge_methods_supported: %w[S256], + token_endpoint_auth_methods_supported: %w[none], + scopes_supported: SCOPES_SUPPORTED + } + end +end diff --git a/config/routes.rb b/config/routes.rb index 4fc7ae5..f9be761 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -71,14 +71,24 @@ # resource-indicator enforcement onto Doorkeeper's stock endpoints. # :applications is skipped (clients arrive via dynamic registration, not # an admin UI); :authorized_applications stays -- it's the "connected - # agents" screen a later task links up and restyles. /mcp itself, the - # well-known discovery documents, and /oauth/register are later tasks. + # agents" screen a later task links up and restyles. /mcp itself and + # /oauth/register are later tasks. constraints host: McpOauth::CONFIG[:host] do use_doorkeeper scope: "oauth" do controllers authorizations: "oauth/authorizations", tokens: "oauth/tokens" skip_controllers :applications end + + # RFC 9728 protected resource metadata + RFC 8414 authorization server + # metadata -- static discovery JSON MCP clients probe before any login. + # The optional /mcp suffix matters: RFC 9728 derives a path-suffixed + # metadata URL from a resource URL that has a path component, so a + # client that builds the URL from McpOauth::CONFIG[:resource_uri] + # (".../mcp") rather than following the WWW-Authenticate pointer asks + # for that one. + get ".well-known/oauth-protected-resource(/mcp)", to: "well_known#protected_resource" + get ".well-known/oauth-authorization-server", to: "well_known#authorization_server" end get "p/:token", to: "pastes#show", as: :paste get "p/:token/password", to: "paste_passwords#new", as: :paste_password diff --git a/test/controllers/well_known_controller_test.rb b/test/controllers/well_known_controller_test.rb new file mode 100644 index 0000000..0e20123 --- /dev/null +++ b/test/controllers/well_known_controller_test.rb @@ -0,0 +1,92 @@ +require "test_helper" + +# RFC 9728 (protected resource metadata) + RFC 8414 (authorization server +# metadata) discovery documents. Both are static JSON derived exclusively +# from McpOauth::CONFIG -- never from request headers -- and must be +# reachable with no session, since MCP clients probe them before any login +# has happened. +class WellKnownControllerTest < ActionDispatch::IntegrationTest + ISSUER = McpOauth::CONFIG[:issuer] + + test "protected resource metadata at the root well-known path" do + get "/.well-known/oauth-protected-resource" + + assert_response :success + assert_equal "application/json", response.media_type + assert_equal expected_protected_resource_metadata, response.parsed_body + end + + test "protected resource metadata at the mcp-suffixed well-known path" do + get "/.well-known/oauth-protected-resource/mcp" + + assert_response :success + assert_equal "application/json", response.media_type + assert_equal expected_protected_resource_metadata, response.parsed_body + end + + test "protected resource metadata is reachable with no session" do + get "/.well-known/oauth-protected-resource" + + assert_response :success + assert_nil session[:return_to_after_authenticating] + end + + test "authorization server metadata returns all required fields" do + get "/.well-known/oauth-authorization-server" + + assert_response :success + assert_equal "application/json", response.media_type + body = response.parsed_body + + assert_equal ISSUER, body["issuer"] + assert_equal "#{ISSUER}/oauth/authorize", body["authorization_endpoint"] + assert_equal "#{ISSUER}/oauth/token", body["token_endpoint"] + assert_equal "#{ISSUER}/oauth/register", body["registration_endpoint"] + assert_equal "#{ISSUER}/oauth/revoke", body["revocation_endpoint"] + assert_equal %w[authorization_code refresh_token], body["grant_types_supported"] + assert_equal %w[code], body["response_types_supported"] + assert_equal %w[mcp:read mcp:write], body["scopes_supported"] + end + + test "authorization server metadata advertises S256 PKCE support" do + get "/.well-known/oauth-authorization-server" + + assert_equal [ "S256" ], response.parsed_body["code_challenge_methods_supported"] + end + + test "authorization server metadata advertises no client authentication (public clients)" do + get "/.well-known/oauth-authorization-server" + + assert_equal [ "none" ], response.parsed_body["token_endpoint_auth_methods_supported"] + end + + test "authorization server metadata is reachable with no session" do + get "/.well-known/oauth-authorization-server" + + assert_response :success + assert_nil session[:return_to_after_authenticating] + end + + test "every endpoint URL in both documents starts with the configured issuer" do + get "/.well-known/oauth-protected-resource" + prm = response.parsed_body + assert prm["resource"].start_with?(ISSUER) + prm["authorization_servers"].each { |url| assert url.start_with?(ISSUER) } + + get "/.well-known/oauth-authorization-server" + asm = response.parsed_body + %w[issuer authorization_endpoint token_endpoint registration_endpoint revocation_endpoint].each do |key| + assert asm[key].start_with?(ISSUER), "expected #{key} (#{asm[key]}) to start with #{ISSUER}" + end + end + + private + def expected_protected_resource_metadata + { + "resource" => McpOauth::CONFIG[:resource_uri], + "authorization_servers" => [ McpOauth::CONFIG[:issuer] ], + "scopes_supported" => %w[mcp:read mcp:write], + "bearer_methods_supported" => %w[header] + } + end +end From ed51a1fe490f9839034cee10c1ed4b7d71238a9e Mon Sep 17 00:00:00 2001 From: Ali Hamdi Ali Fadel Date: Fri, 10 Jul 2026 12:45:59 +0300 Subject: [PATCH 04/22] feat(oauth): add RFC 7591 Dynamic Client Registration endpoint POST /oauth/register lets MCP agents (Claude Code, Codex CLI, ...) self-register before the OAuth flow. It is public and internet-facing, so client metadata is validated strictly, not echoed: - redirect_uris: required non-empty array (<= 10), each an absolute URI with no fragment/userinfo/duplicates; https for any host, http only for RFC 8252 loopback (localhost, 127.0.0.1, [::1]) on any port. - token_endpoint_auth_method: absent or "none" only. - grant_types / response_types / scope: validated subsets, normalized to the full allowed sets; the app always persists "mcp:read mcp:write". - client_name: optional, stripped, <= 255 chars, stored unverified. Each registration mints a secretless public Doorkeeper::Application (confidential: false, dynamic: true) -- Doorkeeper skips secret generation because the column is nullable and the client is public. The 201 response returns the client_id plus an explicit "token_endpoint_auth_method": "none" (RFC 7591's omitted default of client_secret_basic would contradict these clients) and never a client_secret, with no-store cache headers. Guards: a 10/hour/IP rate limit (JSON 429) and an MCP_DYNAMIC_REGISTRATION_DISABLED kill switch that 403s before any validation. Doorkeeper's force_ssl_in_redirect_uri now exempts the same loopback hosts so validated loopback URIs survive its RedirectUriValidator. Co-Authored-By: Claude Fable 5 --- .../oauth/registrations_controller.rb | 213 +++++++++++++ config/initializers/doorkeeper.rb | 10 + config/initializers/mcp_oauth.rb | 9 + config/routes.rb | 5 + .../oauth/registrations_controller_test.rb | 299 ++++++++++++++++++ 5 files changed, 536 insertions(+) create mode 100644 app/controllers/oauth/registrations_controller.rb create mode 100644 test/controllers/oauth/registrations_controller_test.rb diff --git a/app/controllers/oauth/registrations_controller.rb b/app/controllers/oauth/registrations_controller.rb new file mode 100644 index 0000000..fdbe26f --- /dev/null +++ b/app/controllers/oauth/registrations_controller.rb @@ -0,0 +1,213 @@ +# RFC 7591 Dynamic Client Registration -- POST /oauth/register. This is the +# PUBLIC, internet-facing endpoint where MCP agents (Claude Code, Codex CLI, +# ...) self-register before running the OAuth flow, so client metadata is +# validated strictly rather than echoed. Unknown fields are silently ignored +# per RFC 7591; unrecognized values are rejected. +# +# ActionController::API on purpose, not ApplicationController: that base +# includes the Authentication concern, whose default before_action would +# redirect this session-less endpoint to the login page, and CLI clients POST +# bare JSON with no CSRF token. Every client minted here is a PUBLIC client +# (confidential: false) that never holds a secret -- Doorkeeper skips secret +# generation because the `secret` column is nullable and the client is public. +class Oauth::RegistrationsController < ActionController::API + # Signals a rejected registration; carried up to a single 400 JSON renderer. + class InvalidRegistration < StandardError + attr_reader :code + + def initialize(code, description) + @code = code + super(description) + end + end + + ALLOWED_GRANT_TYPES = %w[authorization_code refresh_token].freeze + ALLOWED_RESPONSE_TYPES = %w[code].freeze + ALLOWED_SCOPES = %w[mcp:read mcp:write].freeze + # Regardless of the requested subset, applications are persisted with -- and + # the response returns -- the full allowed set. The granted subset lives + # per-authorization, so a later step-up never needs a second registration. + NORMALIZED_SCOPE = ALLOWED_SCOPES.join(" ").freeze + MAX_REDIRECT_URIS = 10 + MAX_CLIENT_NAME_LENGTH = 255 + DEFAULT_CLIENT_NAME = "Dynamically registered client" + + before_action :set_cache_headers + before_action :reject_when_registration_disabled + + # Public, unauthenticated endpoint -- cap per-IP registration churn (Claude + # Code is known to re-register aggressively). Mirrors the app's other + # solid_cache-backed rate limits; the 429 body is JSON for this API surface. + rate_limit to: 10, within: 1.hour, + with: -> { render_error(:too_many_requests, "too_many_requests", "Too many registration requests. Try again later.") } + + rescue_from InvalidRegistration do |error| + render_error(:bad_request, error.code, error.message) + end + + def create + redirect_uris = validated_redirect_uris + validate_token_endpoint_auth_method! + grant_types = validated_grant_types + response_types = validated_response_types + validate_scope! + client_name = validated_client_name + + application = Doorkeeper::Application.create!( + name: client_name || DEFAULT_CLIENT_NAME, + redirect_uri: redirect_uris.join("\n"), + scopes: NORMALIZED_SCOPE, + confidential: false, + dynamic: true + ) + + render json: registration_response(application, redirect_uris, grant_types, response_types), status: :created + end + + private + def registration_response(application, redirect_uris, grant_types, response_types) + { + client_id: application.uid, + client_id_issued_at: application.created_at.to_i, + client_name: application.name, + redirect_uris: redirect_uris, + grant_types: grant_types, + response_types: response_types, + scope: NORMALIZED_SCOPE, + # RFC 7591's omitted default is client_secret_basic, which contradicts + # these secretless public clients -- so state "none" explicitly. + token_endpoint_auth_method: "none" + } + end + + def validated_redirect_uris + uris = client_metadata["redirect_uris"] + unless uris.is_a?(Array) && uris.any? + raise invalid_redirect_uri("redirect_uris is required and must be a non-empty array.") + end + if uris.length > MAX_REDIRECT_URIS + raise invalid_redirect_uri("At most #{MAX_REDIRECT_URIS} redirect_uris are allowed.") + end + + uris.each { |uri| validate_redirect_uri!(uri) } + raise invalid_redirect_uri("redirect_uris must not contain duplicates.") if uris.uniq.length != uris.length + + uris + end + + def validate_redirect_uri!(value) + raise invalid_redirect_uri("Each redirect_uri must be a string.") unless value.is_a?(String) + + uri = URI.parse(value) + raise invalid_redirect_uri("redirect_uri must be an absolute URI: #{value}") unless uri.absolute? + raise invalid_redirect_uri("redirect_uri must not contain a fragment: #{value}") unless uri.fragment.nil? + raise invalid_redirect_uri("redirect_uri must not contain userinfo: #{value}") unless uri.userinfo.nil? + + scheme = uri.scheme.to_s.downcase + host = uri.host.to_s.downcase + raise invalid_redirect_uri("redirect_uri is missing a host: #{value}") if host.blank? + + validate_redirect_scheme!(scheme, host, value) + rescue URI::InvalidURIError + raise invalid_redirect_uri("redirect_uri is not a valid URI: #{value}") + end + + # https is allowed for any host (exact-match at authorize time); http only + # for RFC 8252 loopback hosts, on any port. Everything else is rejected. + def validate_redirect_scheme!(scheme, host, value) + case scheme + when "https" + nil + when "http" + return if McpOauth::LOOPBACK_HOSTS.include?(host) + + raise invalid_redirect_uri("http redirect_uris are only allowed for loopback hosts: #{value}") + else + raise invalid_redirect_uri("redirect_uri must use https (or http for loopback): #{value}") + end + end + + def validate_token_endpoint_auth_method! + method = client_metadata["token_endpoint_auth_method"] + return if method.nil? || method == "none" + + raise invalid_client_metadata(%(token_endpoint_auth_method must be "none".)) + end + + def validated_grant_types + requested = client_metadata["grant_types"] + return ALLOWED_GRANT_TYPES if requested.nil? + + unless requested.is_a?(Array) && requested.any? && (requested - ALLOWED_GRANT_TYPES).empty? + raise invalid_client_metadata("grant_types must be a non-empty subset of #{ALLOWED_GRANT_TYPES.join(", ")}.") + end + + ALLOWED_GRANT_TYPES + end + + def validated_response_types + requested = client_metadata["response_types"] + return ALLOWED_RESPONSE_TYPES if requested.nil? + + unless requested.is_a?(Array) && requested.any? && (requested - ALLOWED_RESPONSE_TYPES).empty? + raise invalid_client_metadata(%(response_types must be ["code"].)) + end + + ALLOWED_RESPONSE_TYPES + end + + def validate_scope! + scope = client_metadata["scope"] + return if scope.nil? + + raise invalid_client_metadata("scope must be a space-delimited string.") unless scope.is_a?(String) + return if (scope.split - ALLOWED_SCOPES).empty? + + raise invalid_client_metadata("scope may only request #{ALLOWED_SCOPES.join(" and ")}.") + end + + def validated_client_name + name = client_metadata["client_name"] + return if name.nil? + + raise invalid_client_metadata("client_name must be a string.") unless name.is_a?(String) + + stripped = name.strip + if stripped.length > MAX_CLIENT_NAME_LENGTH + raise invalid_client_metadata("client_name must be at most #{MAX_CLIENT_NAME_LENGTH} characters.") + end + + stripped.presence + end + + # The parsed JSON request body. Read straight from request_parameters so + # unknown fields are naturally ignored and query-string params can't sneak + # in as metadata. A non-object body yields no redirect_uris and is rejected. + def client_metadata + body = request.request_parameters + body.is_a?(Hash) ? body : {} + end + + def invalid_redirect_uri(description) + InvalidRegistration.new("invalid_redirect_uri", description) + end + + def invalid_client_metadata(description) + InvalidRegistration.new("invalid_client_metadata", description) + end + + def reject_when_registration_disabled + return unless ActiveModel::Type::Boolean.new.cast(ENV["MCP_DYNAMIC_REGISTRATION_DISABLED"]) + + render_error(:forbidden, "registration_disabled", "Dynamic client registration is currently disabled.") + end + + def set_cache_headers + response.headers["Cache-Control"] = "no-store" + response.headers["Pragma"] = "no-cache" + end + + def render_error(status, code, description) + render json: { error: code, error_description: description }, status: status + end +end diff --git a/config/initializers/doorkeeper.rb b/config/initializers/doorkeeper.rb index ac71c6c..efd8574 100644 --- a/config/initializers/doorkeeper.rb +++ b/config/initializers/doorkeeper.rb @@ -49,6 +49,16 @@ # bearer_token request params, which the MCP spec forbids. access_token_methods :from_bearer_authorization + # RFC 8252 §7.3: native/CLI clients (Claude Code, Codex) receive their + # authorization code on a loopback redirect over plain http on a per-session + # random port. Require TLS on every other redirect URI, but never on loopback + # -- keep this in lockstep with Oauth::RegistrationsController's own scheme + # rules (both read McpOauth::LOOPBACK_HOSTS) so a URI it accepts at + # registration also passes Doorkeeper's RedirectUriValidator on save. + force_ssl_in_redirect_uri do |uri| + McpOauth::LOOPBACK_HOSTS.exclude?(uri.host.to_s.downcase) + end + default_scopes :"mcp:read" optional_scopes :"mcp:write" diff --git a/config/initializers/mcp_oauth.rb b/config/initializers/mcp_oauth.rb index c5d0fc3..3d94ad7 100644 --- a/config/initializers/mcp_oauth.rb +++ b/config/initializers/mcp_oauth.rb @@ -34,4 +34,13 @@ module McpOauth host: host, protected_resource_metadata_url: "#{issuer}/.well-known/oauth-protected-resource".freeze }.freeze + + # Loopback hosts for which RFC 8252 §7.3 permits plain-http redirect URIs on + # any port -- native/CLI agents (Claude Code, Codex) receive their + # authorization code on a random loopback port per session. Shared by the + # Dynamic Client Registration validation (Oauth::RegistrationsController) and + # Doorkeeper's redirect-URI SSL enforcement (force_ssl_in_redirect_uri), which + # must agree on exactly which hosts skip the TLS requirement. Compared against + # a downcased URI host, so `URI.parse("http://[::1]:...").host` -> "[::1]". + LOOPBACK_HOSTS = %w[localhost 127.0.0.1 [::1]].freeze end diff --git a/config/routes.rb b/config/routes.rb index f9be761..28dc6cb 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -89,6 +89,11 @@ # for that one. get ".well-known/oauth-protected-resource(/mcp)", to: "well_known#protected_resource" get ".well-known/oauth-authorization-server", to: "well_known#authorization_server" + + # RFC 7591 Dynamic Client Registration -- the PUBLIC, internet-facing + # endpoint where MCP agents self-register before running the OAuth flow. + # ActionController::API (no session, no CSRF): CLI clients POST bare JSON. + post "oauth/register", to: "oauth/registrations#create" end get "p/:token", to: "pastes#show", as: :paste get "p/:token/password", to: "paste_passwords#new", as: :paste_password diff --git a/test/controllers/oauth/registrations_controller_test.rb b/test/controllers/oauth/registrations_controller_test.rb new file mode 100644 index 0000000..d13a791 --- /dev/null +++ b/test/controllers/oauth/registrations_controller_test.rb @@ -0,0 +1,299 @@ +require "test_helper" + +# RFC 7591 Dynamic Client Registration -- POST /oauth/register. This endpoint +# is PUBLIC and internet-facing: coding agents (Claude Code, Codex CLI, ...) +# self-register here before running the OAuth flow, so the metadata contract is +# validated strictly rather than echoed. Every client minted here is a public +# client (confidential: false) that never holds a secret. +class Oauth::RegistrationsControllerTest < ActionDispatch::IntegrationTest + NORMALIZED_SCOPE = "mcp:read mcp:write".freeze + + # --- Happy path ----------------------------------------------------------- + + test "minimal registration mints a public dynamic client" do + assert_difference -> { Doorkeeper::Application.count }, 1 do + register(redirect_uris: [ "http://127.0.0.1:49321/callback" ]) + end + + assert_response :created + assert_equal "application/json", response.media_type + body = response.parsed_body + + assert body["client_id"].present? + assert_kind_of Integer, body["client_id_issued_at"] + assert body["client_name"].present? + assert_equal [ "http://127.0.0.1:49321/callback" ], body["redirect_uris"] + assert_equal %w[authorization_code refresh_token], body["grant_types"] + assert_equal %w[code], body["response_types"] + assert_equal NORMALIZED_SCOPE, body["scope"] + end + + test "response advertises token_endpoint_auth_method none and never a secret" do + register(redirect_uris: [ "http://127.0.0.1:49321/callback" ]) + + body = response.parsed_body + assert_equal "none", body["token_endpoint_auth_method"] + assert_not body.key?("client_secret"), "registration response must never carry a client_secret" + end + + test "registration sends no-store and pragma cache headers" do + register(redirect_uris: [ "http://127.0.0.1:49321/callback" ]) + + assert_equal "no-store", response.headers["Cache-Control"] + assert_equal "no-cache", response.headers["Pragma"] + end + + test "persisted record is a secretless public dynamic client with the full scope" do + register(redirect_uris: [ "http://127.0.0.1:49321/callback" ]) + + application = Doorkeeper::Application.find_by(uid: response.parsed_body["client_id"]) + assert_equal false, application.confidential + assert_nil application.secret + assert_equal true, application.dynamic + assert_equal NORMALIZED_SCOPE, application.scopes.to_s + assert_equal "http://127.0.0.1:49321/callback", application.redirect_uri + end + + test "client_id_issued_at matches the record creation time" do + register(redirect_uris: [ "http://127.0.0.1:49321/callback" ]) + + application = Doorkeeper::Application.find_by(uid: response.parsed_body["client_id"]) + assert_equal application.created_at.to_i, response.parsed_body["client_id_issued_at"] + end + + # --- Acceptance edge cases ------------------------------------------------ + + test "accepts loopback http on localhost, 127.0.0.1 and [::1] with odd ports" do + %w[ + http://localhost:1/callback + http://127.0.0.1:65535/cb + http://[::1]:8912/callback + ].each do |uri| + register(redirect_uris: [ uri ]) + assert_response :created, "expected #{uri} to be accepted" + assert_equal [ uri ], response.parsed_body["redirect_uris"] + end + end + + test "accepts an exact https redirect uri on any host" do + register(redirect_uris: [ "https://codex.example.com/oauth/callback" ]) + + assert_response :created + assert_equal [ "https://codex.example.com/oauth/callback" ], response.parsed_body["redirect_uris"] + end + + test "accepts multiple redirect uris" do + uris = [ "http://127.0.0.1:1234/cb", "http://localhost:5678/cb" ] + register(redirect_uris: uris) + + assert_response :created + assert_equal uris, response.parsed_body["redirect_uris"] + application = Doorkeeper::Application.find_by(uid: response.parsed_body["client_id"]) + assert_equal uris.join("\n"), application.redirect_uri + end + + test "requested scope subset is persisted and returned as the full allowed set" do + register(redirect_uris: [ "http://127.0.0.1:49321/callback" ], scope: "mcp:read") + + assert_response :created + assert_equal NORMALIZED_SCOPE, response.parsed_body["scope"] + application = Doorkeeper::Application.find_by(uid: response.parsed_body["client_id"]) + assert_equal NORMALIZED_SCOPE, application.scopes.to_s + end + + test "a supplied grant_types subset is normalized to the full pair" do + register(redirect_uris: [ "http://127.0.0.1:49321/callback" ], grant_types: [ "authorization_code" ]) + + assert_response :created + assert_equal %w[authorization_code refresh_token], response.parsed_body["grant_types"] + end + + test "an explicit token_endpoint_auth_method none is accepted" do + register(redirect_uris: [ "http://127.0.0.1:49321/callback" ], token_endpoint_auth_method: "none") + + assert_response :created + end + + test "a supplied client_name is stored and echoed" do + register(redirect_uris: [ "http://127.0.0.1:49321/callback" ], client_name: " Claude Code ") + + assert_response :created + assert_equal "Claude Code", response.parsed_body["client_name"] + end + + test "unknown metadata fields are silently ignored" do + register( + redirect_uris: [ "http://127.0.0.1:49321/callback" ], + logo_uri: "https://example.com/logo.png", + software_id: "whatever" + ) + + assert_response :created + assert_not response.parsed_body.key?("logo_uri") + end + + # --- Redirect URI rejections (invalid_redirect_uri) ----------------------- + + test "rejects a missing redirect_uris field" do + assert_no_difference -> { Doorkeeper::Application.count } do + register({}) + end + assert_invalid_redirect_uri + end + + test "rejects an empty redirect_uris array" do + register(redirect_uris: []) + assert_invalid_redirect_uri + end + + test "rejects a non-array redirect_uris value" do + register(redirect_uris: "http://127.0.0.1:1234/cb") + assert_invalid_redirect_uri + end + + test "rejects more than ten redirect uris" do + uris = Array.new(11) { |i| "http://127.0.0.1:#{4000 + i}/cb" } + register(redirect_uris: uris) + assert_invalid_redirect_uri + end + + test "rejects a redirect uri with a fragment" do + register(redirect_uris: [ "https://example.com/cb#section" ]) + assert_invalid_redirect_uri + end + + test "rejects a redirect uri with userinfo" do + register(redirect_uris: [ "https://user:pass@example.com/cb" ]) + assert_invalid_redirect_uri + end + + test "rejects duplicate redirect uris" do + register(redirect_uris: [ "http://127.0.0.1:1234/cb", "http://127.0.0.1:1234/cb" ]) + assert_invalid_redirect_uri + end + + test "rejects non-loopback http" do + register(redirect_uris: [ "http://example.com/cb" ]) + assert_invalid_redirect_uri + end + + test "rejects a garbage redirect uri" do + register(redirect_uris: [ "not a uri" ]) + assert_invalid_redirect_uri + end + + test "rejects a relative redirect uri" do + register(redirect_uris: [ "/callback" ]) + assert_invalid_redirect_uri + end + + test "rejects a malformed port" do + register(redirect_uris: [ "http://127.0.0.1:notaport/cb" ]) + assert_invalid_redirect_uri + end + + # --- Metadata rejections (invalid_client_metadata) ------------------------ + + test "rejects a non-none token_endpoint_auth_method" do + register(redirect_uris: [ "http://127.0.0.1:1234/cb" ], token_endpoint_auth_method: "client_secret_basic") + assert_invalid_client_metadata + end + + test "rejects an unknown grant_type" do + register(redirect_uris: [ "http://127.0.0.1:1234/cb" ], grant_types: [ "client_credentials" ]) + assert_invalid_client_metadata + end + + test "rejects response_types other than code" do + register(redirect_uris: [ "http://127.0.0.1:1234/cb" ], response_types: [ "token" ]) + assert_invalid_client_metadata + end + + test "rejects an unknown scope" do + register(redirect_uris: [ "http://127.0.0.1:1234/cb" ], scope: "mcp:read admin") + assert_invalid_client_metadata + end + + test "rejects an over-long client_name" do + register(redirect_uris: [ "http://127.0.0.1:1234/cb" ], client_name: "a" * 256) + assert_invalid_client_metadata + end + + test "validation failures never create an application" do + assert_no_difference -> { Doorkeeper::Application.count } do + register(redirect_uris: [ "http://example.com/cb" ]) + register(redirect_uris: [ "http://127.0.0.1:1234/cb" ], scope: "admin") + end + end + + # --- Kill switch ---------------------------------------------------------- + + test "kill switch returns 403 before any validation" do + with_registration_disabled do + assert_no_difference -> { Doorkeeper::Application.count } do + # Deliberately invalid body -- the kill switch must fire first. + register(redirect_uris: [ "http://example.com/cb" ]) + end + end + + assert_response :forbidden + assert_equal "registration_disabled", response.parsed_body["error"] + end + + # --- Rate limit ----------------------------------------------------------- + + test "the eleventh registration from one IP is rate limited" do + # The test env's cache is a null_store (increment always returns nil), so + # give the rate limiter a real counter on the exact store object the + # rate_limit macro captured at class load, exercising the throttle + # end-to-end without touching production behavior. + with_counting_cache_store do + 10.times do |i| + register(redirect_uris: [ "http://127.0.0.1:49321/callback" ]) + assert_response :created, "request #{i + 1} should be allowed" + end + + register(redirect_uris: [ "http://127.0.0.1:49321/callback" ]) + assert_response :too_many_requests + assert_equal "too_many_requests", response.parsed_body["error"] + end + end + + private + def register(metadata) + post "/oauth/register", params: metadata, as: :json + end + + def assert_invalid_redirect_uri + assert_response :bad_request + body = response.parsed_body + assert_equal "invalid_redirect_uri", body["error"] + assert body["error_description"].present? + end + + def assert_invalid_client_metadata + assert_response :bad_request + body = response.parsed_body + assert_equal "invalid_client_metadata", body["error"] + assert body["error_description"].present? + end + + def with_registration_disabled + original = ENV["MCP_DYNAMIC_REGISTRATION_DISABLED"] + ENV["MCP_DYNAMIC_REGISTRATION_DISABLED"] = "true" + yield + ensure + ENV["MCP_DYNAMIC_REGISTRATION_DISABLED"] = original + end + + def with_counting_cache_store + store = Oauth::RegistrationsController.cache_store + counts = Hash.new(0) + store.define_singleton_method(:increment) do |key, amount = 1, **_opts| + counts[key] += amount + end + yield + ensure + store.singleton_class.remove_method(:increment) + end +end From 34ae11fa31662d1d6c17a9b0b9b5a8cf3ea8851a Mon Sep 17 00:00:00 2001 From: Ali Hamdi Ali Fadel Date: Fri, 10 Jul 2026 13:04:05 +0300 Subject: [PATCH 05/22] feat(mcp): add the /mcp Streamable HTTP endpoint with auth and guards Wire the MCP Streamable HTTP transport (mcp gem 0.23.0, stateless) behind a McpController that layers the guards the transport cannot do itself, in order: 1. Origin guard (first before_action): absent Origin passes (CLI agents); a present Origin must equal the canonical app origin or it is 403'd before any auth or DB work -- a foreign-Origin request with a valid token never reaches token lookup. 2. RFC 6750 split 401 challenges: no bearer credentials -> challenge without an error attribute; expired/revoked/unknown/malformed/wrong-audience (RFC 8707) or non-mcp-scoped token -> same challenge plus error="invalid_token". 3. Bounded, rewind-safe pre-dispatch peek (<= 4 MiB + 1, max_nesting 20, single top-level object; oversized/malformed/batched bodies step aside for the transport): pre-authorizes tools/call (out-of-scope -> 403 insufficient_scope naming the full scope list to avoid scope oscillation; unknown tool falls through to the SDK) and meters mcp:write calls (20/min + 1000/day per token-owning user, mirroring the REST API's rate_limit semantics). 4. Dispatch: hand the untouched request to the transport and return its Rack triple faithfully -- empty body -> head status (202 notifications stay truly empty), otherwise pass the body through unre-serialized. McpTools holds VERSION, INSTRUCTIONS (pastes are permanent, updates overwrite, Markdown source is not retained), and a tool-class -> required-scope registry (empty until Task 6) with register/deregister/for_scopes/required_scope. Route: match "mcp", via: %i[get post delete] inside the apex host constraint so GET yields the transport's 405, never a Rails 404. Co-Authored-By: Claude Fable 5 --- app/controllers/mcp_controller.rb | 237 +++++++++++++++++++ app/models/mcp_tools.rb | 68 ++++++ config/routes.rb | 7 + test/controllers/mcp_controller_test.rb | 291 ++++++++++++++++++++++++ 4 files changed, 603 insertions(+) create mode 100644 app/controllers/mcp_controller.rb create mode 100644 app/models/mcp_tools.rb create mode 100644 test/controllers/mcp_controller_test.rb diff --git a/app/controllers/mcp_controller.rb b/app/controllers/mcp_controller.rb new file mode 100644 index 0000000..8632368 --- /dev/null +++ b/app/controllers/mcp_controller.rb @@ -0,0 +1,237 @@ +# The remote MCP (Model Context Protocol) endpoint. Coding agents (Claude Code, +# Codex CLI, ...) speak Streamable HTTP JSON-RPC here, authorized by a +# Doorkeeper OAuth bearer token that stands in for the signed-in user -- no +# pht_ API key involved. +# +# ActionController::API on purpose (the Api::BaseController pattern): no session +# cookie, no CSRF, no HTML layout. The request is handled entirely by the mcp +# gem's transport; this controller only layers the guards the transport does +# not (or cannot) do on its own, in this strict order: +# +# 1. Origin guard -- reject foreign browser origins before any DB work. +# 2. Bearer auth -- RFC 6750 split 401 challenges. +# 3. Scope + rate limits -- a bounded, rewind-safe peek at the JSON-RPC body +# pre-authorizes tools/call and meters writes. +# 4. Dispatch -- hand the untouched request to the transport and +# pass its Rack triple straight back. +class McpController < ActionController::API + # The canonical browser origin (scheme + host + port) derived once from the + # trusted issuer -- never from request headers, which an attacker controls. + CANONICAL_ORIGIN = begin + uri = URI.parse(McpOauth::CONFIG[:issuer]) + default_port = uri.scheme == "https" ? 443 : 80 + authority = uri.port && uri.port != default_port ? "#{uri.host}:#{uri.port}" : uri.host + "#{uri.scheme}://#{authority}".downcase.freeze + end + + # The full-access scope list every challenge advertises. Naming the full set + # (not just a missing scope) on the insufficient_scope step-up prevents a + # client from re-authorizing against a narrower scope and losing read access + # (scope oscillation). + CHALLENGE_SCOPE = "#{McpTools::READ_SCOPE} #{McpTools::WRITE_SCOPE}".freeze + + # The transport reads at most this many bytes before rejecting (4 MiB); the + # pre-dispatch peek honors the same bound so it never becomes a bypass of it. + MAX_REQUEST_BYTES = MCP::Server::Transports::StreamableHTTPTransport::DEFAULT_MAX_REQUEST_BYTES + + # Conservative nesting bound for the peek. Deeper-but-still-parseable bodies + # simply cause the peek to step aside (returns nil) and reach the transport, + # which applies its own nesting cap. + PEEK_MAX_NESTING = 20 + + # Write-tool budget per token-owning user, mirroring the REST API's paste + # limits -- pastes can never be deleted, so unmetered writes are unbounded + # storage growth. + WRITE_LIMIT_PER_MINUTE = 20 + WRITE_LIMIT_PER_DAY = 1000 + + before_action :enforce_origin! + before_action :authenticate_token! + before_action :enforce_tool_scope! + before_action :enforce_write_rate_limit! + + def handle + server = MCP::Server.new( + name: "pastehtml", + version: McpTools::VERSION, + instructions: McpTools::INSTRUCTIONS, + tools: McpTools.for_scopes(token_scopes), + server_context: { user: current_token_user } + ) + transport = MCP::Server::Transports::StreamableHTTPTransport.new( + server, + stateless: true, + # The transport's default Host allowlist is loopback-only, so production + # (and the test host) would 403 without this. Origin is validated above. + allowed_hosts: [ McpOauth::CONFIG[:host] ] + ) + + status, headers, body = transport.handle_request(request) + headers.each { |key, value| response.headers[key] = value } + + if body.nil? || (body.respond_to?(:empty?) && body.empty?) + # An accepted notification is 202 with a truly empty body -- never a + # literal JSON "null" or "{}". `head` renders no body. + head status + else + # Pass the Rack body through untouched (JSON-RPC result, transport error, + # or the stateless 405/DELETE bodies) rather than re-serializing it. + self.status = status + self.response_body = body + end + end + + private + # --- Step 1: Origin guard ------------------------------------------------ + + # Absent Origin is the normal case for CLI agents and passes. A present + # Origin must be the canonical app origin, or the request is refused before + # any authentication or database work happens. + def enforce_origin! + origin = request.headers["Origin"] + return if origin.blank? + return if origin.strip.downcase == CANONICAL_ORIGIN + + render json: { error: "forbidden_origin" }, status: :forbidden + end + + # --- Step 2: Bearer authentication -------------------------------------- + + def authenticate_token! + token_value = bearer_token + # No credentials at all is not an error condition (RFC 6750): challenge + # without an `error` attribute so the client starts the discovery flow. + return challenge_unauthorized if token_value.blank? + + access_token = Doorkeeper::AccessToken.by_token(token_value) + if access_token.nil? || !access_token.accessible? || + !mcp_scoped?(access_token) || wrong_audience?(access_token) + return challenge_unauthorized(error: "invalid_token") + end + + @current_access_token = access_token + end + + def mcp_scoped?(access_token) + access_token.scopes.to_a.any? { |scope| scope.start_with?("mcp:") } + end + + # RFC 8707: the token must have been issued for this exact resource. Storage + # is normalized to the canonical URI, so exact equality is safe. + def wrong_audience?(access_token) + access_token.resource != McpOauth::CONFIG[:resource_uri] + end + + def challenge_unauthorized(error: nil) + response.headers["WWW-Authenticate"] = www_authenticate(error: error) + render json: { error: error || "unauthorized" }, status: :unauthorized + end + + def www_authenticate(error: nil) + parts = [] + parts << %(error="#{error}") if error + parts << %(resource_metadata="#{McpOauth::CONFIG[:protected_resource_metadata_url]}") + parts << %(scope="#{CHALLENGE_SCOPE}") + "Bearer #{parts.join(", ")}" + end + + # --- Step 3: scope enforcement + write rate limits ---------------------- + + def enforce_tool_scope! + body = mcp_request_body + return if body.nil? + return unless body[:method] == "tools/call" + + required = McpTools.required_scope(body.dig(:params, :name)) + # Unknown tool (nil) falls through so the SDK answers "unknown tool"; a + # scope the token already holds is fine. + return if required.nil? || token_scopes.include?(required) + + challenge_insufficient_scope + end + + def challenge_insufficient_scope + response.headers["WWW-Authenticate"] = + %(Bearer error="insufficient_scope", scope="#{CHALLENGE_SCOPE}", ) + + %(resource_metadata="#{McpOauth::CONFIG[:protected_resource_metadata_url]}") + render json: { error: "insufficient_scope" }, status: :forbidden + end + + def enforce_write_rate_limit! + body = mcp_request_body + return if body.nil? + return unless body[:method] == "tools/call" + return unless McpTools.required_scope(body.dig(:params, :name)) == McpTools::WRITE_SCOPE + + user_id = current_token_user&.id + return if user_id.nil? + + # Mirror the Rails `rate_limit` macro's semantics (increment a per-window + # counter, reject once it exceeds the cap) inline so the check can be + # conditional on the parsed body. Two sequential windows, minute then day, + # matching two stacked `rate_limit` before_actions -- the second window is + # only touched if the first passed. In the test env the cache is a + # null_store whose `increment` returns nil, so this is a no-op unless a + # real counter is injected (see the controller test). + return render_rate_limited unless under_write_limit?(write_rate_key("minute", user_id), WRITE_LIMIT_PER_MINUTE, 1.minute) + render_rate_limited unless under_write_limit?(write_rate_key("day", user_id), WRITE_LIMIT_PER_DAY, 1.day) + end + + def under_write_limit?(key, limit, window) + count = self.class.cache_store.increment(key, 1, expires_in: window) + count.nil? || count <= limit + end + + def write_rate_key(window, user_id) + "mcp-write-rate:#{window}:#{user_id}" + end + + def render_rate_limited + render json: { error: "rate_limited" }, status: :too_many_requests + end + + # A bounded, rewind-safe peek at the JSON-RPC body, parsed once and memoized + # for the scope check and the rate-limit check. It must not consume the body + # the transport needs, and must not do the transport's job of rejecting + # oversized/malformed/batched bodies -- for any of those it returns nil and + # steps aside so the transport applies its own error handling. + def mcp_request_body + return @mcp_request_body if defined?(@mcp_request_body) + + @mcp_request_body = peek_request_body + end + + def peek_request_body + return nil unless request.post? + + request.body.rewind if request.body.respond_to?(:rewind) + raw = request.body.read(MAX_REQUEST_BYTES + 1) + # Oversized: leave it to the transport's 413. + return nil if raw.nil? || raw.bytesize > MAX_REQUEST_BYTES + + parsed = JSON.parse(raw, symbolize_names: true, max_nesting: PEEK_MAX_NESTING) + # Only a single top-level object is ours to inspect; arrays/scalars (and + # too-deep or malformed bodies, via the rescue) are the transport's. + parsed.is_a?(Hash) ? parsed : nil + rescue JSON::ParserError + nil + ensure + request.body.rewind if request.body.respond_to?(:rewind) + end + + # --- Token-derived helpers ---------------------------------------------- + + def token_scopes + @token_scopes ||= @current_access_token.scopes.to_a + end + + def current_token_user + return @current_token_user if defined?(@current_token_user) + + @current_token_user = @current_access_token && User.find_by(id: @current_access_token.resource_owner_id) + end + + def bearer_token + request.authorization.to_s[/\ABearer\s+(.+)\z/i, 1]&.strip.presence + end +end diff --git a/app/models/mcp_tools.rb b/app/models/mcp_tools.rb new file mode 100644 index 0000000..b7a28a3 --- /dev/null +++ b/app/models/mcp_tools.rb @@ -0,0 +1,68 @@ +# Registry and metadata for the MCP tool catalog. Task 6 fills the registry +# with the real paste/folder tools; for now it is intentionally empty so the +# /mcp endpoint (Task 5) can stand up end-to-end with the transport, auth, and +# scope-enforcement plumbing before any tool exists. +# +# Each tool class is registered with the single OAuth scope required to call +# it ("mcp:read" or "mcp:write"). The controller uses `required_scope` to +# pre-authorize `tools/call` at the HTTP layer (a scope the token lacks is a +# 403 step-up, never a JSON-RPC "unknown tool" error) and `for_scopes` to hand +# the MCP server only the tools the token may see. +module McpTools + VERSION = "1.0.0" + + # Up-front invariants an agent must know before it acts. Surfaced through the + # MCP `initialize` handshake as the server `instructions`. + INSTRUCTIONS = <<~TEXT.strip + PasteHTML pastes are permanent: there is no delete operation, and a paste \ + can never be removed once created. update_paste overwrites a paste's \ + content irreversibly -- there is no version history to roll back to. The \ + original Markdown source of a paste is not retained; stored content is \ + always the rendered HTML. + TEXT + + READ_SCOPE = "mcp:read" + WRITE_SCOPE = "mcp:write" + + # tool class => required scope. A module instance variable, mutated only at + # boot (Task 6's registrations) and in tests (register a fake tool, then + # `deregister` it in teardown), read on every request. + @registry = {} + + class << self + # Registers a tool class under its required scope. Later registrations of + # the same class overwrite the earlier scope, so tests can re-register + # freely. + def register(tool_class, scope:) + unless [ READ_SCOPE, WRITE_SCOPE ].include?(scope) + raise ArgumentError, "unknown scope #{scope.inspect}" + end + + @registry[tool_class] = scope + end + + # Removes a tool class from the registry. Used by tests to clean up fakes. + def deregister(tool_class) + @registry.delete(tool_class) + end + + # The tool classes whose required scope is covered by `scopes` (the token's + # granted scopes). Presentation only -- `tools/list` is filtered by this, + # while `tools/call` is enforced at the HTTP layer by `required_scope`. + def for_scopes(scopes) + granted = Array(scopes).map(&:to_s) + @registry.select { |_tool_class, scope| granted.include?(scope) }.keys + end + + # The scope a named tool requires, or nil for an unknown tool. Returning nil + # for unknown tools is deliberate: the controller then declines to issue a + # 403 step-up and lets the SDK answer with its own "unknown tool" error. + def required_scope(tool_name) + return nil if tool_name.blank? + + name = tool_name.to_s + @registry.each { |tool_class, scope| return scope if tool_class.name_value == name } + nil + end + end +end diff --git a/config/routes.rb b/config/routes.rb index 28dc6cb..c813d06 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -94,6 +94,13 @@ # endpoint where MCP agents self-register before running the OAuth flow. # ActionController::API (no session, no CSRF): CLI clients POST bare JSON. post "oauth/register", to: "oauth/registrations#create" + + # The MCP Streamable HTTP endpoint. Routed for GET, POST, and DELETE (not + # POST-only): the transport dispatches all three internally, and the + # transports spec requires GET to receive an SSE stream or a 405 -- a + # Rails routing 404 is neither. In stateless mode the transport produces + # the compliant refusals itself. + match "mcp", to: "mcp#handle", via: %i[get post delete] end get "p/:token", to: "pastes#show", as: :paste get "p/:token/password", to: "paste_passwords#new", as: :paste_password diff --git a/test/controllers/mcp_controller_test.rb b/test/controllers/mcp_controller_test.rb new file mode 100644 index 0000000..6b6babe --- /dev/null +++ b/test/controllers/mcp_controller_test.rb @@ -0,0 +1,291 @@ +require "test_helper" + +# Fake tools registered into the McpTools registry for the scope/rate-limit +# tests, then removed in teardown so the global registry stays clean. They are +# never actually invoked except on the read happy-path (which proves the peek +# rewound the body for the transport). +class FakeMcpReadTool < MCP::Tool + tool_name "fake_read" + description "A fake read-only tool for tests." + + def self.call(**_args) + MCP::Tool::Response.new([ { type: "text", text: "read ok" } ]) + end +end + +class FakeMcpWriteTool < MCP::Tool + tool_name "fake_write" + description "A fake write tool for tests." + + def self.call(**_args) + MCP::Tool::Response.new([ { type: "text", text: "write ok" } ]) + end +end + +class McpControllerTest < ActionDispatch::IntegrationTest + RESOURCE = McpOauth::CONFIG[:resource_uri] + CANONICAL_ORIGIN = "http://www.example.com".freeze + + setup do + @user = users(:alice) + @application = oauth_applications(:mcp_client) + McpTools.register(FakeMcpReadTool, scope: "mcp:read") + McpTools.register(FakeMcpWriteTool, scope: "mcp:write") + end + + teardown do + McpTools.deregister(FakeMcpReadTool) + McpTools.deregister(FakeMcpWriteTool) + end + + # --- Happy path ---------------------------------------------------------- + + test "valid token + POST initialize returns a 200 JSON-RPC result" do + mcp_post(initialize_body, token: read_write_token.plaintext_token) + + assert_response :ok + result = response.parsed_body["result"] + assert result.present?, "expected a JSON-RPC result" + assert_equal "pastehtml", result.dig("serverInfo", "name") + assert result["protocolVersion"].present? + assert_match(/permanent/, result["instructions"].to_s) + end + + test "a notification is acknowledged with 202 and a truly empty body" do + mcp_post(notification_body, token: read_write_token.plaintext_token) + + assert_response 202 + assert_equal "", response.body + end + + test "a read tools/call succeeds, proving the peek rewound the full body" do + mcp_post(tools_call_body("fake_read"), token: read_token.plaintext_token) + + assert_response :ok + assert_includes response.body, "read ok" + end + + # --- Verb handling (stateless transport) --------------------------------- + + test "GET /mcp with a valid token is 405, never a routing 404" do + get "/mcp", headers: auth_headers(read_write_token.plaintext_token).merge("Accept" => "text/event-stream") + + assert_response :method_not_allowed + end + + test "DELETE /mcp with a valid token is handled by the transport, not 404/500" do + delete "/mcp", headers: auth_headers(read_write_token.plaintext_token) + + assert_not_includes [ 404, 500 ], response.status + end + + # --- RFC 6750 split 401 challenges ---------------------------------------- + + test "no Authorization header yields a 401 challenge with no error attribute" do + mcp_post(initialize_body, token: nil) + + assert_response :unauthorized + challenge = response.headers["WWW-Authenticate"] + assert challenge.present? + assert_not_includes challenge, "error=" + assert_includes challenge, %(resource_metadata=) + assert_includes challenge, %(scope="mcp:read mcp:write") + end + + test "a garbage token yields error=invalid_token" do + mcp_post(initialize_body, token: "not-a-real-token") + + assert_invalid_token + end + + test "a revoked token yields error=invalid_token" do + token = read_write_token + token.update!(revoked_at: Time.current) + + mcp_post(initialize_body, token: token.plaintext_token) + + assert_invalid_token + end + + test "an expired token yields error=invalid_token" do + token = read_write_token + # expires_in is 1 hour; backdating creation puts expiry in the past. + token.update!(created_at: 2.hours.ago) + + mcp_post(initialize_body, token: token.plaintext_token) + + assert_invalid_token + end + + test "a token bound to another resource (wrong audience) yields error=invalid_token" do + token = mint_token(scopes: "mcp:read mcp:write", resource: "https://evil.example.com/mcp") + + mcp_post(initialize_body, token: token.plaintext_token) + + assert_invalid_token + end + + # --- Origin guard (runs before authentication) ---------------------------- + + test "a foreign Origin with a valid token is 403 from the Origin guard" do + mcp_post(initialize_body, token: read_write_token.plaintext_token, origin: "https://evil.example.com") + + assert_response :forbidden + assert_equal "forbidden_origin", response.parsed_body["error"] + assert_nil response.headers["WWW-Authenticate"] + end + + test "a foreign Origin with no token is 403, not 401 (proves guard runs first)" do + mcp_post(initialize_body, token: nil, origin: "https://evil.example.com") + + assert_response :forbidden + assert_equal "forbidden_origin", response.parsed_body["error"] + end + + test "the canonical Origin passes the guard" do + mcp_post(initialize_body, token: read_write_token.plaintext_token, origin: CANONICAL_ORIGIN) + + assert_response :ok + end + + # --- Scope step-up -------------------------------------------------------- + + test "a read-only token calling a write tool gets a 403 full-scope step-up" do + mcp_post(tools_call_body("fake_write"), token: read_token.plaintext_token) + + assert_response :forbidden + assert_equal "insufficient_scope", response.parsed_body["error"] + challenge = response.headers["WWW-Authenticate"] + assert_includes challenge, %(error="insufficient_scope") + assert_includes challenge, %(scope="mcp:read mcp:write") + assert_includes challenge, %(resource_metadata=) + end + + test "calling an unknown tool is not a 403 (SDK answers instead)" do + mcp_post(tools_call_body("nope_not_a_tool"), token: read_token.plaintext_token) + + assert_not_equal 403, response.status + assert_response :ok + assert response.parsed_body["error"].present?, "expected a JSON-RPC error from the SDK" + end + + # --- Bounded, rewind-safe peek robustness --------------------------------- + + test "a deeply nested body reaches the transport's error handling, no exception" do + # 80 levels: past the peek's cap (20) and the transport's cap (64), but + # under Rails' own param-parser nesting limit, so the peek steps aside and + # the transport returns a JSON-RPC parse error rather than the app 500ing. + mcp_post(nested_json(80), token: read_write_token.plaintext_token) + + assert_not_equal 500, response.status + assert_response :bad_request + end + + test "an oversized body reaches the transport's oversize handling, no 500" do + filler = "a" * (McpController::MAX_REQUEST_BYTES + 128) + body = %({"jsonrpc":"2.0","id":1,"method":"initialize","params":{"filler":"#{filler}"}}) + + mcp_post(body, token: read_write_token.plaintext_token) + + assert_not_equal 500, response.status + assert_response :content_too_large + end + + # --- Write rate limit ----------------------------------------------------- + + test "the 21st write tools/call in a minute is rate limited" do + token = read_write_token + minute_key = "mcp-write-rate:minute:#{@user.id}" + + # The test cache is a null_store (increment returns nil), so inject a real + # counter on the store the controller uses and pre-seed it at the minute + # cap. The next write call increments to 21 and is rejected. + with_counting_cache_store(minute_key => WRITE_LIMIT) do + mcp_post(tools_call_body("fake_write"), token: token.plaintext_token) + end + + assert_response :too_many_requests + assert_equal "rate_limited", response.parsed_body["error"] + end + + private + WRITE_LIMIT = McpController::WRITE_LIMIT_PER_MINUTE + + def mcp_post(body, token:, origin: nil, accept: "application/json, text/event-stream") + headers = { + "Content-Type" => "application/json", + "Accept" => accept + } + headers["Authorization"] = "Bearer #{token}" if token + headers["Origin"] = origin if origin + post "/mcp", params: body, headers: headers + end + + def auth_headers(token) + { "Authorization" => "Bearer #{token}", "Content-Type" => "application/json" } + end + + def mint_token(scopes:, resource: RESOURCE, expires_in: 3600, user: @user) + Doorkeeper::AccessToken.create!( + application: @application, + resource_owner_id: user.id, + scopes: scopes, + expires_in: expires_in, + resource: resource + ) + end + + def read_write_token + @read_write_token ||= mint_token(scopes: "mcp:read mcp:write") + end + + def read_token + @read_token ||= mint_token(scopes: "mcp:read") + end + + def initialize_body + { + jsonrpc: "2.0", + id: 1, + method: "initialize", + params: { + protocolVersion: "2025-11-25", + capabilities: {}, + clientInfo: { name: "test-agent", version: "1.0" } + } + }.to_json + end + + def notification_body + { jsonrpc: "2.0", method: "notifications/initialized" }.to_json + end + + def tools_call_body(name) + { jsonrpc: "2.0", id: 2, method: "tools/call", params: { name: name, arguments: {} } }.to_json + end + + def nested_json(depth) + inner = "1" + depth.times { inner = %({"a":#{inner}}) } + inner + end + + def assert_invalid_token + assert_response :unauthorized + assert_includes response.headers["WWW-Authenticate"], %(error="invalid_token") + end + + # Mirrors the registrations controller test: swap `increment` on the exact + # store object the controller uses so the throttle can be exercised without + # touching production behavior. `preseed` sets starting counts per key. + def with_counting_cache_store(preseed = {}) + store = McpController.cache_store + counts = Hash.new(0).merge(preseed) + store.define_singleton_method(:increment) do |key, amount = 1, **_opts| + counts[key] += amount + end + yield counts + ensure + store.singleton_class.remove_method(:increment) + end +end From 21607463668b130c94ec4f479d60e7db99aeae64 Mon Sep 17 00:00:00 2001 From: Ali Hamdi Ali Fadel Date: Fri, 10 Jul 2026 13:20:05 +0300 Subject: [PATCH 06/22] feat(mcp): add Phase 1 tools (create_paste, list_pastes, list_folders) Build the MCP tool infrastructure and the three Phase 1 tools on top of the Task 5 /mcp endpoint. McpTools::BaseTool centralizes the conventions every tool shares: a single success shape (structured content mirroring the tool's output_schema, plus a JSON text block), one stable error shape ({ code, message, field? }) for all domain failures so they are model-correctable rather than raised exceptions, and URL generation derived from the trusted McpOauth::CONFIG[:issuer] (tools have no request context) -- app paths mirror config/routes.rb and the per-paste live origin mirrors PasteLiveUrl, sourcing scheme/host/port from the issuer. create_paste (mcp:write) publishes a paste owned by the token user with a required explicit format, filename/format agreement checks, folder-by-id ownership, folder-by-name auto-create (folder_created flag), password, and custom subdomain. list_pastes / list_folders (mcp:read) never load paste bodies (with_content_size) and page 20 newest-first. Each tool sets all four annotation hints and is registered with its scope via a to_prepare block, so tools/list filtering and the controller's pre-dispatch write step-up both work. The server is now built with validate_tool_call_results enabled, so a success result that violates its output_schema is caught server-side. Co-Authored-By: Claude Fable 5 --- app/controllers/mcp_controller.rb | 8 +- app/models/mcp_tools/base_tool.rb | 133 ++++++++++++++ app/models/mcp_tools/create_paste.rb | 184 +++++++++++++++++++ app/models/mcp_tools/list_folders.rb | 57 ++++++ app/models/mcp_tools/list_pastes.rb | 111 +++++++++++ config/initializers/mcp_tools.rb | 15 ++ test/integration/mcp_tools_test.rb | 140 ++++++++++++++ test/models/mcp_tools/create_paste_test.rb | 138 ++++++++++++++ test/models/mcp_tools/error_contract_test.rb | 31 ++++ test/models/mcp_tools/list_folders_test.rb | 42 +++++ test/models/mcp_tools/list_pastes_test.rb | 109 +++++++++++ 11 files changed, 967 insertions(+), 1 deletion(-) create mode 100644 app/models/mcp_tools/base_tool.rb create mode 100644 app/models/mcp_tools/create_paste.rb create mode 100644 app/models/mcp_tools/list_folders.rb create mode 100644 app/models/mcp_tools/list_pastes.rb create mode 100644 config/initializers/mcp_tools.rb create mode 100644 test/integration/mcp_tools_test.rb create mode 100644 test/models/mcp_tools/create_paste_test.rb create mode 100644 test/models/mcp_tools/error_contract_test.rb create mode 100644 test/models/mcp_tools/list_folders_test.rb create mode 100644 test/models/mcp_tools/list_pastes_test.rb diff --git a/app/controllers/mcp_controller.rb b/app/controllers/mcp_controller.rb index 8632368..7fae2b9 100644 --- a/app/controllers/mcp_controller.rb +++ b/app/controllers/mcp_controller.rb @@ -56,7 +56,13 @@ def handle version: McpTools::VERSION, instructions: McpTools::INSTRUCTIONS, tools: McpTools.for_scopes(token_scopes), - server_context: { user: current_token_user } + server_context: { user: current_token_user }, + # Turn on the SDK's server-side result validation so a successful tool + # result that does not match its declared output_schema is caught here + # rather than shipped to the agent. Argument validation is already on by + # the SDK default; error results are exempt (they follow the tool error + # contract, not the success schema). + configuration: MCP::Configuration.new(validate_tool_call_results: true) ) transport = MCP::Server::Transports::StreamableHTTPTransport.new( server, diff --git a/app/models/mcp_tools/base_tool.rb b/app/models/mcp_tools/base_tool.rb new file mode 100644 index 0000000..b1c8d87 --- /dev/null +++ b/app/models/mcp_tools/base_tool.rb @@ -0,0 +1,133 @@ +module McpTools + # Shared conventions for every PasteHTML MCP tool. Subclasses set the DSL + # metadata (tool_name/description/input_schema/output_schema/annotations) and + # implement `self.call`, leaning on the helpers here so results and errors + # come out in exactly one shape. + # + # Success is a structured result matching the tool's output_schema (validated + # server-side by the SDK, see McpController). Domain failures -- ownership, + # not-found, name conflicts, model validation -- are tool ERROR responses in a + # single stable shape: { code:, message:, field? }. They are never raised + # Ruby exceptions: an exception would surface as a JSON-RPC internal/protocol + # error the agent cannot correct, whereas a structured error is model-correctable. + # + # Tools have no request context, so every URL is derived from the trusted + # McpOauth::CONFIG[:issuer] (never from request headers) -- the same canonical + # origin the OAuth issuer/audience use. App paths mirror config/routes.rb + # (`/p/:token[/raw|/render|/markdown]`); the per-paste live origin mirrors + # PasteLiveUrl but sources scheme/host/port from the issuer instead of the + # (absent) request. + class BaseTool < MCP::Tool + # Maps an offending ActiveModel attribute to the tool argument that carries + # it, so a model validation error points the agent at the arg it passed. + FIELD_FOR_ATTRIBUTE = { + "content" => "content", + "custom_subdomain" => "custom_subdomain", + "password" => "password", + "original_filename" => "filename", + "folder" => "folder_id" + }.freeze + + class << self + private + + # The token's user, from the controller's server_context. Works whether + # server_context is the raw Hash (unit tests call tools directly) or the + # SDK's MCP::ServerContext wrapper (which delegates `[]` to that Hash). + def user_for(server_context) + server_context[:user] + end + + # Success: structured content plus a JSON text mirror for clients that + # only read `content`. + def ok(structured) + MCP::Tool::Response.new( + [ { type: "text", text: JSON.generate(structured) } ], + structured_content: structured + ) + end + + # The one error shape. `field` is included only when an offending argument + # exists (the spec's "offending_arg_or_omitted"). + def failure(code:, message:, field: nil) + payload = { code: code, message: message } + payload[:field] = field.to_s if field.present? + + MCP::Tool::Response.new( + [ { type: "text", text: message } ], + error: true, + structured_content: payload + ) + end + + # A model's first validation error, rendered into the error shape with the + # argument that owns it. + def validation_error(record) + error = record.errors.first + attribute = error.attribute.to_s + failure( + code: "validation_failed", + message: error.full_message, + field: FIELD_FOR_ATTRIBUTE.fetch(attribute, attribute) + ) + end + + # Look up a folder the user owns, by id or by (case-insensitive) name, for + # read-side filtering. Returns [folder_or_nil, error_or_nil]; an unknown + # (or another user's) folder is a not-found error, never a silent empty list. + def owned_folder(user, folder_id, folder_name) + if folder_id.present? + folder = user.folders.find_by(id: folder_id) + return [ nil, failure(code: "folder_not_found", message: "No folder with id #{folder_id}.", field: "folder_id") ] if folder.nil? + + [ folder, nil ] + elsif folder_name.present? + folder = user.folders.where("LOWER(name) = ?", folder_name.to_s.strip.downcase).first + return [ nil, failure(code: "folder_not_found", message: "No folder named #{folder_name.to_s.strip.inspect}.", field: "folder_name") ] if folder.nil? + + [ folder, nil ] + else + [ nil, nil ] + end + end + + # { id, name } for a paste's folder, or nil when unfiled. + def folder_ref(paste) + paste.folder && { id: paste.folder_id, name: paste.folder.name } + end + + def issuer + McpOauth::CONFIG[:issuer] + end + + def app_url(path) + "#{issuer}#{path}" + end + + # The per-paste origin, e.g. https://.pastehtml.dev/ -- scheme, + # host, and non-default port taken from the issuer. + def live_url_for(paste) + uri = URI.parse(issuer) + default_port = uri.scheme == "https" ? 443 : 80 + port = uri.port && uri.port != default_port ? ":#{uri.port}" : "" + "#{uri.scheme}://#{paste.public_subdomain.downcase}.#{uri.host}#{port}/" + end + + # The full create/update success payload for a single paste. + def paste_detail(paste, folder_created:) + { + token: paste.token, + title: paste.display_title, + url: app_url("/p/#{paste.token}"), + live_url: live_url_for(paste), + raw_url: app_url("/p/#{paste.token}/raw"), + render_url: app_url("/p/#{paste.token}/render"), + markdown_url: app_url("/p/#{paste.token}/markdown"), + folder: folder_ref(paste), + folder_created: folder_created, + password_protected: paste.password_protected? + } + end + end + end +end diff --git a/app/models/mcp_tools/create_paste.rb b/app/models/mcp_tools/create_paste.rb new file mode 100644 index 0000000..352f2ff --- /dev/null +++ b/app/models/mcp_tools/create_paste.rb @@ -0,0 +1,184 @@ +module McpTools + # Publishes a new paste owned by the token's user. + # + # `format` is required and explicit (never inferred): "html" stores the source + # verbatim, "markdown" renders GitHub-Flavored Markdown to a branded, + # self-contained HTML page. A supplied `filename` must agree with `format` + # (its extension is what Paste.render_content keys on); when omitted a + # synthetic name (paste.html / paste.md) is used so rendering still does the + # right thing. There is deliberately no `title` argument -- the title is always + # derived from the content's on save. + class CreatePaste < BaseTool + SYNTHETIC_FILENAME = { "html" => "paste.html", "markdown" => "paste.md" }.freeze + EXTENSION_FOR_FORMAT = { "html" => Paste::HTML_EXTENSION, "markdown" => Paste::MARKDOWN_EXTENSION }.freeze + + tool_name "create_paste" + description <<~TEXT.strip + Create and publish a new paste owned by the authenticated user. Side effect: \ + writes a new, permanent paste (pastes can never be deleted). `format` is \ + required -- "html" stores the content as-is, "markdown" renders it to a \ + branded HTML page. If `filename` is given its extension must match `format`. \ + Supplying `folder_name` for a folder that does not exist creates it (the \ + result sets folder_created: true). Returns the paste's token and its URLs. + TEXT + + input_schema( + type: "object", + properties: { + content: { + type: "string", + description: "The paste body: HTML source when format is \"html\", GitHub-Flavored Markdown when format is \"markdown\"." + }, + format: { + type: "string", + enum: [ "html", "markdown" ], + description: "Required. \"html\" stores content verbatim; \"markdown\" renders it to a branded self-contained HTML page." + }, + filename: { + type: "string", + description: "Optional filename; its extension must match format (.html/.htm for html, .md/.markdown for markdown). For markdown it seeds the rendered <title>." + }, + custom_subdomain: { + type: "string", + description: "Optional vanity subdomain for the paste's live origin (<custom_subdomain>.pastehtml.dev)." + }, + password: { + type: "string", + description: "Optional password; when set the live paste is gated behind it." + }, + folder_id: { + type: "integer", + description: "Optional id of one of the user's folders to file the paste into." + }, + folder_name: { + type: "string", + description: "Optional folder name; a missing folder is created (result sets folder_created: true)." + } + }, + required: [ "content", "format" ], + additionalProperties: false + ) + + output_schema( + type: "object", + properties: { + token: { type: "string" }, + title: { type: "string" }, + url: { type: "string" }, + live_url: { type: "string" }, + raw_url: { type: "string" }, + render_url: { type: "string" }, + markdown_url: { type: "string" }, + folder: { + type: [ "object", "null" ], + properties: { id: { type: "integer" }, name: { type: "string" } } + }, + folder_created: { type: "boolean" }, + password_protected: { type: "boolean" } + }, + required: %w[ token title url live_url raw_url render_url markdown_url folder folder_created password_protected ] + ) + + annotations( + read_only_hint: false, + destructive_hint: false, + idempotent_hint: false, + open_world_hint: false + ) + + class << self + def call(content:, format:, filename: nil, custom_subdomain: nil, password: nil, folder_id: nil, folder_name: nil, server_context:) + user = user_for(server_context) + + resolved_filename, filename_error = resolve_filename(format, filename) + return filename_error if filename_error + + result = nil + Paste.transaction do + folder, folder_created, folder_error = resolve_folder(user, folder_id, folder_name) + if folder_error + result = folder_error + raise ActiveRecord::Rollback + end + + paste = build_paste(user, content, resolved_filename, folder, custom_subdomain, password) + if paste.save + result = ok(paste_detail(paste, folder_created: folder_created)) + else + result = validation_error(paste) + raise ActiveRecord::Rollback + end + end + result + end + + private + # Returns [filename, error]. A supplied filename must carry the extension + # `format` implies; otherwise a synthetic name drives render_content. + def resolve_filename(format, filename) + return [ SYNTHETIC_FILENAME.fetch(format), nil ] if filename.blank? + + extension = File.extname(filename) + return [ filename, nil ] if extension.match?(EXTENSION_FOR_FORMAT.fetch(format)) + + [ nil, failure( + code: "filename_format_mismatch", + message: "filename #{filename.inspect} does not match format #{format.inspect}.", + field: "filename" + ) ] + end + + # Returns [folder, folder_created, error]. folder_id wins and must belong + # to the user; a folder_id + folder_name pair that name different folders + # is a conflict; a lone folder_name auto-creates a missing folder. + def resolve_folder(user, folder_id, folder_name) + requested_name = folder_name.to_s.strip.presence + + if folder_id.present? + folder = user.folders.find_by(id: folder_id) + return [ nil, false, failure(code: "folder_not_found", message: "No folder with id #{folder_id}.", field: "folder_id") ] if folder.nil? + + if requested_name && !folder.name.casecmp?(requested_name) + return [ nil, false, failure(code: "folder_mismatch", message: "folder_id and folder_name refer to different folders.", field: "folder_name") ] + end + + [ folder, false, nil ] + elsif requested_name + find_or_create_folder(user, requested_name) + else + [ nil, false, nil ] + end + end + + # Find-or-create by name, tolerant of a concurrent creator, mirroring + # Api::PastesController#find_or_create_named_folder. Returns + # [folder, folder_created, error]. + def find_or_create_folder(user, name) + existing = user.folders.where("LOWER(name) = ?", name.downcase).first + return [ existing, false, nil ] if existing + + folder = user.folders.new(name: name) + begin + Folder.transaction(requires_new: true) { folder.save! } + [ folder, true, nil ] + rescue ActiveRecord::RecordInvalid + [ nil, false, validation_error(folder) ] + rescue ActiveRecord::RecordNotUnique + [ user.folders.find_by!("LOWER(name) = ?", name.downcase), false, nil ] + end + end + + def build_paste(user, content, filename, folder, custom_subdomain, password) + paste = Paste.new( + content: Paste.render_content(content, filename), + original_filename: filename, + user: user, + folder: folder + ) + paste.custom_subdomain = custom_subdomain if custom_subdomain.present? + paste.password = password if password.present? + paste + end + end + end +end diff --git a/app/models/mcp_tools/list_folders.rb b/app/models/mcp_tools/list_folders.rb new file mode 100644 index 0000000..a2cf2db --- /dev/null +++ b/app/models/mcp_tools/list_folders.rb @@ -0,0 +1,57 @@ +module McpTools + # Lists the authenticated user's folders, ordered by name, each with its paste + # count. Read-only, no arguments. + class ListFolders < BaseTool + tool_name "list_folders" + description <<~TEXT.strip + List the authenticated user's folders, ordered by name, each with the number \ + of pastes filed in it. Read-only; takes no arguments. + TEXT + + input_schema( + type: "object", + properties: {}, + required: [], + additionalProperties: false + ) + + output_schema( + type: "object", + properties: { + folders: { + type: "array", + items: { + type: "object", + properties: { + id: { type: "integer" }, + name: { type: "string" }, + pastes_count: { type: "integer" } + }, + required: %w[ id name pastes_count ] + } + } + }, + required: %w[ folders ] + ) + + annotations( + read_only_hint: true, + destructive_hint: false, + idempotent_hint: true, + open_world_hint: false + ) + + class << self + def call(server_context:) + user = user_for(server_context) + counts = user.pastes.where.not(folder_id: nil).group(:folder_id).count + + folders = user.folders.order(Arel.sql("LOWER(name), id")).map do |folder| + { id: folder.id, name: folder.name, pastes_count: counts.fetch(folder.id, 0) } + end + + ok(folders: folders) + end + end + end +end diff --git a/app/models/mcp_tools/list_pastes.rb b/app/models/mcp_tools/list_pastes.rb new file mode 100644 index 0000000..4ef2e20 --- /dev/null +++ b/app/models/mcp_tools/list_pastes.rb @@ -0,0 +1,111 @@ +module McpTools + # Lists the authenticated user's pastes, newest first, 20 per page, optionally + # filtered to a single folder. Never loads paste bodies (up to 2 MB each) -- + # the byte size is projected instead via the with_content_size scope. + class ListPastes < BaseTool + PAGE_SIZE = 20 + + tool_name "list_pastes" + description <<~TEXT.strip + List the authenticated user's pastes, newest first, #{PAGE_SIZE} per page. \ + Optionally filter to a single folder by folder_id or folder_name (an unknown \ + folder is an error). Read-only. Returns metadata and URLs only -- not the \ + paste content -- with the total count for pagination. + TEXT + + input_schema( + type: "object", + properties: { + folder_id: { type: "integer", description: "Optional: only pastes in this folder." }, + folder_name: { type: "string", description: "Optional: only pastes in the folder with this name (case-insensitive)." }, + page: { type: "integer", minimum: 1, description: "1-based page number; page size is fixed at #{PAGE_SIZE}." } + }, + required: [], + additionalProperties: false + ) + + output_schema( + type: "object", + properties: { + pastes: { + type: "array", + items: { + type: "object", + properties: { + token: { type: "string" }, + title: { type: "string" }, + url: { type: "string" }, + live_url: { type: "string" }, + folder: { + type: [ "object", "null" ], + properties: { id: { type: "integer" }, name: { type: "string" } } + }, + views_count: { type: "integer" }, + content_bytes: { type: "integer" }, + created_at: { type: "string" }, + updated_at: { type: "string" } + }, + required: %w[ token title url live_url folder views_count content_bytes created_at updated_at ] + } + }, + page: { type: "integer" }, + total_count: { type: "integer" } + }, + required: %w[ pastes page total_count ] + ) + + annotations( + read_only_hint: true, + destructive_hint: false, + idempotent_hint: true, + open_world_hint: false + ) + + class << self + def call(folder_id: nil, folder_name: nil, page: nil, server_context:) + user = user_for(server_context) + + folder, folder_error = owned_folder(user, folder_id, folder_name) + return folder_error if folder_error + + page = normalize_page(page) + scope = folder ? user.pastes.where(folder_id: folder.id) : user.pastes + + ok( + pastes: page_of(scope, page).map { |paste| paste_summary(paste) }, + page: page, + total_count: scope.count + ) + end + + private + def normalize_page(page) + page = page.to_i + page < 1 ? 1 : page + end + + def page_of(scope, page) + scope + .with_content_size + .recent + .includes(:folder) + .offset((page - 1) * PAGE_SIZE) + .limit(PAGE_SIZE) + end + + def paste_summary(paste) + { + token: paste.token, + title: paste.display_title, + url: app_url("/p/#{paste.token}"), + live_url: live_url_for(paste), + folder: folder_ref(paste), + views_count: paste.views_count, + content_bytes: paste["content_bytes"].to_i, + created_at: paste.created_at.iso8601, + updated_at: paste.updated_at.iso8601 + } + end + end + end +end diff --git a/config/initializers/mcp_tools.rb b/config/initializers/mcp_tools.rb new file mode 100644 index 0000000..7ad0057 --- /dev/null +++ b/config/initializers/mcp_tools.rb @@ -0,0 +1,15 @@ +# Be sure to restart your server when you modify this file. +# +# Registers the MCP tool catalog into the McpTools registry with each tool's +# required OAuth scope. The registry drives both `tools/list` filtering +# (presentation) and the controller's pre-dispatch scope enforcement (a write +# tool called with a read-only token is a 403 step-up, not an unknown-tool error). +# +# Runs inside `to_prepare` so the autoloaded tool constants are referenced (and +# thus loaded) on boot and re-registered after each code reload in development; +# `register` overwrites, so this is idempotent. +Rails.application.config.to_prepare do + McpTools.register(McpTools::CreatePaste, scope: McpTools::WRITE_SCOPE) + McpTools.register(McpTools::ListPastes, scope: McpTools::READ_SCOPE) + McpTools.register(McpTools::ListFolders, scope: McpTools::READ_SCOPE) +end diff --git a/test/integration/mcp_tools_test.rb b/test/integration/mcp_tools_test.rb new file mode 100644 index 0000000..5a5f83d --- /dev/null +++ b/test/integration/mcp_tools_test.rb @@ -0,0 +1,140 @@ +require "test_helper" + +# Exercises the Phase 1 tools through the real /mcp endpoint with a real +# Doorkeeper token, proving the registry wiring: scope-filtered tools/list, +# structured tool results, and the controller's pre-dispatch write step-up. +class McpToolsIntegrationTest < ActionDispatch::IntegrationTest + RESOURCE = McpOauth::CONFIG[:resource_uri] + + setup do + @user = users(:alice) + @application = oauth_applications(:mcp_client) + end + + # --- tools/list is scope-filtered presentation --------------------------- + + test "a full-scope token lists all three tools with annotations present" do + mcp_post(tools_list_body, token: read_write_token.plaintext_token) + + assert_response :ok + tools = response.parsed_body.dig("result", "tools") + names = tools.map { |tool| tool["name"] }.sort + assert_equal %w[ create_paste list_folders list_pastes ], names + + create = tools.find { |tool| tool["name"] == "create_paste" } + annotations = create.fetch("annotations") + assert_equal false, annotations["readOnlyHint"] + assert_equal false, annotations["destructiveHint"] + assert_equal false, annotations["idempotentHint"] + assert_equal false, annotations["openWorldHint"] + assert create.key?("outputSchema"), "expected an output schema on the wire" + + read_tool = tools.find { |tool| tool["name"] == "list_pastes" } + assert_equal true, read_tool.dig("annotations", "readOnlyHint") + assert_equal true, read_tool.dig("annotations", "idempotentHint") + end + + test "a read-only token lists only the two read tools" do + mcp_post(tools_list_body, token: read_token.plaintext_token) + + assert_response :ok + names = response.parsed_body.dig("result", "tools").map { |tool| tool["name"] }.sort + assert_equal %w[ list_folders list_pastes ], names + end + + # --- tools/call ----------------------------------------------------------- + + test "create_paste persists a paste owned by the token user and returns structuredContent" do + assert_difference -> { @user.pastes.count }, 1 do + mcp_post( + tools_call_body("create_paste", content: "<title>Via MCP

hi

", format: "html"), + token: read_write_token.plaintext_token + ) + end + + assert_response :ok + result = response.parsed_body["result"] + structured = result["structuredContent"] + assert structured.present?, "expected structuredContent on the result" + + paste = Paste.find_by(token: structured["token"]) + assert_equal @user, paste.user + assert_equal "Via MCP", structured["title"] + assert_not result["isError"] + end + + test "create_paste with a read-only token is a 403 step-up at the HTTP layer" do + assert_no_difference -> { Paste.count } do + mcp_post( + tools_call_body("create_paste", content: "

x

", format: "html"), + token: read_token.plaintext_token + ) + end + + assert_response :forbidden + assert_equal "insufficient_scope", response.parsed_body["error"] + assert_includes response.headers["WWW-Authenticate"], %(scope="mcp:read mcp:write") + end + + test "list_pastes returns a schema-valid structured result through the server" do + @user.pastes.create!(content: "One", original_filename: "p.html") + + mcp_post(tools_call_body("list_pastes"), token: read_token.plaintext_token) + + assert_response :ok + result = response.parsed_body["result"] + # A schema-invalid result would come back as a JSON-RPC error, not a result + # with structuredContent -- so this also proves server-side output + # validation accepts the computed content_bytes/timestamps. + assert_not result["isError"] + pastes = result.dig("structuredContent", "pastes") + assert pastes.first["content_bytes"].is_a?(Integer) + assert_equal 1, result.dig("structuredContent", "total_count") + end + + test "list_folders returns a structured result for a read token" do + @user.folders.create!(name: "Zeta") + + mcp_post(tools_call_body("list_folders"), token: read_token.plaintext_token) + + assert_response :ok + folders = response.parsed_body.dig("result", "structuredContent", "folders") + assert folders.any? { |folder| folder["name"] == "Zeta" } + end + + private + def mcp_post(body, token:) + headers = { + "Content-Type" => "application/json", + "Accept" => "application/json, text/event-stream", + "Authorization" => "Bearer #{token}" + } + post "/mcp", params: body, headers: headers + end + + def mint_token(scopes:) + Doorkeeper::AccessToken.create!( + application: @application, + resource_owner_id: @user.id, + scopes: scopes, + expires_in: 3600, + resource: RESOURCE + ) + end + + def read_write_token + @read_write_token ||= mint_token(scopes: "mcp:read mcp:write") + end + + def read_token + @read_token ||= mint_token(scopes: "mcp:read") + end + + def tools_list_body + { jsonrpc: "2.0", id: 1, method: "tools/list", params: {} }.to_json + end + + def tools_call_body(name, **arguments) + { jsonrpc: "2.0", id: 2, method: "tools/call", params: { name: name, arguments: arguments } }.to_json + end +end diff --git a/test/models/mcp_tools/create_paste_test.rb b/test/models/mcp_tools/create_paste_test.rb new file mode 100644 index 0000000..e3a6b93 --- /dev/null +++ b/test/models/mcp_tools/create_paste_test.rb @@ -0,0 +1,138 @@ +require "test_helper" + +class McpTools::CreatePasteTest < ActiveSupport::TestCase + setup do + @alice = users(:alice) + @bob = users(:bob) + @ctx = { user: @alice } + end + + test "html content is stored verbatim, owned by the token user, with a title" do + response = create(content: "Report

hi

", format: "html") + + assert_not response.error? + paste = Paste.find_by(token: response.structured_content[:token]) + assert_equal @alice, paste.user + assert_equal "Report

hi

", paste.content + assert_equal "Report", response.structured_content[:title] + assert_equal false, response.structured_content[:password_protected] + assert_nil response.structured_content[:folder] + end + + test "markdown content is rendered to branded HTML, not stored raw" do + response = create(content: "# Heading\n\nbody text", format: "markdown") + + assert_not response.error? + paste = Paste.find_by(token: response.structured_content[:token]) + assert_includes paste.content, "md-body", "expected the branded Markdown wrapper" + assert_includes paste.content, "x

", format: "html") + assert_equal "paste.html", Paste.find_by(token: html.structured_content[:token]).original_filename + + markdown = create(content: "# x", format: "markdown") + assert_equal "paste.md", Paste.find_by(token: markdown.structured_content[:token]).original_filename + end + + test "a supplied filename whose extension disagrees with format is an error" do + response = create(content: "

x

", format: "html", filename: "note.md") + + assert response.error? + assert_equal "filename_format_mismatch", response.structured_content[:code] + assert_equal "filename", response.structured_content[:field] + + reverse = create(content: "# x", format: "markdown", filename: "page.html") + assert reverse.error? + assert_equal "filename_format_mismatch", reverse.structured_content[:code] + end + + test "an agreeing filename is accepted" do + response = create(content: "# Title\n\nx", format: "markdown", filename: "guide.markdown") + + assert_not response.error? + assert_equal "guide.markdown", Paste.find_by(token: response.structured_content[:token]).original_filename + end + + test "folder_name auto-creates a missing folder and flags it" do + assert_difference -> { @alice.folders.count }, 1 do + @response = create(content: "

x

", format: "html", folder_name: "Fresh Folder") + end + + assert_equal true, @response.structured_content[:folder_created] + assert_equal "Fresh Folder", @response.structured_content.dig(:folder, :name) + assert @alice.folders.exists?(name: "Fresh Folder") + end + + test "folder_name reuses an existing folder case-insensitively without duplicating" do + existing = @alice.folders.create!(name: "Work") + + assert_no_difference -> { @alice.folders.count } do + @response = create(content: "

x

", format: "html", folder_name: "work") + end + + assert_equal false, @response.structured_content[:folder_created] + assert_equal existing.id, @response.structured_content.dig(:folder, :id) + end + + test "a folder_id belonging to another user is an ownership (not-found) error" do + response = create(content: "

x

", format: "html", folder_id: folders(:bob_notes).id) + + assert response.error? + assert_equal "folder_not_found", response.structured_content[:code] + assert_equal "folder_id", response.structured_content[:field] + end + + test "folder_id and folder_name naming different folders is a conflict" do + response = create(content: "

x

", format: "html", folder_id: folders(:projects).id, folder_name: "Something Else") + + assert response.error? + assert_equal "folder_mismatch", response.structured_content[:code] + assert_equal "folder_name", response.structured_content[:field] + end + + test "an invalid custom_subdomain returns the error contract with the field" do + response = create(content: "

x

", format: "html", custom_subdomain: "bad_sub") + + assert response.error? + assert_equal "validation_failed", response.structured_content[:code] + assert_equal "custom_subdomain", response.structured_content[:field] + end + + test "content over the size limit is a validation error on the content field" do + oversized = "a" * (Paste::MAX_CONTENT_BYTES + 1) + + assert_no_difference -> { Paste.count } do + @response = create(content: oversized, format: "html") + end + + assert @response.error? + assert_equal "validation_failed", @response.structured_content[:code] + assert_equal "content", @response.structured_content[:field] + end + + test "a password sets password_protected" do + response = create(content: "

x

", format: "html", password: "s3cret") + + assert_not response.error? + assert_equal true, response.structured_content[:password_protected] + assert Paste.find_by(token: response.structured_content[:token]).password_protected? + end + + test "an auto-created folder is rolled back when the paste itself is invalid" do + assert_no_difference -> { @alice.folders.count } do + @response = create(content: "a" * (Paste::MAX_CONTENT_BYTES + 1), format: "html", folder_name: "Doomed") + end + + assert @response.error? + assert_not @alice.folders.exists?(name: "Doomed") + end + + private + def create(**args) + McpTools::CreatePaste.call(**args, server_context: @ctx) + end +end diff --git a/test/models/mcp_tools/error_contract_test.rb b/test/models/mcp_tools/error_contract_test.rb new file mode 100644 index 0000000..6fa4438 --- /dev/null +++ b/test/models/mcp_tools/error_contract_test.rb @@ -0,0 +1,31 @@ +require "test_helper" + +# Every tool's domain failures come out of the shared BaseTool helper, so their +# error responses are one stable shape across tools: an SDK error response whose +# structuredContent is { code, message, field? }. +class McpTools::ErrorContractTest < ActiveSupport::TestCase + setup do + @ctx = { user: users(:alice) } + end + + test "field-bearing errors from different tools share an identical key set" do + from_create = McpTools::CreatePaste.call( + content: "

x

", format: "html", filename: "note.md", server_context: @ctx + ) + from_list = McpTools::ListPastes.call(folder_id: 999_999, server_context: @ctx) + + assert from_create.error? + assert from_list.error? + assert_equal %i[ code field message ], from_create.structured_content.keys.sort + assert_equal from_create.structured_content.keys.sort, from_list.structured_content.keys.sort + end + + test "every error carries a machine code and a human message" do + error = McpTools::ListPastes.call(folder_name: "nope", server_context: @ctx) + + assert error.error? + assert error.structured_content[:code].is_a?(String) + assert error.structured_content[:message].is_a?(String) + assert error.structured_content[:message].present? + end +end diff --git a/test/models/mcp_tools/list_folders_test.rb b/test/models/mcp_tools/list_folders_test.rb new file mode 100644 index 0000000..4d74304 --- /dev/null +++ b/test/models/mcp_tools/list_folders_test.rb @@ -0,0 +1,42 @@ +require "test_helper" + +class McpTools::ListFoldersTest < ActiveSupport::TestCase + setup do + @alice = users(:alice) + @bob = users(:bob) + @ctx = { user: @alice } + end + + test "lists the user's folders ordered by name, case-insensitively" do + @alice.folders.create!(name: "beta") + @alice.folders.create!(name: "Alpha") + + names = list.structured_content[:folders].map { |folder| folder[:name] } + + # "Projects" comes from the fixture; ordering is by LOWER(name). + assert_equal [ "Alpha", "beta", "Projects" ], names + end + + test "excludes other users' folders" do + names = list.structured_content[:folders].map { |folder| folder[:name] } + + assert_not_includes names, folders(:bob_notes).name + end + + test "reports the paste count per folder" do + folder = @alice.folders.create!(name: "Counted") + 2.times { Paste.create!(content: "

x

", original_filename: "p.html", user: @alice, folder: folder) } + Paste.create!(content: "

unfiled

", original_filename: "p.html", user: @alice) + + counted = list.structured_content[:folders].find { |f| f[:name] == "Counted" } + projects = list.structured_content[:folders].find { |f| f[:name] == "Projects" } + + assert_equal 2, counted[:pastes_count] + assert_equal 0, projects[:pastes_count] + end + + private + def list + McpTools::ListFolders.call(server_context: @ctx) + end +end diff --git a/test/models/mcp_tools/list_pastes_test.rb b/test/models/mcp_tools/list_pastes_test.rb new file mode 100644 index 0000000..ece576b --- /dev/null +++ b/test/models/mcp_tools/list_pastes_test.rb @@ -0,0 +1,109 @@ +require "test_helper" + +class McpTools::ListPastesTest < ActiveSupport::TestCase + setup do + @alice = users(:alice) + @bob = users(:bob) + @ctx = { user: @alice } + end + + test "lists the user's pastes newest first" do + first = create_paste_for(@alice, "

1

") + second = create_paste_for(@alice, "

2

") + third = create_paste_for(@alice, "

3

") + + tokens = list.structured_content[:pastes].map { |paste| paste[:token] } + + assert_equal [ third.token, second.token, first.token ], tokens + end + + test "does not include another user's pastes" do + mine = create_paste_for(@alice, "

mine

") + theirs = create_paste_for(@bob, "

theirs

") + + tokens = list.structured_content[:pastes].map { |paste| paste[:token] } + + assert_includes tokens, mine.token + assert_not_includes tokens, theirs.token + assert_equal 1, list.structured_content[:total_count] + end + + test "filters by folder id" do + folder = @alice.folders.create!(name: "Filtered") + inside = create_paste_for(@alice, "

in

", folder: folder) + create_paste_for(@alice, "

out

") + + result = list(folder_id: folder.id) + + tokens = result.structured_content[:pastes].map { |paste| paste[:token] } + assert_equal [ inside.token ], tokens + assert_equal 1, result.structured_content[:total_count] + end + + test "filters by folder name" do + folder = @alice.folders.create!(name: "Named Filter") + inside = create_paste_for(@alice, "

in

", folder: folder) + create_paste_for(@alice, "

out

") + + tokens = list(folder_name: "named filter").structured_content[:pastes].map { |paste| paste[:token] } + + assert_equal [ inside.token ], tokens + end + + test "an unknown folder id is an error, not an empty list" do + result = list(folder_id: 999_999) + + assert result.error? + assert_equal "folder_not_found", result.structured_content[:code] + assert_equal "folder_id", result.structured_content[:field] + end + + test "an unknown folder name is an error" do + result = list(folder_name: "no such folder") + + assert result.error? + assert_equal "folder_not_found", result.structured_content[:code] + assert_equal "folder_name", result.structured_content[:field] + end + + test "paginates with a fixed page size of 20, newest first across pages" do + 21.times { |i| create_paste_for(@alice, "

#{i}

") } + + first_page = list(page: 1).structured_content + assert_equal 20, first_page[:pastes].length + assert_equal 1, first_page[:page] + assert_equal 21, first_page[:total_count] + + second_page = list(page: 2).structured_content + assert_equal 1, second_page[:pastes].length + assert_equal 2, second_page[:page] + assert_equal 21, second_page[:total_count] + end + + test "a page past the end is empty but still reports the total" do + create_paste_for(@alice, "

only

") + + result = list(page: 5).structured_content + + assert_empty result[:pastes] + assert_equal 1, result[:total_count] + end + + test "reports each paste's content byte size without loading the body" do + paste = create_paste_for(@alice, "

measured

") + + summary = list.structured_content[:pastes].first + + assert_equal paste.content.bytesize, summary[:content_bytes] + assert summary[:content_bytes].positive? + end + + private + def list(**args) + McpTools::ListPastes.call(**args, server_context: @ctx) + end + + def create_paste_for(user, content, folder: nil) + Paste.create!(content: content, original_filename: "p.html", user: user, folder: folder) + end +end From a132883c49f97b25c8a89586b0374026a1876007 Mon Sep 17 00:00:00 2001 From: Ali Hamdi Ali Fadel Date: Fri, 10 Jul 2026 13:33:42 +0300 Subject: [PATCH 07/22] feat(mcp): add Phase 2 tools (update/configure/get_paste, get_paste_stats, folder CRUD) Rounds out the MCP tool catalog to all 10 tools from the plan: update_paste (destructive republish, format always derives the rendering filename so an old .md-sourced paste can never be silently re-rendered as Markdown when updated with format: "html"), configure_paste (password/subdomain/folder settings, independent of content, with conflicting-argument and no-settings-supplied guards), get_paste (stored HTML plus an optional best-effort Markdown conversion), get_paste_stats (aggregate-only view analytics, zero-filled by source, no referrers/user-agents/IPs), and create_folder/rename_folder/delete_folder (delete nullifies pastes and revokes folder-scoped API keys, gated on confirm: true). Extracted filename-resolution and folder-resolve-or-create logic from create_paste into BaseTool so update_paste and configure_paste share the exact same semantics rather than reimplementing them. Extends the Phase 1 integration test to assert the full-scope tools/list now returns all 10 tools and a read-only token sees exactly the 4 read tools, plus an end-to-end update_paste call through POST /mcp. Co-Authored-By: Claude Fable 5 --- app/models/mcp_tools/base_tool.rb | 74 ++++++++ app/models/mcp_tools/configure_paste.rb | 162 ++++++++++++++++ app/models/mcp_tools/create_folder.rb | 51 +++++ app/models/mcp_tools/create_paste.rb | 60 +----- app/models/mcp_tools/delete_folder.rb | 76 ++++++++ app/models/mcp_tools/get_paste.rb | 84 +++++++++ app/models/mcp_tools/get_paste_stats.rb | 97 ++++++++++ app/models/mcp_tools/rename_folder.rb | 58 ++++++ app/models/mcp_tools/update_paste.rb | 95 ++++++++++ config/initializers/mcp_tools.rb | 7 + test/integration/mcp_tools_test.rb | 32 +++- test/models/mcp_tools/configure_paste_test.rb | 177 ++++++++++++++++++ test/models/mcp_tools/create_folder_test.rb | 52 +++++ test/models/mcp_tools/delete_folder_test.rb | 88 +++++++++ test/models/mcp_tools/get_paste_stats_test.rb | 104 ++++++++++ test/models/mcp_tools/get_paste_test.rb | 76 ++++++++ test/models/mcp_tools/rename_folder_test.rb | 64 +++++++ test/models/mcp_tools/update_paste_test.rb | 115 ++++++++++++ 18 files changed, 1409 insertions(+), 63 deletions(-) create mode 100644 app/models/mcp_tools/configure_paste.rb create mode 100644 app/models/mcp_tools/create_folder.rb create mode 100644 app/models/mcp_tools/delete_folder.rb create mode 100644 app/models/mcp_tools/get_paste.rb create mode 100644 app/models/mcp_tools/get_paste_stats.rb create mode 100644 app/models/mcp_tools/rename_folder.rb create mode 100644 app/models/mcp_tools/update_paste.rb create mode 100644 test/models/mcp_tools/configure_paste_test.rb create mode 100644 test/models/mcp_tools/create_folder_test.rb create mode 100644 test/models/mcp_tools/delete_folder_test.rb create mode 100644 test/models/mcp_tools/get_paste_stats_test.rb create mode 100644 test/models/mcp_tools/get_paste_test.rb create mode 100644 test/models/mcp_tools/rename_folder_test.rb create mode 100644 test/models/mcp_tools/update_paste_test.rb diff --git a/app/models/mcp_tools/base_tool.rb b/app/models/mcp_tools/base_tool.rb index b1c8d87..1ea73a8 100644 --- a/app/models/mcp_tools/base_tool.rb +++ b/app/models/mcp_tools/base_tool.rb @@ -28,6 +28,15 @@ class BaseTool < MCP::Tool "folder" => "folder_id" }.freeze + # format => synthetic filename used when a tool omits `filename`, and the + # extension that format's filename must carry when one is supplied. Shared + # by every tool that republishes content (create_paste, update_paste) so + # `Paste.render_content`/`Paste#republish` are always keyed on a filename + # derived from the required `format` argument -- never inferred or left to + # fall back to a paste's previously stored filename. + SYNTHETIC_FILENAME = { "html" => "paste.html", "markdown" => "paste.md" }.freeze + EXTENSION_FOR_FORMAT = { "html" => Paste::HTML_EXTENSION, "markdown" => Paste::MARKDOWN_EXTENSION }.freeze + class << self private @@ -96,6 +105,64 @@ def folder_ref(paste) paste.folder && { id: paste.folder_id, name: paste.folder.name } end + # Returns [filename, error]. A supplied filename must carry the extension + # `format` implies; otherwise a synthetic name drives render_content, so + # rendering always agrees with the caller's explicit `format`. + def resolve_filename(format, filename) + return [ SYNTHETIC_FILENAME.fetch(format), nil ] if filename.blank? + + extension = File.extname(filename) + return [ filename, nil ] if extension.match?(EXTENSION_FOR_FORMAT.fetch(format)) + + [ nil, failure( + code: "filename_format_mismatch", + message: "filename #{filename.inspect} does not match format #{format.inspect}.", + field: "filename" + ) ] + end + + # Look up a folder the user owns, by id or by (case-insensitive) name, for + # write-side use, auto-creating a missing named folder. Returns + # [folder, folder_created, error]. folder_id wins and must belong to the + # user; a folder_id + folder_name pair naming different folders is a + # conflict. Shared by create_paste and configure_paste. + def resolve_or_create_folder(user, folder_id, folder_name) + requested_name = folder_name.to_s.strip.presence + + if folder_id.present? + folder = user.folders.find_by(id: folder_id) + return [ nil, false, failure(code: "folder_not_found", message: "No folder with id #{folder_id}.", field: "folder_id") ] if folder.nil? + + if requested_name && !folder.name.casecmp?(requested_name) + return [ nil, false, failure(code: "folder_mismatch", message: "folder_id and folder_name refer to different folders.", field: "folder_name") ] + end + + [ folder, false, nil ] + elsif requested_name + find_or_create_folder(user, requested_name) + else + [ nil, false, nil ] + end + end + + # Find-or-create by name, tolerant of a concurrent creator, mirroring + # Api::PastesController#find_or_create_named_folder. Returns + # [folder, folder_created, error]. + def find_or_create_folder(user, name) + existing = user.folders.where("LOWER(name) = ?", name.downcase).first + return [ existing, false, nil ] if existing + + folder = user.folders.new(name: name) + begin + Folder.transaction(requires_new: true) { folder.save! } + [ folder, true, nil ] + rescue ActiveRecord::RecordInvalid + [ nil, false, validation_error(folder) ] + rescue ActiveRecord::RecordNotUnique + [ user.folders.find_by!("LOWER(name) = ?", name.downcase), false, nil ] + end + end + def issuer McpOauth::CONFIG[:issuer] end @@ -128,6 +195,13 @@ def paste_detail(paste, folder_created:) password_protected: paste.password_protected? } end + + # paste_detail without the folder_created flag, for tools that never + # create a folder as a side effect (update_paste, get_paste) -- the field + # would only ever read false and invite a misleading reading. + def paste_summary(paste) + paste_detail(paste, folder_created: false).except(:folder_created) + end end end end diff --git a/app/models/mcp_tools/configure_paste.rb b/app/models/mcp_tools/configure_paste.rb new file mode 100644 index 0000000..c1fd1d8 --- /dev/null +++ b/app/models/mcp_tools/configure_paste.rb @@ -0,0 +1,162 @@ +module McpTools + # Changes a user-owned paste's settings -- password, custom subdomain, + # folder -- without touching its content. Every setting is independently + # optional, but at least one must be supplied, and a "set" argument and its + # matching "clear" argument are mutually exclusive (the agent must pick one). + class ConfigurePaste < BaseTool + tool_name "configure_paste" + description <<~TEXT.strip + Change an existing paste's settings without republishing its content: set + or clear password protection, set or clear a custom_subdomain, or file it + into (or out of) a folder. At least one setting must be supplied. + Destructive: clearing password protection exposes the paste to anyone with + the link -- that is an exposure event even if a new password is set + later -- and replacing a custom_subdomain immediately releases the old one + for anyone else to claim. Supplying folder_name for a folder that does not + exist creates it (the result sets folder_created: true). Only pastes owned + by the authenticated user can be configured. + TEXT + + input_schema( + type: "object", + properties: { + token: { type: "string", description: "The paste's token." }, + password: { type: "string", description: "Set (or replace) the paste's password. Conflicts with clear_password." }, + clear_password: { type: "boolean", description: "Remove password protection, exposing the paste. Conflicts with password." }, + custom_subdomain: { type: "string", description: "Set (or replace) the paste's custom subdomain, releasing any previous one. Conflicts with clear_custom_subdomain." }, + clear_custom_subdomain: { type: "boolean", description: "Remove the custom subdomain, releasing it. Conflicts with custom_subdomain." }, + folder_id: { type: "integer", description: "File the paste into this folder (by id). Conflicts with clear_folder." }, + folder_name: { type: "string", description: "File the paste into this folder (by name); creates it if missing. Conflicts with clear_folder." }, + clear_folder: { type: "boolean", description: "Remove the paste from its folder. Conflicts with folder_id/folder_name." } + }, + required: [ "token" ], + additionalProperties: false + ) + + output_schema( + type: "object", + properties: { + token: { type: "string" }, + title: { type: "string" }, + url: { type: "string" }, + live_url: { type: "string" }, + raw_url: { type: "string" }, + render_url: { type: "string" }, + markdown_url: { type: "string" }, + folder: { + type: [ "object", "null" ], + properties: { id: { type: "integer" }, name: { type: "string" } } + }, + folder_created: { type: "boolean" }, + password_protected: { type: "boolean" } + }, + required: %w[ token title url live_url raw_url render_url markdown_url folder folder_created password_protected ] + ) + + annotations( + read_only_hint: false, + destructive_hint: true, + idempotent_hint: true, + open_world_hint: false + ) + + class << self + def call(token:, password: nil, clear_password: nil, custom_subdomain: nil, clear_custom_subdomain: nil, + folder_id: nil, folder_name: nil, clear_folder: nil, server_context:) + user = user_for(server_context) + + paste = user.pastes.find_by(token: token) + return paste_not_found(token) if paste.nil? + + settings_error = validate_settings( + password:, clear_password:, custom_subdomain:, clear_custom_subdomain:, folder_id:, folder_name:, clear_folder: + ) + return settings_error if settings_error + + result = nil + Paste.transaction do + apply_password!(paste, password, clear_password) + apply_custom_subdomain!(paste, custom_subdomain, clear_custom_subdomain) + + folder_created, folder_error = apply_folder!(paste, user, folder_id, folder_name, clear_folder) + if folder_error + result = folder_error + raise ActiveRecord::Rollback + end + + if paste.save + result = ok(paste_detail(paste, folder_created: folder_created)) + else + result = validation_error(paste) + raise ActiveRecord::Rollback + end + end + result + end + + private + def paste_not_found(token) + failure(code: "paste_not_found", message: "No paste with token #{token.inspect}.", field: "token") + end + + def validate_settings(password:, clear_password:, custom_subdomain:, clear_custom_subdomain:, folder_id:, folder_name:, clear_folder:) + unless settings_supplied?( + password:, clear_password:, custom_subdomain:, clear_custom_subdomain:, folder_id:, folder_name:, clear_folder: + ) + return failure(code: "no_settings_provided", message: "Supply at least one setting to change.") + end + + if password.present? && clear_password + return failure(code: "conflicting_arguments", message: "password and clear_password cannot both be given.", field: "clear_password") + end + + if custom_subdomain.present? && clear_custom_subdomain + return failure(code: "conflicting_arguments", message: "custom_subdomain and clear_custom_subdomain cannot both be given.", field: "clear_custom_subdomain") + end + + if (folder_id.present? || folder_name.present?) && clear_folder + return failure(code: "conflicting_arguments", message: "folder_id/folder_name and clear_folder cannot both be given.", field: "clear_folder") + end + + nil + end + + def settings_supplied?(password:, clear_password:, custom_subdomain:, clear_custom_subdomain:, folder_id:, folder_name:, clear_folder:) + password.present? || !clear_password.nil? || custom_subdomain.present? || + !clear_custom_subdomain.nil? || folder_id.present? || folder_name.present? || !clear_folder.nil? + end + + def apply_password!(paste, password, clear_password) + if clear_password + paste.password_digest = nil + elsif password.present? + paste.password = password + end + end + + def apply_custom_subdomain!(paste, custom_subdomain, clear_custom_subdomain) + if clear_custom_subdomain + paste.custom_subdomain = nil + elsif custom_subdomain.present? + paste.custom_subdomain = custom_subdomain + end + end + + # Returns [folder_created, error]; mutates paste.folder in place. + def apply_folder!(paste, user, folder_id, folder_name, clear_folder) + if clear_folder + paste.folder = nil + return [ false, nil ] + end + + return [ false, nil ] unless folder_id.present? || folder_name.present? + + folder, folder_created, folder_error = resolve_or_create_folder(user, folder_id, folder_name) + return [ false, folder_error ] if folder_error + + paste.folder = folder + [ folder_created, nil ] + end + end + end +end diff --git a/app/models/mcp_tools/create_folder.rb b/app/models/mcp_tools/create_folder.rb new file mode 100644 index 0000000..6b17c17 --- /dev/null +++ b/app/models/mcp_tools/create_folder.rb @@ -0,0 +1,51 @@ +module McpTools + # Creates a new, empty folder owned by the authenticated user. Folder names + # are unique per user, case-insensitively (model validation). + class CreateFolder < BaseTool + tool_name "create_folder" + description <<~TEXT.strip + Create a new, empty folder owned by the authenticated user. Folder names + must be unique per user (case-insensitive) -- a duplicate name is a + validation error. + TEXT + + input_schema( + type: "object", + properties: { + name: { type: "string", description: "The folder's name." } + }, + required: [ "name" ], + additionalProperties: false + ) + + output_schema( + type: "object", + properties: { + id: { type: "integer" }, + name: { type: "string" }, + pastes_count: { type: "integer" } + }, + required: %w[ id name pastes_count ] + ) + + annotations( + read_only_hint: false, + destructive_hint: false, + idempotent_hint: false, + open_world_hint: false + ) + + class << self + def call(name:, server_context:) + user = user_for(server_context) + folder = user.folders.new(name: name) + + if folder.save + ok(id: folder.id, name: folder.name, pastes_count: 0) + else + validation_error(folder) + end + end + end + end +end diff --git a/app/models/mcp_tools/create_paste.rb b/app/models/mcp_tools/create_paste.rb index 352f2ff..d742079 100644 --- a/app/models/mcp_tools/create_paste.rb +++ b/app/models/mcp_tools/create_paste.rb @@ -9,9 +9,6 @@ module McpTools # right thing. There is deliberately no `title` argument -- the title is always # derived from the content's on save. class CreatePaste < BaseTool - SYNTHETIC_FILENAME = { "html" => "paste.html", "markdown" => "paste.md" }.freeze - EXTENSION_FOR_FORMAT = { "html" => Paste::HTML_EXTENSION, "markdown" => Paste::MARKDOWN_EXTENSION }.freeze - tool_name "create_paste" description <<~TEXT.strip Create and publish a new paste owned by the authenticated user. Side effect: \ @@ -95,7 +92,7 @@ def call(content:, format:, filename: nil, custom_subdomain: nil, password: nil, result = nil Paste.transaction do - folder, folder_created, folder_error = resolve_folder(user, folder_id, folder_name) + folder, folder_created, folder_error = resolve_or_create_folder(user, folder_id, folder_name) if folder_error result = folder_error raise ActiveRecord::Rollback @@ -113,61 +110,6 @@ def call(content:, format:, filename: nil, custom_subdomain: nil, password: nil, end private - # Returns [filename, error]. A supplied filename must carry the extension - # `format` implies; otherwise a synthetic name drives render_content. - def resolve_filename(format, filename) - return [ SYNTHETIC_FILENAME.fetch(format), nil ] if filename.blank? - - extension = File.extname(filename) - return [ filename, nil ] if extension.match?(EXTENSION_FOR_FORMAT.fetch(format)) - - [ nil, failure( - code: "filename_format_mismatch", - message: "filename #{filename.inspect} does not match format #{format.inspect}.", - field: "filename" - ) ] - end - - # Returns [folder, folder_created, error]. folder_id wins and must belong - # to the user; a folder_id + folder_name pair that name different folders - # is a conflict; a lone folder_name auto-creates a missing folder. - def resolve_folder(user, folder_id, folder_name) - requested_name = folder_name.to_s.strip.presence - - if folder_id.present? - folder = user.folders.find_by(id: folder_id) - return [ nil, false, failure(code: "folder_not_found", message: "No folder with id #{folder_id}.", field: "folder_id") ] if folder.nil? - - if requested_name && !folder.name.casecmp?(requested_name) - return [ nil, false, failure(code: "folder_mismatch", message: "folder_id and folder_name refer to different folders.", field: "folder_name") ] - end - - [ folder, false, nil ] - elsif requested_name - find_or_create_folder(user, requested_name) - else - [ nil, false, nil ] - end - end - - # Find-or-create by name, tolerant of a concurrent creator, mirroring - # Api::PastesController#find_or_create_named_folder. Returns - # [folder, folder_created, error]. - def find_or_create_folder(user, name) - existing = user.folders.where("LOWER(name) = ?", name.downcase).first - return [ existing, false, nil ] if existing - - folder = user.folders.new(name: name) - begin - Folder.transaction(requires_new: true) { folder.save! } - [ folder, true, nil ] - rescue ActiveRecord::RecordInvalid - [ nil, false, validation_error(folder) ] - rescue ActiveRecord::RecordNotUnique - [ user.folders.find_by!("LOWER(name) = ?", name.downcase), false, nil ] - end - end - def build_paste(user, content, filename, folder, custom_subdomain, password) paste = Paste.new( content: Paste.render_content(content, filename), diff --git a/app/models/mcp_tools/delete_folder.rb b/app/models/mcp_tools/delete_folder.rb new file mode 100644 index 0000000..5b9102f --- /dev/null +++ b/app/models/mcp_tools/delete_folder.rb @@ -0,0 +1,76 @@ +module McpTools + # Destroys a folder owned by the authenticated user. Pastes are never + # deleted -- the folder's pastes are nullified (survive, unfiled) -- and any + # API keys scoped to the folder are revoked, both via Folder's own + # `dependent: :nullify`/`before_destroy` callbacks. `confirm: true` is + # required; it is accidental-action friction (the agent supplies it itself), + # not a security boundary -- the meaningful protections are the honest + # destructive_hint annotation and the client's own approval flow. + class DeleteFolder < BaseTool + tool_name "delete_folder" + description <<~TEXT.strip + Permanently delete a folder owned by the authenticated user. Destructive + and irreversible: pastes filed in the folder are NOT deleted -- they + survive, unfiled (their folder_id becomes null) -- and any API keys + scoped to this folder are revoked. Requires confirm: true; any other + value is refused with no changes made. + TEXT + + input_schema( + type: "object", + properties: { + folder_id: { type: "integer", description: "The id of the folder to delete." }, + confirm: { type: "boolean", description: "Must be true to proceed. Any other value is refused." } + }, + required: [ "folder_id", "confirm" ], + additionalProperties: false + ) + + output_schema( + type: "object", + properties: { + deleted: { type: "boolean" }, + unfiled_pastes_count: { type: "integer", description: "Pastes that were in this folder and are now unfiled (not deleted)." }, + revoked_api_keys_count: { type: "integer", description: "API keys scoped to this folder that were revoked." } + }, + required: %w[ deleted unfiled_pastes_count revoked_api_keys_count ] + ) + + annotations( + read_only_hint: false, + destructive_hint: true, + idempotent_hint: false, + open_world_hint: false + ) + + class << self + def call(folder_id:, confirm:, server_context:) + user = user_for(server_context) + folder = user.folders.find_by(id: folder_id) + return folder_not_found(folder_id) if folder.nil? + + return confirmation_required unless confirm == true + + unfiled_pastes_count = folder.pastes.count + revoked_api_keys_count = folder.api_keys.active.count + + folder.destroy! + + ok(deleted: true, unfiled_pastes_count: unfiled_pastes_count, revoked_api_keys_count: revoked_api_keys_count) + end + + private + def folder_not_found(folder_id) + failure(code: "folder_not_found", message: "No folder with id #{folder_id}.", field: "folder_id") + end + + def confirmation_required + failure( + code: "confirmation_required", + message: "Set confirm: true to permanently delete this folder.", + field: "confirm" + ) + end + end + end +end diff --git a/app/models/mcp_tools/get_paste.rb b/app/models/mcp_tools/get_paste.rb new file mode 100644 index 0000000..60054fe --- /dev/null +++ b/app/models/mcp_tools/get_paste.rb @@ -0,0 +1,84 @@ +module McpTools + # Fetches a single user-owned paste's metadata and content. The returned + # `content` is always the stored HTML -- Markdown ingests are rendered to + # HTML at create/update time and the original Markdown source is never + # retained, so there is no lossless way back to it. `include_markdown` opts + # into a best-effort, lossy HTML-to-Markdown conversion (the same one behind + # GET /p/:token/markdown) for callers that want a rough Markdown view anyway. + class GetPaste < BaseTool + tool_name "get_paste" + description <<~TEXT.strip + Fetch a single paste owned by the authenticated user, by token: its + metadata, URLs, and content. `content` is always the stored HTML -- + Markdown-created pastes are rendered to HTML at ingest and the original + Markdown source is not retained. Set include_markdown: true to also get a + best-effort, lossy HTML-to-Markdown conversion of the content (the same + conversion behind the /markdown URL); it is not the original source. + Read-only. + TEXT + + input_schema( + type: "object", + properties: { + token: { type: "string", description: "The paste's token." }, + include_markdown: { + type: "boolean", + description: "When true, also return a best-effort, lossy HTML-to-Markdown conversion of content. Default false." + } + }, + required: [ "token" ], + additionalProperties: false + ) + + output_schema( + type: "object", + properties: { + token: { type: "string" }, + title: { type: "string" }, + url: { type: "string" }, + live_url: { type: "string" }, + raw_url: { type: "string" }, + render_url: { type: "string" }, + markdown_url: { type: "string" }, + folder: { + type: [ "object", "null" ], + properties: { id: { type: "integer" }, name: { type: "string" } } + }, + password_protected: { type: "boolean" }, + content: { type: "string", description: "The stored HTML -- never the original Markdown source." }, + content_bytes: { type: "integer" }, + markdown: { type: "string", description: "Present only when include_markdown was true: a best-effort, lossy HTML-to-Markdown conversion." } + }, + required: %w[ token title url live_url raw_url render_url markdown_url folder password_protected content content_bytes ] + ) + + annotations( + read_only_hint: true, + destructive_hint: false, + idempotent_hint: true, + open_world_hint: false + ) + + class << self + def call(token:, include_markdown: false, server_context:) + user = user_for(server_context) + + paste = user.pastes.find_by(token: token) + return paste_not_found(token) if paste.nil? + + payload = paste_summary(paste).merge( + content: paste.content, + content_bytes: paste.content.bytesize + ) + payload[:markdown] = paste.to_markdown if include_markdown + + ok(payload) + end + + private + def paste_not_found(token) + failure(code: "paste_not_found", message: "No paste with token #{token.inspect}.", field: "token") + end + end + end +end diff --git a/app/models/mcp_tools/get_paste_stats.rb b/app/models/mcp_tools/get_paste_stats.rb new file mode 100644 index 0000000..5ce4c6b --- /dev/null +++ b/app/models/mcp_tools/get_paste_stats.rb @@ -0,0 +1,97 @@ +module McpTools + # Aggregate-only view analytics for a single user-owned paste. Deliberately + # never returns anything from the raw paste_view rows beyond counts: no + # referrer or user-agent strings, and no IPs (only an HMAC digest is stored + # for those, and even that never leaves this tool). + class GetPasteStats < BaseTool + RECENT_DAYS = 30 + + tool_name "get_paste_stats" + description <<~TEXT.strip + Aggregate view analytics for a paste owned by the authenticated user, by + token: total views_count, a views_by_source breakdown (zero-filled across + all sources: show, live, raw, render), and a recent_views daily timeline + for the last #{RECENT_DAYS} days (days with zero views are omitted from + the timeline). Aggregate-only: never returns referrers, user agents, or + IP addresses. Read-only. + TEXT + + input_schema( + type: "object", + properties: { + token: { type: "string", description: "The paste's token." } + }, + required: [ "token" ], + additionalProperties: false + ) + + output_schema( + type: "object", + properties: { + views_count: { type: "integer" }, + views_by_source: { + type: "object", + properties: { + show: { type: "integer" }, + live: { type: "integer" }, + raw: { type: "integer" }, + render: { type: "integer" } + }, + required: %w[ show live raw render ] + }, + recent_views: { + type: "array", + description: "One entry per day with at least one view, in the last #{RECENT_DAYS} days. Zero-view days are omitted.", + items: { + type: "object", + properties: { + date: { type: "string", description: "ISO 8601 date (YYYY-MM-DD)." }, + count: { type: "integer" } + }, + required: %w[ date count ] + } + } + }, + required: %w[ views_count views_by_source recent_views ] + ) + + annotations( + read_only_hint: true, + destructive_hint: false, + idempotent_hint: true, + open_world_hint: false + ) + + class << self + def call(token:, server_context:) + user = user_for(server_context) + + paste = user.pastes.find_by(token: token) + return paste_not_found(token) if paste.nil? + + ok( + views_count: paste.views_count, + views_by_source: views_by_source(paste), + recent_views: recent_views(paste) + ) + end + + private + def paste_not_found(token) + failure(code: "paste_not_found", message: "No paste with token #{token.inspect}.", field: "token") + end + + def views_by_source(paste) + counts = paste.paste_views.group(:source).count + PasteView::SOURCES.each_with_object({}) { |source, hash| hash[source.to_sym] = counts.fetch(source, 0) } + end + + def recent_views(paste) + since = RECENT_DAYS.days.ago.beginning_of_day + counts = paste.paste_views.where(created_at: since..).group("DATE(created_at)").count + + counts.map { |date, count| { date: date.to_s, count: count } }.sort_by { |entry| entry[:date] } + end + end + end +end diff --git a/app/models/mcp_tools/rename_folder.rb b/app/models/mcp_tools/rename_folder.rb new file mode 100644 index 0000000..cb2e37d --- /dev/null +++ b/app/models/mcp_tools/rename_folder.rb @@ -0,0 +1,58 @@ +module McpTools + # Renames a folder owned by the authenticated user. Uniqueness (per user, + # case-insensitive) is enforced by the same model validation as create_folder. + class RenameFolder < BaseTool + tool_name "rename_folder" + description <<~TEXT.strip + Rename a folder owned by the authenticated user. Folder names must be + unique per user (case-insensitive) -- a duplicate name is a validation + error. Only folders owned by the authenticated user can be renamed. + TEXT + + input_schema( + type: "object", + properties: { + folder_id: { type: "integer", description: "The id of the folder to rename." }, + name: { type: "string", description: "The folder's new name." } + }, + required: [ "folder_id", "name" ], + additionalProperties: false + ) + + output_schema( + type: "object", + properties: { + id: { type: "integer" }, + name: { type: "string" }, + pastes_count: { type: "integer" } + }, + required: %w[ id name pastes_count ] + ) + + annotations( + read_only_hint: false, + destructive_hint: false, + idempotent_hint: true, + open_world_hint: false + ) + + class << self + def call(folder_id:, name:, server_context:) + user = user_for(server_context) + folder = user.folders.find_by(id: folder_id) + return folder_not_found(folder_id) if folder.nil? + + if folder.update(name: name) + ok(id: folder.id, name: folder.name, pastes_count: folder.pastes.count) + else + validation_error(folder) + end + end + + private + def folder_not_found(folder_id) + failure(code: "folder_not_found", message: "No folder with id #{folder_id}.", field: "folder_id") + end + end + end +end diff --git a/app/models/mcp_tools/update_paste.rb b/app/models/mcp_tools/update_paste.rb new file mode 100644 index 0000000..6a7ee91 --- /dev/null +++ b/app/models/mcp_tools/update_paste.rb @@ -0,0 +1,95 @@ +module McpTools + # Republishes an existing, user-owned paste's content. `format` is required + # and explicit for the same reason as create_paste: `Paste#republish` keeps + # the paste's previously stored filename when none is supplied, so inferring + # nothing and instead always deriving a filename from `format` (or a + # supplied `filename` whose extension must agree with it) is the only way to + # guarantee HTML content is never accidentally run through the Markdown + # renderer (or vice versa) because an old filename disagreed with the new + # content. + class UpdatePaste < BaseTool + tool_name "update_paste" + description <<~TEXT.strip + Republish an existing paste's content, identified by token. Destructive: + this irreversibly overwrites the paste's current content -- there is no + version history to roll back to. `format` is required -- "html" stores + the content as-is, "markdown" renders it to a branded HTML page -- and is + always used to (re)derive the filename that drives rendering, never the + paste's previously stored filename. If `filename` is given its extension + must match `format`. Only pastes owned by the authenticated user can be + updated. Settings (password, custom_subdomain, folder) are untouched -- + use configure_paste for those. + TEXT + + input_schema( + type: "object", + properties: { + token: { type: "string", description: "The paste's token." }, + content: { + type: "string", + description: "The new paste body: HTML source when format is \"html\", GitHub-Flavored Markdown when format is \"markdown\"." + }, + format: { + type: "string", + enum: [ "html", "markdown" ], + description: "Required. \"html\" stores content verbatim; \"markdown\" renders it to a branded self-contained HTML page. Always drives the filename used for rendering -- never inferred from the paste's stored filename." + }, + filename: { + type: "string", + description: "Optional filename; its extension must match format (.html/.htm for html, .md/.markdown for markdown). For markdown it seeds the rendered <title>." + } + }, + required: [ "token", "content", "format" ], + additionalProperties: false + ) + + output_schema( + type: "object", + properties: { + token: { type: "string" }, + title: { type: "string" }, + url: { type: "string" }, + live_url: { type: "string" }, + raw_url: { type: "string" }, + render_url: { type: "string" }, + markdown_url: { type: "string" }, + folder: { + type: [ "object", "null" ], + properties: { id: { type: "integer" }, name: { type: "string" } } + }, + password_protected: { type: "boolean" } + }, + required: %w[ token title url live_url raw_url render_url markdown_url folder password_protected ] + ) + + annotations( + read_only_hint: false, + destructive_hint: true, + idempotent_hint: false, + open_world_hint: false + ) + + class << self + def call(token:, content:, format:, filename: nil, server_context:) + user = user_for(server_context) + + paste = user.pastes.find_by(token: token) + return paste_not_found(token) if paste.nil? + + resolved_filename, filename_error = resolve_filename(format, filename) + return filename_error if filename_error + + if paste.republish(content: content, original_filename: resolved_filename) + ok(paste_summary(paste)) + else + validation_error(paste) + end + end + + private + def paste_not_found(token) + failure(code: "paste_not_found", message: "No paste with token #{token.inspect}.", field: "token") + end + end + end +end diff --git a/config/initializers/mcp_tools.rb b/config/initializers/mcp_tools.rb index 7ad0057..5378240 100644 --- a/config/initializers/mcp_tools.rb +++ b/config/initializers/mcp_tools.rb @@ -10,6 +10,13 @@ # `register` overwrites, so this is idempotent. Rails.application.config.to_prepare do McpTools.register(McpTools::CreatePaste, scope: McpTools::WRITE_SCOPE) + McpTools.register(McpTools::UpdatePaste, scope: McpTools::WRITE_SCOPE) + McpTools.register(McpTools::ConfigurePaste, scope: McpTools::WRITE_SCOPE) + McpTools.register(McpTools::GetPaste, scope: McpTools::READ_SCOPE) + McpTools.register(McpTools::GetPasteStats, scope: McpTools::READ_SCOPE) McpTools.register(McpTools::ListPastes, scope: McpTools::READ_SCOPE) McpTools.register(McpTools::ListFolders, scope: McpTools::READ_SCOPE) + McpTools.register(McpTools::CreateFolder, scope: McpTools::WRITE_SCOPE) + McpTools.register(McpTools::RenameFolder, scope: McpTools::WRITE_SCOPE) + McpTools.register(McpTools::DeleteFolder, scope: McpTools::WRITE_SCOPE) end diff --git a/test/integration/mcp_tools_test.rb b/test/integration/mcp_tools_test.rb index 5a5f83d..257571c 100644 --- a/test/integration/mcp_tools_test.rb +++ b/test/integration/mcp_tools_test.rb @@ -13,13 +13,16 @@ class McpToolsIntegrationTest < ActionDispatch::IntegrationTest # --- tools/list is scope-filtered presentation --------------------------- - test "a full-scope token lists all three tools with annotations present" do + test "a full-scope token lists all ten tools with annotations present" do mcp_post(tools_list_body, token: read_write_token.plaintext_token) assert_response :ok tools = response.parsed_body.dig("result", "tools") names = tools.map { |tool| tool["name"] }.sort - assert_equal %w[ create_paste list_folders list_pastes ], names + assert_equal %w[ + configure_paste create_folder create_paste delete_folder get_paste + get_paste_stats list_folders list_pastes rename_folder update_paste + ], names create = tools.find { |tool| tool["name"] == "create_paste" } annotations = create.fetch("annotations") @@ -32,14 +35,20 @@ class McpToolsIntegrationTest < ActionDispatch::IntegrationTest read_tool = tools.find { |tool| tool["name"] == "list_pastes" } assert_equal true, read_tool.dig("annotations", "readOnlyHint") assert_equal true, read_tool.dig("annotations", "idempotentHint") + + update_tool = tools.find { |tool| tool["name"] == "update_paste" } + assert_equal true, update_tool.dig("annotations", "destructiveHint"), "update_paste must be flagged destructive" + + delete_tool = tools.find { |tool| tool["name"] == "delete_folder" } + assert_equal true, delete_tool.dig("annotations", "destructiveHint"), "delete_folder must be flagged destructive" end - test "a read-only token lists only the two read tools" do + test "a read-only token lists exactly the four read tools" do mcp_post(tools_list_body, token: read_token.plaintext_token) assert_response :ok names = response.parsed_body.dig("result", "tools").map { |tool| tool["name"] }.sort - assert_equal %w[ list_folders list_pastes ], names + assert_equal %w[ get_paste get_paste_stats list_folders list_pastes ], names end # --- tools/call ----------------------------------------------------------- @@ -92,6 +101,21 @@ class McpToolsIntegrationTest < ActionDispatch::IntegrationTest assert_equal 1, result.dig("structuredContent", "total_count") end + test "update_paste republishes a user-owned paste's content through the real endpoint" do + paste = @user.pastes.create!(content: "<title>Before

old

", original_filename: "paste.html") + + mcp_post( + tools_call_body("update_paste", token: paste.token, content: "After

new

", format: "html"), + token: read_write_token.plaintext_token + ) + + assert_response :ok + result = response.parsed_body["result"] + assert_not result["isError"] + assert_equal "After", result.dig("structuredContent", "title") + assert_equal "After

new

", paste.reload.content + end + test "list_folders returns a structured result for a read token" do @user.folders.create!(name: "Zeta") diff --git a/test/models/mcp_tools/configure_paste_test.rb b/test/models/mcp_tools/configure_paste_test.rb new file mode 100644 index 0000000..17392d1 --- /dev/null +++ b/test/models/mcp_tools/configure_paste_test.rb @@ -0,0 +1,177 @@ +require "test_helper" + +class McpTools::ConfigurePasteTest < ActiveSupport::TestCase + setup do + @alice = users(:alice) + @bob = users(:bob) + @ctx = { user: @alice } + end + + test "sets a password" do + paste = create_paste_for(@alice) + + response = configure(token: paste.token, password: "s3cret") + + assert_not response.error? + assert_equal true, response.structured_content[:password_protected] + assert paste.reload.password_protected? + end + + test "clear_password actually clears password protection" do + paste = create_paste_for(@alice, password: "s3cret") + assert paste.password_protected? + + response = configure(token: paste.token, clear_password: true) + + assert_not response.error? + assert_equal false, response.structured_content[:password_protected] + assert_not paste.reload.password_protected? + end + + test "replacing a custom_subdomain frees the old one" do + paste = create_paste_for(@alice, custom_subdomain: "old-sub") + assert Paste.exists?(custom_subdomain: "old-sub") + + response = configure(token: paste.token, custom_subdomain: "new-sub") + + assert_not response.error? + assert_equal "new-sub", paste.reload.custom_subdomain + assert_not Paste.exists?(custom_subdomain: "old-sub"), "the old subdomain must be released" + end + + test "clear_custom_subdomain removes the custom subdomain" do + paste = create_paste_for(@alice, custom_subdomain: "taken-sub") + + response = configure(token: paste.token, clear_custom_subdomain: true) + + assert_not response.error? + assert_nil paste.reload.custom_subdomain + assert_not Paste.exists?(custom_subdomain: "taken-sub") + end + + test "moves a paste into a folder by id and clears it back out" do + paste = create_paste_for(@alice) + folder = @alice.folders.create!(name: "Target") + + moved = configure(token: paste.token, folder_id: folder.id) + assert_not moved.error? + assert_equal folder.id, paste.reload.folder_id + assert_equal folder.id, moved.structured_content.dig(:folder, :id) + + cleared = configure(token: paste.token, clear_folder: true) + assert_not cleared.error? + assert_nil paste.reload.folder_id + assert_nil cleared.structured_content[:folder] + end + + test "folder_name auto-creates a missing folder and flags it" do + paste = create_paste_for(@alice) + + assert_difference -> { @alice.folders.count }, 1 do + @response = configure(token: paste.token, folder_name: "Fresh Folder") + end + + assert_not @response.error? + assert_equal true, @response.structured_content[:folder_created] + assert_equal "Fresh Folder", @response.structured_content.dig(:folder, :name) + end + + test "a folder_id belonging to another user is a not-found error" do + paste = create_paste_for(@alice) + + response = configure(token: paste.token, folder_id: folders(:bob_notes).id) + + assert response.error? + assert_equal "folder_not_found", response.structured_content[:code] + assert_equal "folder_id", response.structured_content[:field] + end + + test "content is left untouched" do + paste = create_paste_for(@alice, content: "

untouched

") + + configure(token: paste.token, password: "s3cret") + + assert_equal "

untouched

", paste.reload.content + end + + test "a token belonging to another user is a not-found error" do + theirs = create_paste_for(@bob) + + response = configure(token: theirs.token, password: "hijack") + + assert response.error? + assert_equal "paste_not_found", response.structured_content[:code] + assert_equal "token", response.structured_content[:field] + end + + test "no settings supplied is an error" do + paste = create_paste_for(@alice) + + response = configure(token: paste.token) + + assert response.error? + assert_equal "no_settings_provided", response.structured_content[:code] + end + + test "password and clear_password together is a conflict" do + paste = create_paste_for(@alice) + + response = configure(token: paste.token, password: "s3cret", clear_password: true) + + assert response.error? + assert_equal "conflicting_arguments", response.structured_content[:code] + assert_equal "clear_password", response.structured_content[:field] + end + + test "custom_subdomain and clear_custom_subdomain together is a conflict" do + paste = create_paste_for(@alice) + + response = configure(token: paste.token, custom_subdomain: "sub", clear_custom_subdomain: true) + + assert response.error? + assert_equal "conflicting_arguments", response.structured_content[:code] + assert_equal "clear_custom_subdomain", response.structured_content[:field] + end + + test "folder_id and clear_folder together is a conflict" do + paste = create_paste_for(@alice) + folder = @alice.folders.create!(name: "Target") + + response = configure(token: paste.token, folder_id: folder.id, clear_folder: true) + + assert response.error? + assert_equal "conflicting_arguments", response.structured_content[:code] + assert_equal "clear_folder", response.structured_content[:field] + end + + test "an invalid custom_subdomain returns the error contract with the field" do + paste = create_paste_for(@alice) + + response = configure(token: paste.token, custom_subdomain: "bad_sub") + + assert response.error? + assert_equal "validation_failed", response.structured_content[:code] + assert_equal "custom_subdomain", response.structured_content[:field] + end + + test "annotations mark it destructive, idempotent, non-read-only, closed-world" do + annotations = McpTools::ConfigurePaste.annotations_value + + assert_equal false, annotations.read_only_hint + assert_equal true, annotations.destructive_hint + assert_equal true, annotations.idempotent_hint + assert_equal false, annotations.open_world_hint + end + + private + def configure(**args) + McpTools::ConfigurePaste.call(**args, server_context: @ctx) + end + + def create_paste_for(user, content: "

x

", **options) + paste = Paste.new(content: content, original_filename: "paste.html", user: user) + options.each { |key, value| paste.public_send("#{key}=", value) } + paste.save! + paste + end +end diff --git a/test/models/mcp_tools/create_folder_test.rb b/test/models/mcp_tools/create_folder_test.rb new file mode 100644 index 0000000..168e495 --- /dev/null +++ b/test/models/mcp_tools/create_folder_test.rb @@ -0,0 +1,52 @@ +require "test_helper" + +class McpTools::CreateFolderTest < ActiveSupport::TestCase + setup do + @alice = users(:alice) + @ctx = { user: @alice } + end + + test "creates an empty folder owned by the user" do + response = create(name: "New Folder") + + assert_not response.error? + structured = response.structured_content + assert_equal "New Folder", structured[:name] + assert_equal 0, structured[:pastes_count] + + folder = Folder.find(structured[:id]) + assert_equal @alice, folder.user + end + + test "a duplicate name (case-insensitive) is a validation error" do + @alice.folders.create!(name: "Work") + + response = create(name: "work") + + assert response.error? + assert_equal "validation_failed", response.structured_content[:code] + assert_equal "name", response.structured_content[:field] + end + + test "another user may reuse the same name" do + users(:bob).folders.create!(name: "Shared Name") + + response = create(name: "Shared Name") + + assert_not response.error? + end + + test "annotations mark it a write, non-idempotent, non-destructive, closed-world tool" do + annotations = McpTools::CreateFolder.annotations_value + + assert_equal false, annotations.read_only_hint + assert_equal false, annotations.destructive_hint + assert_equal false, annotations.idempotent_hint + assert_equal false, annotations.open_world_hint + end + + private + def create(**args) + McpTools::CreateFolder.call(**args, server_context: @ctx) + end +end diff --git a/test/models/mcp_tools/delete_folder_test.rb b/test/models/mcp_tools/delete_folder_test.rb new file mode 100644 index 0000000..cc88b67 --- /dev/null +++ b/test/models/mcp_tools/delete_folder_test.rb @@ -0,0 +1,88 @@ +require "test_helper" + +class McpTools::DeleteFolderTest < ActiveSupport::TestCase + setup do + @alice = users(:alice) + @bob = users(:bob) + @ctx = { user: @alice } + end + + test "confirm: false is refused and the folder survives untouched" do + folder = @alice.folders.create!(name: "Keep Me") + paste = Paste.create!(content: "

x

", original_filename: "p.html", user: @alice, folder: folder) + + response = delete(folder_id: folder.id, confirm: false) + + assert response.error? + assert_equal "confirmation_required", response.structured_content[:code] + assert_equal "confirm", response.structured_content[:field] + assert Folder.exists?(folder.id) + assert_equal folder.id, paste.reload.folder_id + end + + test "omitting a truthy confirm value is refused" do + folder = @alice.folders.create!(name: "Keep Me Too") + + response = delete(folder_id: folder.id, confirm: "nope") + + assert response.error? + assert_equal "confirmation_required", response.structured_content[:code] + assert Folder.exists?(folder.id) + end + + test "confirm: true destroys the folder, unfiles its pastes (which survive), and revokes scoped API keys" do + folder = folders(:projects) + scoped_key = api_keys(:alice_projects_key) + assert scoped_key.active?, "sanity check: the fixture key starts active" + + paste_one = Paste.create!(content: "

1

", original_filename: "p.html", user: @alice, folder: folder) + paste_two = Paste.create!(content: "

2

", original_filename: "p.html", user: @alice, folder: folder) + + response = delete(folder_id: folder.id, confirm: true) + + assert_not response.error? + structured = response.structured_content + assert_equal true, structured[:deleted] + assert_equal 2, structured[:unfiled_pastes_count] + assert_equal 1, structured[:revoked_api_keys_count] + + assert_not Folder.exists?(folder.id) + + assert Paste.exists?(paste_one.id), "pastes must survive -- they can never be deleted" + assert Paste.exists?(paste_two.id) + assert_nil paste_one.reload.folder_id + assert_nil paste_two.reload.folder_id + + assert scoped_key.reload.revoked? + end + + test "a folder_id belonging to another user is a not-found error" do + response = delete(folder_id: folders(:bob_notes).id, confirm: true) + + assert response.error? + assert_equal "folder_not_found", response.structured_content[:code] + assert_equal "folder_id", response.structured_content[:field] + assert Folder.exists?(folders(:bob_notes).id) + end + + test "an unknown folder_id is a not-found error" do + response = delete(folder_id: 999_999, confirm: true) + + assert response.error? + assert_equal "folder_not_found", response.structured_content[:code] + end + + test "annotations mark it destructive, non-idempotent, non-read-only, closed-world" do + annotations = McpTools::DeleteFolder.annotations_value + + assert_equal false, annotations.read_only_hint + assert_equal true, annotations.destructive_hint + assert_equal false, annotations.idempotent_hint + assert_equal false, annotations.open_world_hint + end + + private + def delete(**args) + McpTools::DeleteFolder.call(**args, server_context: @ctx) + end +end diff --git a/test/models/mcp_tools/get_paste_stats_test.rb b/test/models/mcp_tools/get_paste_stats_test.rb new file mode 100644 index 0000000..c1c90cc --- /dev/null +++ b/test/models/mcp_tools/get_paste_stats_test.rb @@ -0,0 +1,104 @@ +require "test_helper" + +class McpTools::GetPasteStatsTest < ActiveSupport::TestCase + setup do + @alice = users(:alice) + @bob = users(:bob) + @ctx = { user: @alice } + @paste = Paste.create!(content: "

x

", original_filename: "paste.html", user: @alice) + end + + test "views_by_source is zero-filled across every source" do + record_view!(source: "show") + record_view!(source: "raw") + + response = stats(token: @paste.token) + + assert_not response.error? + by_source = response.structured_content[:views_by_source] + assert_equal({ show: 1, live: 0, raw: 1, render: 0 }, by_source) + end + + test "views_count reflects the total across every source, including views outside the recent window" do + record_view!(source: "show", created_at: 40.days.ago) + record_view!(source: "raw") + record_view!(source: "live") + + response = stats(token: @paste.token) + + assert_equal 3, response.structured_content[:views_count] + assert_equal 1, response.structured_content[:views_by_source][:show] + end + + test "recent_views aggregates by day for the last 30 days and omits older days" do + today = Date.current + yesterday = today - 1 + record_view!(source: "show", created_at: today.in_time_zone.noon) + record_view!(source: "raw", created_at: today.in_time_zone.noon) + record_view!(source: "show", created_at: yesterday.in_time_zone.noon) + record_view!(source: "show", created_at: 40.days.ago) + + response = stats(token: @paste.token) + + recent = response.structured_content[:recent_views] + today_entry = recent.find { |entry| entry[:date] == today.iso8601 } + yesterday_entry = recent.find { |entry| entry[:date] == yesterday.iso8601 } + + assert_equal 2, today_entry[:count] + assert_equal 1, yesterday_entry[:count] + assert_not recent.any? { |entry| entry[:date] == 40.days.ago.to_date.iso8601 } + end + + test "never returns referrers, user agents, or IPs" do + record_view!(source: "show", referrer: "https://evil.example/track", user_agent: "SecretBrowser/1.0") + + response = stats(token: @paste.token) + + payload_json = JSON.generate(response.structured_content) + assert_not_includes payload_json, "evil.example" + assert_not_includes payload_json, "SecretBrowser" + assert_not response.structured_content.to_s.match?(/referrer|user_agent|ip_address/i) + end + + test "a token belonging to another user is a not-found error" do + theirs = Paste.create!(content: "

bob's

", original_filename: "paste.html", user: @bob) + + response = stats(token: theirs.token) + + assert response.error? + assert_equal "paste_not_found", response.structured_content[:code] + assert_equal "token", response.structured_content[:field] + end + + test "an unknown token is a not-found error" do + response = stats(token: "does-not-exist") + + assert response.error? + assert_equal "paste_not_found", response.structured_content[:code] + end + + test "annotations mark it read-only, idempotent, non-destructive, closed-world" do + annotations = McpTools::GetPasteStats.annotations_value + + assert_equal true, annotations.read_only_hint + assert_equal false, annotations.destructive_hint + assert_equal true, annotations.idempotent_hint + assert_equal false, annotations.open_world_hint + end + + private + def stats(**args) + McpTools::GetPasteStats.call(**args, server_context: @ctx) + end + + def record_view!(source:, created_at: Time.current, referrer: nil, user_agent: nil) + PasteView.create!( + paste: @paste, + source: source, + created_at: created_at, + updated_at: created_at, + referrer: referrer, + user_agent: user_agent + ) + end +end diff --git a/test/models/mcp_tools/get_paste_test.rb b/test/models/mcp_tools/get_paste_test.rb new file mode 100644 index 0000000..d677e5a --- /dev/null +++ b/test/models/mcp_tools/get_paste_test.rb @@ -0,0 +1,76 @@ +require "test_helper" + +class McpTools::GetPasteTest < ActiveSupport::TestCase + setup do + @alice = users(:alice) + @bob = users(:bob) + @ctx = { user: @alice } + end + + test "returns the paste detail plus stored content and content_bytes" do + paste = Paste.create!(content: "Report

hi

", original_filename: "paste.html", user: @alice) + + response = get(token: paste.token) + + assert_not response.error? + structured = response.structured_content + assert_equal paste.token, structured[:token] + assert_equal "Report", structured[:title] + assert_equal "Report

hi

", structured[:content] + assert_equal "Report

hi

".bytesize, structured[:content_bytes] + assert_not structured.key?(:markdown) + end + + test "content is the stored HTML for a markdown-created paste, never the original Markdown source" do + created = McpTools::CreatePaste.call(content: "# Heading\n\nbody text", format: "markdown", server_context: @ctx) + token = created.structured_content[:token] + + response = get(token: token) + + assert_not response.error? + assert_includes response.structured_content[:content], "Title

hi

", original_filename: "paste.html", user: @alice) + + response = get(token: paste.token, include_markdown: true) + + assert_not response.error? + assert_includes response.structured_content[:content], "

Title

" + assert_includes response.structured_content[:markdown], "Title" + assert_not_includes response.structured_content[:markdown], "

" + end + + test "a token belonging to another user is a not-found error" do + theirs = Paste.create!(content: "

bob's

", original_filename: "paste.html", user: @bob) + + response = get(token: theirs.token) + + assert response.error? + assert_equal "paste_not_found", response.structured_content[:code] + assert_equal "token", response.structured_content[:field] + end + + test "an unknown token is a not-found error" do + response = get(token: "does-not-exist") + + assert response.error? + assert_equal "paste_not_found", response.structured_content[:code] + end + + test "annotations mark it read-only, idempotent, non-destructive, closed-world" do + annotations = McpTools::GetPaste.annotations_value + + assert_equal true, annotations.read_only_hint + assert_equal false, annotations.destructive_hint + assert_equal true, annotations.idempotent_hint + assert_equal false, annotations.open_world_hint + end + + private + def get(**args) + McpTools::GetPaste.call(**args, server_context: @ctx) + end +end diff --git a/test/models/mcp_tools/rename_folder_test.rb b/test/models/mcp_tools/rename_folder_test.rb new file mode 100644 index 0000000..9ac7577 --- /dev/null +++ b/test/models/mcp_tools/rename_folder_test.rb @@ -0,0 +1,64 @@ +require "test_helper" + +class McpTools::RenameFolderTest < ActiveSupport::TestCase + setup do + @alice = users(:alice) + @bob = users(:bob) + @ctx = { user: @alice } + end + + test "renames a folder owned by the user and reports its paste count" do + folder = @alice.folders.create!(name: "Old Name") + Paste.create!(content: "

x

", original_filename: "p.html", user: @alice, folder: folder) + + response = rename(folder_id: folder.id, name: "New Name") + + assert_not response.error? + structured = response.structured_content + assert_equal folder.id, structured[:id] + assert_equal "New Name", structured[:name] + assert_equal 1, structured[:pastes_count] + assert_equal "New Name", folder.reload.name + end + + test "a duplicate name (case-insensitive) is a validation error" do + @alice.folders.create!(name: "Taken") + folder = @alice.folders.create!(name: "Renameable") + + response = rename(folder_id: folder.id, name: "taken") + + assert response.error? + assert_equal "validation_failed", response.structured_content[:code] + assert_equal "name", response.structured_content[:field] + assert_equal "Renameable", folder.reload.name + end + + test "a folder_id belonging to another user is a not-found error" do + response = rename(folder_id: folders(:bob_notes).id, name: "Hijacked") + + assert response.error? + assert_equal "folder_not_found", response.structured_content[:code] + assert_equal "folder_id", response.structured_content[:field] + end + + test "an unknown folder_id is a not-found error" do + response = rename(folder_id: 999_999, name: "Nope") + + assert response.error? + assert_equal "folder_not_found", response.structured_content[:code] + end + + test "annotations mark it a write, idempotent, non-destructive, closed-world tool" do + annotations = McpTools::RenameFolder.annotations_value + + assert_equal false, annotations.read_only_hint + assert_equal false, annotations.destructive_hint + assert_equal true, annotations.idempotent_hint + assert_equal false, annotations.open_world_hint + end + + private + def rename(**args) + McpTools::RenameFolder.call(**args, server_context: @ctx) + end +end diff --git a/test/models/mcp_tools/update_paste_test.rb b/test/models/mcp_tools/update_paste_test.rb new file mode 100644 index 0000000..ff10ccf --- /dev/null +++ b/test/models/mcp_tools/update_paste_test.rb @@ -0,0 +1,115 @@ +require "test_helper" + +class McpTools::UpdatePasteTest < ActiveSupport::TestCase + setup do + @alice = users(:alice) + @bob = users(:bob) + @ctx = { user: @alice } + end + + test "html content republishes verbatim and returns the paste detail without folder_created" do + paste = create_paste_for(@alice, "Old

old

", filename: "paste.html") + + response = update(token: paste.token, content: "New

new

", format: "html") + + assert_not response.error? + paste.reload + assert_equal "New

new

", paste.content + assert_equal "New", response.structured_content[:title] + assert_not response.structured_content.key?(:folder_created) + end + + test "markdown content is rendered to branded HTML, not stored raw" do + paste = create_paste_for(@alice, "

old

", filename: "paste.html") + + response = update(token: paste.token, content: "# Heading\n\nbody", format: "markdown") + + assert_not response.error? + paste.reload + assert_includes paste.content, "md-body" + assert_includes paste.content, "# Not a heading, just text

", format: "html") + + assert_not response.error? + paste.reload + assert_equal "

# Not a heading, just text

", paste.content, "format: html must drive rendering, not the paste's stored .md filename" + assert_not_includes paste.content, "old

", filename: "paste.html") + + update(token: paste.token, content: "# x", format: "markdown") + + assert_equal "paste.md", paste.reload.original_filename + end + + test "a supplied filename whose extension disagrees with format is an error and does not update the paste" do + paste = create_paste_for(@alice, "

old

", filename: "paste.html") + + response = update(token: paste.token, content: "

new

", format: "html", filename: "note.md") + + assert response.error? + assert_equal "filename_format_mismatch", response.structured_content[:code] + assert_equal "filename", response.structured_content[:field] + assert_equal "

old

", paste.reload.content + end + + test "a token belonging to another user is a not-found error" do + theirs = create_paste_for(@bob, "

bob's

", filename: "paste.html") + + response = update(token: theirs.token, content: "

hijacked

", format: "html") + + assert response.error? + assert_equal "paste_not_found", response.structured_content[:code] + assert_equal "token", response.structured_content[:field] + assert_equal "

bob's

", theirs.reload.content + end + + test "an unknown token is a not-found error" do + response = update(token: "does-not-exist", content: "

x

", format: "html") + + assert response.error? + assert_equal "paste_not_found", response.structured_content[:code] + end + + test "content over the size limit is a validation error and does not persist" do + paste = create_paste_for(@alice, "

old

", filename: "paste.html") + oversized = "a" * (Paste::MAX_CONTENT_BYTES + 1) + + response = update(token: paste.token, content: oversized, format: "html") + + assert response.error? + assert_equal "validation_failed", response.structured_content[:code] + assert_equal "content", response.structured_content[:field] + assert_equal "

old

", paste.reload.content + end + + test "annotations mark it destructive, non-idempotent, non-read-only, closed-world" do + annotations = McpTools::UpdatePaste.annotations_value + + assert_equal false, annotations.read_only_hint + assert_equal true, annotations.destructive_hint + assert_equal false, annotations.idempotent_hint + assert_equal false, annotations.open_world_hint + end + + private + def update(**args) + McpTools::UpdatePaste.call(**args, server_context: @ctx) + end + + def create_paste_for(user, content, filename:) + Paste.create!(content: content, original_filename: filename, user: user) + end +end From de0c9679b571baca09a3b201710eb069a3b470d2 Mon Sep 17 00:00:00 2001 From: Ali Hamdi Ali Fadel Date: Fri, 10 Jul 2026 13:52:20 +0300 Subject: [PATCH 08/22] feat(oauth): add the Connected agents screen (Doorkeeper authorized_applications) Restyles Doorkeeper's stock authorized_applications index/destroy into the account's "Connected agents" screen: a custom Oauth::AuthorizedApplicationsController (pinning the app layout like the consent screen) lists each authorized OAuth client with its redirect host, registered date, granted scopes, and last-used time (one grouped-tokens query, no N+1), labels dynamically-registered clients as unverified, and revokes an app's tokens/grants with a confirm dialog. Links the screen from the header nav next to API keys, bilingual EN/AR throughout. Co-Authored-By: Claude Fable 5 --- .../authorized_applications_controller.rb | 33 ++++ app/helpers/application_helper.rb | 2 + .../oauth/authorized_applications_helper.rb | 34 ++++ .../authorized_applications/index.html.erb | 59 +++++++ app/views/layouts/application.html.erb | 5 + config/locales/ar.yml | 18 +++ config/locales/en.yml | 18 +++ config/routes.rb | 8 +- ...authorized_applications_controller_test.rb | 149 ++++++++++++++++++ 9 files changed, 323 insertions(+), 3 deletions(-) create mode 100644 app/controllers/oauth/authorized_applications_controller.rb create mode 100644 app/helpers/oauth/authorized_applications_helper.rb create mode 100644 app/views/doorkeeper/authorized_applications/index.html.erb create mode 100644 test/controllers/oauth/authorized_applications_controller_test.rb diff --git a/app/controllers/oauth/authorized_applications_controller.rb b/app/controllers/oauth/authorized_applications_controller.rb new file mode 100644 index 0000000..5ee3e5c --- /dev/null +++ b/app/controllers/oauth/authorized_applications_controller.rb @@ -0,0 +1,33 @@ +# Doorkeeper's "authorized applications" screen, restyled as the account's +# "Connected agents" screen: every OAuth client (Claude Code, Codex, ...) the +# signed-in user has authorized for the MCP endpoint, with a one-click revoke. +# Authentication is the app's own -- ApplicationController's Authentication +# concern (see Doorkeeper's base_controller config) runs before Doorkeeper's +# own resource_owner_authenticator ever gets a chance to redirect. +class Oauth::AuthorizedApplicationsController < Doorkeeper::AuthorizedApplicationsController + # Implicit layout lookup would otherwise stop at the gem's bundled + # layouts/doorkeeper/application before ever reaching the app's layout -- + # see Oauth::AuthorizationsController for the same fix on the consent screen. + layout "application" + + def index + @applications = Doorkeeper.config.application_model + .authorized_for(current_resource_owner) + .order(created_at: :desc, id: :desc) + + # One query for every non-revoked token this user holds, grouped in Ruby + # by application -- avoids an N+1 computing each application's granted + # scopes and last-used time in the view (see Oauth::AuthorizedApplicationsHelper). + @tokens_by_application_id = Doorkeeper::AccessToken + .active_for(current_resource_owner) + .group_by(&:application_id) + end + + # Doorkeeper's stock action also serves JSON and a gem-localized flash; + # this screen is HTML-only, so the override drops the JSON branch and uses + # the app's own bilingual copy instead. + def destroy + Doorkeeper.config.application_model.revoke_tokens_and_grants_for(params[:id], current_resource_owner) + redirect_to oauth_authorized_applications_url, status: :see_other, notice: t("connected_agents.revoked") + end +end diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index a3d09f8..e9c4c4a 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -46,6 +46,7 @@ def text_direction NAV_ICON_PATHS = { dashboard: "M3.75 6A2.25 2.25 0 0 1 6 3.75h2.25A2.25 2.25 0 0 1 10.5 6v2.25a2.25 2.25 0 0 1-2.25 2.25H6a2.25 2.25 0 0 1-2.25-2.25V6ZM3.75 15.75A2.25 2.25 0 0 1 6 13.5h2.25a2.25 2.25 0 0 1 2.25 2.25V18a2.25 2.25 0 0 1-2.25 2.25H6A2.25 2.25 0 0 1 3.75 18v-2.25ZM13.5 6a2.25 2.25 0 0 1 2.25-2.25H18A2.25 2.25 0 0 1 20.25 6v2.25A2.25 2.25 0 0 1 18 10.5h-2.25a2.25 2.25 0 0 1-2.25-2.25V6ZM13.5 15.75a2.25 2.25 0 0 1 2.25-2.25H18a2.25 2.25 0 0 1 2.25 2.25V18A2.25 2.25 0 0 1 18 20.25h-2.25a2.25 2.25 0 0 1-2.25-2.25v-2.25Z", api_keys: "M15.75 5.25a3 3 0 0 1 3 3m3 0a6 6 0 0 1-7.029 5.912c-.563-.097-1.159.026-1.563.43L10.5 17.25H8.25v2.25H6v2.25H2.25v-2.818c0-.597.237-1.17.659-1.591l6.499-6.499c.404-.404.527-1 .43-1.563A6 6 0 1 1 21.75 8.25Z", + connected_agents: "M13.19 8.688a4.5 4.5 0 0 1 1.242 7.244l-4.5 4.5a4.5 4.5 0 0 1-6.364-6.364l1.757-1.757m13.35-.622 1.757-1.757a4.5 4.5 0 0 0-6.364-6.364l-4.5 4.5a4.5 4.5 0 0 0 1.242 7.244", sign_out: "M8.25 9V5.25A2.25 2.25 0 0 1 10.5 3h6a2.25 2.25 0 0 1 2.25 2.25v13.5A2.25 2.25 0 0 1 16.5 21h-6a2.25 2.25 0 0 1-2.25-2.25V15m-3 0-3-3m0 0 3-3m-3 3H15", sign_in: "M15.75 9V5.25A2.25 2.25 0 0 0 13.5 3h-6a2.25 2.25 0 0 0-2.25 2.25v13.5A2.25 2.25 0 0 0 7.5 21h6a2.25 2.25 0 0 0 2.25-2.25V15m3 0 3-3m0 0-3-3m3 3H9", sign_up: "M19 7.5v3m0 0v3m0-3h3m-3 0h-3m-2.25-4.125a3.375 3.375 0 1 1-6.75 0 3.375 3.375 0 0 1 6.75 0ZM4 19.235v-.11a6.375 6.375 0 0 1 12.75 0v.109A12.318 12.318 0 0 1 10.374 21c-2.331 0-4.512-.645-6.374-1.766Z" @@ -63,6 +64,7 @@ def current_nav?(section) case section when :dashboard then controller_name == "folders" || (controller_name == "pastes" && action_name == "index") when :api_keys then controller_name == "api_keys" + when :connected_agents then controller_path == "oauth/authorized_applications" when :sign_in then controller_name == "sessions" && action_name == "new" when :sign_up then controller_name == "users" && action_name == "new" else false diff --git a/app/helpers/oauth/authorized_applications_helper.rb b/app/helpers/oauth/authorized_applications_helper.rb new file mode 100644 index 0000000..648f1f7 --- /dev/null +++ b/app/helpers/oauth/authorized_applications_helper.rb @@ -0,0 +1,34 @@ +module Oauth::AuthorizedApplicationsHelper + # Host-only (never full URIs) parse of an application's possibly + # whitespace-separated list of redirect URIs (Doorkeeper's own + # RedirectUriValidator splits on any whitespace) -- the one client- + # controlled value the OAuth flow actually verifies. Mirrors the consent + # screen's redirect_host_html treatment of the same field. + def redirect_hosts(application) + application.redirect_uri.to_s.split.filter_map { |uri| URI.parse(uri).host }.uniq.join(", ") + rescue URI::InvalidURIError + application.redirect_uri + end + + # Union of scopes granted across all of the user's active tokens for this + # application, as human labels -- reuses the consent screen's + # doorkeeper.scopes.* keys. `tokens_by_application_id` is the controller's + # single grouped-tokens query (see Oauth::AuthorizedApplicationsController). + def granted_scope_labels(application, tokens_by_application_id) + tokens_for(application, tokens_by_application_id) + .flat_map { |token| token.scopes.to_a } + .uniq + .map { |scope| t(scope, scope: [ :doorkeeper, :scopes ]) } + end + + # Max last_used_at across the user's active tokens for this application, or + # nil for a "never used" state (every token issued but never presented yet). + def application_last_used_at(application, tokens_by_application_id) + tokens_for(application, tokens_by_application_id).filter_map(&:last_used_at).max + end + + private + def tokens_for(application, tokens_by_application_id) + tokens_by_application_id[application.id] || [] + end +end diff --git a/app/views/doorkeeper/authorized_applications/index.html.erb b/app/views/doorkeeper/authorized_applications/index.html.erb new file mode 100644 index 0000000..ab71bf5 --- /dev/null +++ b/app/views/doorkeeper/authorized_applications/index.html.erb @@ -0,0 +1,59 @@ +<% set_meta_tags title: t("connected_agents.title"), noindex: true %> + +
+
+
+

+ <%= t("connected_agents.eyebrow") %> +

+

<%= t("connected_agents.heading") %>

+

<%= t("connected_agents.body") %>

+
+ <%= link_to t("connected_agents.back"), pastes_path, + class: "rounded-md border-3 border-ink bg-white px-4 py-2.5 font-display text-xl tracking-wide text-ink shadow-comic-sm hover:bg-hero-yellow" %> +
+ +
+ <% if @applications.any? %> + <% @applications.each do |application| %> +
+
+
+

<%= application.name %>

+ <% if application.dynamic? %> +

+ <%= t("doorkeeper.authorizations.new.unverified_client") %> +

+ <% end %> +

<%= redirect_hosts(application) %>

+

+ <%= t("connected_agents.meta.registered", date: l(application.created_at.to_date, format: :long)) %> + · + <% if (last_used_at = application_last_used_at(application, @tokens_by_application_id)).present? %> + <%= t("connected_agents.meta.last_used", date: l(last_used_at.to_date, format: :long)) %> + <% else %> + <%= t("connected_agents.meta.never_used") %> + <% end %> +

+ <% scopes = granted_scope_labels(application, @tokens_by_application_id) %> + <% if scopes.any? %> +

+ <%= t("connected_agents.scopes_heading") %> + <%= scopes.join(" · ") %> +

+ <% end %> +
+ <%= button_to t("connected_agents.revoke"), oauth_authorized_application_path(application), method: :delete, + form: { data: { turbo_confirm: t("connected_agents.revoke_confirm") } }, + class: "shrink-0 rounded-md border-2 border-ink bg-white px-3 py-2 font-display text-lg tracking-wide text-hero-red shadow-comic-sm hover:bg-hero-yellow" %> +
+
+ <% end %> + <% else %> +
+

<%= t("connected_agents.empty_title") %>

+

<%= t("connected_agents.empty_body") %>

+
+ <% end %> +
+
diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb index cbc478a..11c17a0 100644 --- a/app/views/layouts/application.html.erb +++ b/app/views/layouts/application.html.erb @@ -62,6 +62,11 @@ class: "inline-flex items-center sm:-rotate-1 border-2 border-ink p-2.5 font-display text-xs tracking-wide shadow-comic-sm sm:px-2.5 sm:py-1 sm:text-sm #{api_keys_current ? "bg-ink text-paper" : "bg-white text-ink hover:bg-hero-yellow"}" do %> <%= nav_icon(:api_keys) %> <% end %> + <% connected_agents_current = current_nav?(:connected_agents) %> + <%= link_to oauth_authorized_applications_path, aria: { label: t("layout.connected_agents"), current: ("page" if connected_agents_current) }, + class: "inline-flex items-center sm:rotate-1 border-2 border-ink p-2.5 font-display text-xs tracking-wide shadow-comic-sm sm:px-2.5 sm:py-1 sm:text-sm #{connected_agents_current ? "bg-ink text-paper" : "bg-white text-ink hover:bg-hero-yellow"}" do %> + <%= nav_icon(:connected_agents) %> + <% end %> <%= button_to session_path, method: :delete, aria: { label: t("layout.sign_out") }, class: "inline-flex items-center border-2 border-ink bg-white p-2.5 font-display text-xs tracking-wide text-ink shadow-comic-sm hover:bg-hero-yellow sm:px-2.5 sm:py-1 sm:text-sm" do %> <%= nav_icon(:sign_out) %> diff --git a/config/locales/ar.yml b/config/locales/ar.yml index ad14b67..e46ab4a 100644 --- a/config/locales/ar.yml +++ b/config/locales/ar.yml @@ -9,6 +9,7 @@ ar: skip_to_content: تخطَّ إلى المحتوى dashboard: مكتبتي api_keys: مفاتيح API + connected_agents: الوكلاء المتصلون sign_in: تسجيل الدخول sign_up: إنشاء حساب sign_out: تسجيل الخروج @@ -98,6 +99,23 @@ ar: last_used: آخر استخدام %{date} never_used: لم يُستخدم بعد + connected_agents: + title: الوكلاء المتصلون + eyebrow: وصول MCP + heading: الوكلاء المتصلون + body: تتصل الوكلاء مثل Claude Code وCodex بحسابك عبر OAuth للعمل نيابةً عنك. يمكنك إبطال الوصول في أي وقت — يسري ذلك فورًا. + back: العودة إلى مكتبتك + revoked: تم إبطال الوصول. + scopes_heading: "يمكنه:" + revoke: إبطال الوصول + revoke_confirm: هل تريد إبطال وصول هذا الوكيل؟ سيتوقف عن العمل فورًا. + empty_title: لا يوجد وكلاء متصلون + empty_body: لا يوجد وكلاء متصلون بعد. عند تفويض وكيل مثل Claude Code أو Codex، سيظهر هنا. + meta: + registered: اتصل %{date} + last_used: آخر استخدام %{date} + never_used: لم يُستخدم بعد + dashboard: meta_title: صفحاتك eyebrow: مكتبتك diff --git a/config/locales/en.yml b/config/locales/en.yml index bd02041..94bc1ec 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -8,6 +8,7 @@ en: skip_to_content: "Skip to content" dashboard: "Dashboard" api_keys: "API keys" + connected_agents: "Connected agents" sign_in: "Sign in" sign_up: "Sign up" sign_out: "Sign out" @@ -97,6 +98,23 @@ en: last_used: "last used %{date}" never_used: "never used" + connected_agents: + title: "Connected agents" + eyebrow: "MCP access" + heading: "Connected agents" + body: "Agents like Claude Code and Codex connect here through OAuth to act as your account. Revoke access any time — it takes effect immediately." + back: "Back to dashboard" + revoked: "Access revoked." + scopes_heading: "Can:" + revoke: "Revoke access" + revoke_confirm: "Revoke this agent's access? It will stop working immediately." + empty_title: "No agents connected" + empty_body: "No agents connected yet. When you authorize an agent like Claude Code or Codex, it will appear here." + meta: + registered: "connected %{date}" + last_used: "last used %{date}" + never_used: "never used" + dashboard: meta_title: "Your pastes" eyebrow: "Your library" diff --git a/config/routes.rb b/config/routes.rb index c813d06..6f9ed6c 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -71,12 +71,14 @@ # resource-indicator enforcement onto Doorkeeper's stock endpoints. # :applications is skipped (clients arrive via dynamic registration, not # an admin UI); :authorized_applications stays -- it's the "connected - # agents" screen a later task links up and restyles. /mcp itself and - # /oauth/register are later tasks. + # agents" screen, restyled by its own controller/view like the + # authorizations consent screen. /mcp itself and /oauth/register are + # later tasks. constraints host: McpOauth::CONFIG[:host] do use_doorkeeper scope: "oauth" do controllers authorizations: "oauth/authorizations", - tokens: "oauth/tokens" + tokens: "oauth/tokens", + authorized_applications: "oauth/authorized_applications" skip_controllers :applications end diff --git a/test/controllers/oauth/authorized_applications_controller_test.rb b/test/controllers/oauth/authorized_applications_controller_test.rb new file mode 100644 index 0000000..c1651a7 --- /dev/null +++ b/test/controllers/oauth/authorized_applications_controller_test.rb @@ -0,0 +1,149 @@ +require "test_helper" + +# Doorkeeper's stock "authorized applications" screen, restyled as the +# account's "Connected agents" screen (Oauth::AuthorizedApplicationsController) +# -- lists every OAuth client (Claude Code, Codex, ...) the signed-in user has +# authorized for the MCP endpoint, and lets them revoke access. +class Oauth::AuthorizedApplicationsControllerTest < ActionDispatch::IntegrationTest + RESOURCE = McpOauth::CONFIG[:resource_uri] + + test "index requires authentication" do + get oauth_authorized_applications_url + + assert_response :see_other + assert_redirected_to new_session_url + end + + test "lists only the current user's authorized applications" do + sign_in_as users(:alice) + mint_token(user: users(:alice), application: oauth_applications(:mcp_client)) + mint_token(user: users(:bob), application: oauth_applications(:dynamic_client)) + + get oauth_authorized_applications_url + assert_response :success + + # Scoped to the app card itself (article h2), not a loose body substring + # match: the page's own copy names "Codex" as an example agent, which + # would otherwise collide with the dynamic_client fixture of that name. + assert_select "article h2", text: oauth_applications(:mcp_client).name + assert_select "article h2", text: oauth_applications(:dynamic_client).name, count: 0 + end + + test "shows the unverified label only for dynamically registered clients" do + sign_in_as users(:alice) + mint_token(user: users(:alice), application: oauth_applications(:mcp_client)) + mint_token(user: users(:alice), application: oauth_applications(:dynamic_client)) + + get oauth_authorized_applications_url + assert_response :success + + unverified_label = I18n.t("doorkeeper.authorizations.new.unverified_client") + assert_equal 1, response.body.scan(unverified_label).count + assert_includes response.body, oauth_applications(:dynamic_client).name + end + + test "shows the redirect host, not the full redirect URI" do + sign_in_as users(:alice) + mint_token(user: users(:alice), application: oauth_applications(:mcp_client)) + + get oauth_authorized_applications_url + assert_response :success + + assert_includes response.body, "127.0.0.1" + assert_not_includes response.body, "/callback" + end + + test "shows human scope labels for granted scopes" do + sign_in_as users(:alice) + mint_token(user: users(:alice), application: oauth_applications(:mcp_client), scopes: "mcp:read mcp:write") + + get oauth_authorized_applications_url + assert_response :success + + assert_includes response.body, I18n.t("doorkeeper.scopes.mcp:read") + assert_includes response.body, I18n.t("doorkeeper.scopes.mcp:write") + end + + test "shows a never-used state and a formatted last-used date" do + sign_in_as users(:alice) + never_used_app = oauth_applications(:mcp_client) + used_app = oauth_applications(:dynamic_client) + mint_token(user: users(:alice), application: never_used_app) + used_token = mint_token(user: users(:alice), application: used_app) + used_token.update!(last_used_at: Time.zone.local(2026, 3, 4, 10, 0, 0)) + + get oauth_authorized_applications_url + assert_response :success + + assert_includes response.body, I18n.t("connected_agents.meta.never_used") + assert_includes response.body, I18n.l(Date.new(2026, 3, 4), format: :long) + end + + test "revoking an application invalidates its access token and refresh token" do + sign_in_as users(:alice) + application = oauth_applications(:mcp_client) + token = mint_token(user: users(:alice), application: application, use_refresh_token: true) + access_token = token.plaintext_token + refresh_token = token.plaintext_refresh_token + + assert_difference -> { Doorkeeper::AccessToken.active_for(users(:alice)).count }, -1 do + delete oauth_authorized_application_url(application) + end + assert_response :see_other + assert_redirected_to oauth_authorized_applications_url + + get oauth_authorized_applications_url + assert_not_includes response.body, application.name + + post "/mcp", params: { jsonrpc: "2.0", id: 1, method: "ping" }.to_json, + headers: { + "Authorization" => "Bearer #{access_token}", + "Content-Type" => "application/json", + "Accept" => "application/json, text/event-stream" + } + assert_response :unauthorized + assert_includes response.headers["WWW-Authenticate"], %(error="invalid_token") + + post "/oauth/token", params: { + grant_type: "refresh_token", + client_id: application.uid, + refresh_token: refresh_token, + resource: RESOURCE + } + assert_response :bad_request + assert_equal "invalid_grant", response.parsed_body["error"] + end + + test "empty state when no applications are connected" do + sign_in_as users(:alice) + + get oauth_authorized_applications_url + assert_response :success + assert_includes response.body, I18n.t("connected_agents.empty_body") + end + + test "the account nav shows a Connected agents link for signed-in users" do + sign_in_as users(:alice) + + get pastes_url + assert_response :success + assert_select "a[href=?]", oauth_authorized_applications_path + end + + private + def sign_in_as(user) + post session_url, params: { email_address: user.email_address, password: "password" } + assert_redirected_to pastes_url + end + + def mint_token(user:, application:, scopes: "mcp:read mcp:write", resource: RESOURCE, use_refresh_token: false) + Doorkeeper::AccessToken.create!( + application: application, + resource_owner_id: user.id, + scopes: scopes, + expires_in: 3600, + resource: resource, + use_refresh_token: use_refresh_token + ) + end +end From b442feb72e57089bd5c0e8e9d38fa35fca3ae2f3 Mon Sep 17 00:00:00 2001 From: Ali Hamdi Ali Fadel Date: Fri, 10 Jul 2026 14:04:51 +0300 Subject: [PATCH 09/22] feat(oauth): throttle last_used_at bump and add nightly OAuth cleanup job McpController now bumps an access token's last_used_at on every successful /mcp authentication, but only when the existing value is stale by more than 15 minutes (nil counts as stale), so heavy agent traffic isn't one UPDATE per request. Failure paths never bump. Add OauthCleanupJob (solid_queue), scheduled nightly via config/recurring.yml: phase 1 revokes access tokens inactive for 90+ days (COALESCE(last_used_at, created_at)), phase 2 deletes dynamic (DCR) applications 30+ days old with no active tokens or grants left -- absorbing Claude Code's re-registration churn without ever touching pre-registered clients. Co-Authored-By: Claude Fable 5 --- app/controllers/mcp_controller.rb | 20 +++ app/jobs/oauth_cleanup_job.rb | 72 ++++++++++ config/recurring.yml | 4 + test/config/recurring_test.rb | 12 ++ test/controllers/mcp_controller_test.rb | 62 ++++++++ test/jobs/oauth_cleanup_job_test.rb | 179 ++++++++++++++++++++++++ 6 files changed, 349 insertions(+) create mode 100644 app/jobs/oauth_cleanup_job.rb create mode 100644 test/config/recurring_test.rb create mode 100644 test/jobs/oauth_cleanup_job_test.rb diff --git a/app/controllers/mcp_controller.rb b/app/controllers/mcp_controller.rb index 7fae2b9..b58c8b3 100644 --- a/app/controllers/mcp_controller.rb +++ b/app/controllers/mcp_controller.rb @@ -39,6 +39,11 @@ class McpController < ActionController::API # which applies its own nesting cap. PEEK_MAX_NESTING = 20 + # Throttle window for the `last_used_at` usage bump below -- heavy agent + # traffic hits /mcp on every tool call, so writing on every request would be + # one UPDATE per request. nil counts as stale (first use). + LAST_USED_AT_STALE_AFTER = 15.minutes + # Write-tool budget per token-owning user, mirroring the REST API's paste # limits -- pastes can never be deleted, so unmetered writes are unbounded # storage growth. @@ -116,6 +121,21 @@ def authenticate_token! end @current_access_token = access_token + # Bump usage tracking only on the successful-auth path -- never for the + # failure branches above (nothing to bump: no accessible token). + bump_last_used_at!(access_token) + end + + # Throttled usage tracking driving the nightly OauthCleanupJob's inactivity + # window. Mirrors ApiKey#mark_used! (update_columns: no validations, no + # callbacks) but skips the write entirely unless the existing value is + # stale by more than LAST_USED_AT_STALE_AFTER -- nil (never used) counts as + # stale so the very first request always records a value. + def bump_last_used_at!(access_token) + last_used_at = access_token.last_used_at + return if last_used_at.present? && last_used_at > LAST_USED_AT_STALE_AFTER.ago + + access_token.update_columns(last_used_at: Time.current) end def mcp_scoped?(access_token) diff --git a/app/jobs/oauth_cleanup_job.rb b/app/jobs/oauth_cleanup_job.rb new file mode 100644 index 0000000..c47b8c5 --- /dev/null +++ b/app/jobs/oauth_cleanup_job.rb @@ -0,0 +1,72 @@ +# Nightly inactivity-based cleanup for the MCP OAuth authorization server (see +# the OAuth plan, §6.5). Two sequential phases: +# +# 1. Revoke access tokens nobody has used in a long time -- refresh +# capability dies with the token row's revocation, so this is how a +# long-lived agent connection actually goes away. +# 2. Delete Dynamic Client Registration (DCR) applications that are old and +# have nothing left pointing at them -- this is what absorbs Claude +# Code's known re-registration churn (it may re-register per +# authenticate) without ever touching a pre-registered, non-dynamic +# client. +# +# Scheduled nightly via config/recurring.yml. +class OauthCleanupJob < ApplicationJob + queue_as :default + + # An access token is stale once neither it (nor, if never used, its + # creation) has seen activity within this window. COALESCE(last_used_at, + # created_at) mirrors the throttled bump in McpController -- a token that + # was never used at all is judged by its age instead. + TOKEN_STALE_AFTER = 90.days + + # A dynamically registered application is abandoned once it is at least this + # old AND has no active token or grant left. Non-dynamic (pre-registered) + # applications are never considered, regardless of age or activity. + DYNAMIC_APPLICATION_STALE_AFTER = 30.days + + def perform + revoked_count = revoke_stale_tokens! + deleted_count = delete_abandoned_dynamic_applications! + + Rails.logger.info( + "OauthCleanupJob: revoked #{revoked_count} stale access token(s), " \ + "deleted #{deleted_count} abandoned dynamic application(s)" + ) + end + + private + # Active (non-revoked) tokens are revoked once COALESCE(last_used_at, + # created_at) is older than TOKEN_STALE_AFTER. Uses Doorkeeper's own + # `revoke` (sets revoked_at) rather than a bulk update so it stays the + # single source of truth for what "revoked" means. + def revoke_stale_tokens! + stale_tokens = Doorkeeper::AccessToken + .where(revoked_at: nil) + .where("COALESCE(last_used_at, created_at) < ?", TOKEN_STALE_AFTER.ago) + + count = stale_tokens.count + stale_tokens.find_each(&:revoke) + count + end + + # Dynamic applications old enough, with no active token and no active + # grant, are destroyed outright. Doorkeeper's Application#destroy + # delete_all's its access_tokens/access_grants associations, so any + # already-revoked rows (including ones this same run just revoked above) + # disappear along with the application -- that composition is intended. + def delete_abandoned_dynamic_applications! + candidates = Doorkeeper::Application + .where(dynamic: true) + .where("created_at < ?", DYNAMIC_APPLICATION_STALE_AFTER.ago) + + abandoned = candidates.select { |application| abandoned?(application) } + abandoned.each(&:destroy) + abandoned.size + end + + def abandoned?(application) + application.access_tokens.where(revoked_at: nil).none? && + application.access_grants.where(revoked_at: nil).none? + end +end diff --git a/config/recurring.yml b/config/recurring.yml index b4207f9..a126153 100644 --- a/config/recurring.yml +++ b/config/recurring.yml @@ -13,3 +13,7 @@ production: clear_solid_queue_finished_jobs: command: "SolidQueue::Job.clear_finished_in_batches(sleep_between_batches: 0.3)" schedule: every hour at minute 12 + oauth_cleanup: + class: OauthCleanupJob + queue: default + schedule: every day at 3am diff --git a/test/config/recurring_test.rb b/test/config/recurring_test.rb new file mode 100644 index 0000000..6eaba72 --- /dev/null +++ b/test/config/recurring_test.rb @@ -0,0 +1,12 @@ +require "test_helper" + +# A light, string-level check that the nightly OAuth cleanup job is actually +# registered in solid_queue's recurring-task configuration -- catches the class +# of bug where the job exists and is tested, but nobody wired it to run. +class RecurringConfigTest < ActiveSupport::TestCase + test "the production recurring schedule references OauthCleanupJob" do + config = File.read(Rails.root.join("config/recurring.yml")) + + assert_includes config, "OauthCleanupJob" + end +end diff --git a/test/controllers/mcp_controller_test.rb b/test/controllers/mcp_controller_test.rb index 6b6babe..57cd659 100644 --- a/test/controllers/mcp_controller_test.rb +++ b/test/controllers/mcp_controller_test.rb @@ -191,6 +191,68 @@ class McpControllerTest < ActionDispatch::IntegrationTest assert_response :content_too_large end + # --- Throttled last_used_at bump ------------------------------------------ + + test "a successful request sets last_used_at when it was nil" do + token = read_write_token + assert_nil token.last_used_at + + travel_to Time.current do + mcp_post(initialize_body, token: token.plaintext_token) + end + + assert_response :ok + assert_in_delta Time.current.to_f, token.reload.last_used_at.to_f, 2 + end + + test "a second request within the throttle window does not change last_used_at" do + token = read_write_token + + first_time = Time.current + travel_to(first_time) { mcp_post(initialize_body, token: token.plaintext_token) } + first_last_used_at = token.reload.last_used_at + + travel_to(first_time + 5.minutes) { mcp_post(initialize_body, token: token.plaintext_token) } + + assert_equal first_last_used_at, token.reload.last_used_at + end + + test "a request after the throttle window elapses bumps last_used_at" do + token = read_write_token + + first_time = Time.current + travel_to(first_time) { mcp_post(initialize_body, token: token.plaintext_token) } + first_last_used_at = token.reload.last_used_at + + later = first_time + 16.minutes + travel_to(later) { mcp_post(initialize_body, token: token.plaintext_token) } + + assert_operator token.reload.last_used_at, :>, first_last_used_at + assert_in_delta later.to_f, token.last_used_at.to_f, 2 + end + + test "failed authentication bumps nothing" do + token = read_write_token + assert_nil token.last_used_at + + mcp_post(initialize_body, token: "not-a-real-token") + + assert_response :unauthorized + # The real token was never presented, so authenticate_token! never ran the + # bump for it -- it must remain untouched by this unrelated failed request. + assert_nil token.reload.last_used_at + end + + test "a revoked token's failed authentication does not bump last_used_at" do + token = read_write_token + token.update!(revoked_at: Time.current) + + mcp_post(initialize_body, token: token.plaintext_token) + + assert_response :unauthorized + assert_nil token.reload.last_used_at + end + # --- Write rate limit ----------------------------------------------------- test "the 21st write tools/call in a minute is rate limited" do diff --git a/test/jobs/oauth_cleanup_job_test.rb b/test/jobs/oauth_cleanup_job_test.rb new file mode 100644 index 0000000..fee9990 --- /dev/null +++ b/test/jobs/oauth_cleanup_job_test.rb @@ -0,0 +1,179 @@ +require "test_helper" + +# Nightly inactivity-based cleanup for the MCP OAuth authorization server: +# phase 1 revokes stale access tokens, phase 2 deletes abandoned Dynamic +# Client Registration (DCR) applications. See OauthCleanupJob for the exact +# thresholds and the OAuth plan's §6.5 for the rationale. +class OauthCleanupJobTest < ActiveJob::TestCase + RESOURCE = McpOauth::CONFIG[:resource_uri] + + setup do + @user = users(:alice) + end + + # --- Phase 1: revoke stale tokens ----------------------------------------- + + test "a token last used 91 days ago is revoked" do + token = create_token(last_used_at: 91.days.ago) + + OauthCleanupJob.perform_now + + assert_predicate token.reload, :revoked? + end + + test "a token with nil last_used_at but created_at 91 days ago is revoked (COALESCE path)" do + token = create_token(last_used_at: nil, created_at: 91.days.ago) + + OauthCleanupJob.perform_now + + assert_predicate token.reload, :revoked? + end + + test "a token last used 89 days ago is left untouched" do + token = create_token(last_used_at: 89.days.ago) + + OauthCleanupJob.perform_now + + assert_not token.reload.revoked? + end + + test "an already-revoked token is untouched (idempotent)" do + revoked_at = 100.days.ago.change(usec: 0) + token = create_token(last_used_at: 91.days.ago, revoked_at: revoked_at) + + OauthCleanupJob.perform_now + + assert_equal revoked_at, token.reload.revoked_at + end + + # --- Phase 2: delete abandoned dynamic applications ----------------------- + + test "a dynamic app 31 days old with only revoked tokens is deleted, along with its tokens" do + application = create_application(dynamic: true, created_at: 31.days.ago) + token = create_token(application: application, revoked_at: 1.day.ago) + + OauthCleanupJob.perform_now + + assert_not Doorkeeper::Application.exists?(application.id) + assert_not Doorkeeper::AccessToken.exists?(token.id) + end + + test "a dynamic app 31 days old with one active token is kept" do + application = create_application(dynamic: true, created_at: 31.days.ago) + create_token(application: application, last_used_at: 1.day.ago) + + OauthCleanupJob.perform_now + + assert Doorkeeper::Application.exists?(application.id) + end + + test "a dynamic app 29 days old with nothing is kept (too young)" do + application = create_application(dynamic: true, created_at: 29.days.ago) + + OauthCleanupJob.perform_now + + assert Doorkeeper::Application.exists?(application.id) + end + + test "a NON-dynamic app 31 days old with nothing is kept" do + application = create_application(dynamic: false, created_at: 31.days.ago) + + OauthCleanupJob.perform_now + + assert Doorkeeper::Application.exists?(application.id) + end + + test "a dynamic app 31 days old with an active grant but no tokens is kept" do + application = create_application(dynamic: true, created_at: 31.days.ago) + create_grant(application: application) + + OauthCleanupJob.perform_now + + assert Doorkeeper::Application.exists?(application.id) + end + + # --- Composition: phase 1's revocation feeds phase 2's deletion ----------- + + test "phase 1 revoking a stale token lets phase 2 delete the now-abandoned dynamic app in the same run" do + application = create_application(dynamic: true, created_at: 31.days.ago) + token = create_token(application: application, last_used_at: 91.days.ago) + + OauthCleanupJob.perform_now + + # Without phase 1's revocation this token would still be "active" and + # phase 2 would keep the application (see the "one active token is kept" + # test above) -- both the application and the token it fed into phase 2 + # are gone, proving the two phases composed within a single run. + assert_not Doorkeeper::Application.exists?(application.id) + assert_not Doorkeeper::AccessToken.exists?(token.id) + end + + # --- Logging --------------------------------------------------------------- + + test "logs a single summary line with both counts" do + create_token(last_used_at: 91.days.ago) + application = create_application(dynamic: true, created_at: 31.days.ago) + create_token(application: application, revoked_at: 1.day.ago) + + logged = capture_rails_logger_info { OauthCleanupJob.perform_now } + + assert_equal 1, logged.count { |line| line.include?("OauthCleanupJob") } + assert_includes logged.join, "revoked 1" + assert_includes logged.join, "deleted 1" + end + + private + def create_application(dynamic:, created_at: Time.current) + application = Doorkeeper::Application.create!( + name: "Test App #{SecureRandom.hex(4)}", + redirect_uri: "http://127.0.0.1:#{rand(20_000..60_000)}/callback", + scopes: "mcp:read mcp:write", + confidential: false, + dynamic: dynamic + ) + application.update_columns(created_at: created_at) + application + end + + def create_token(application: nil, user: @user, last_used_at: nil, created_at: nil, revoked_at: nil) + application ||= create_application(dynamic: false) + token = Doorkeeper::AccessToken.create!( + application: application, + resource_owner_id: user.id, + scopes: "mcp:read mcp:write", + expires_in: 3600, + resource: RESOURCE + ) + token.update_columns( + last_used_at: last_used_at, + created_at: created_at || token.created_at, + revoked_at: revoked_at + ) + token + end + + def create_grant(application:, user: @user, revoked_at: nil) + grant = Doorkeeper::AccessGrant.create!( + application: application, + resource_owner_id: user.id, + redirect_uri: application.redirect_uri, + expires_in: 600, + scopes: "mcp:read mcp:write", + resource: RESOURCE + ) + grant.update_columns(revoked_at: revoked_at) if revoked_at + grant + end + + def capture_rails_logger_info + lines = [] + original_logger = Rails.logger + recorder = Logger.new(StringIO.new) + recorder.define_singleton_method(:info) { |msg = nil, &block| lines << (msg || block&.call).to_s } + Rails.logger = recorder + yield + lines + ensure + Rails.logger = original_logger + end +end From 76d0c422ac33646130ca7d17d00a6b07b454ddef Mon Sep 17 00:00:00 2001 From: Ali Hamdi Ali Fadel Date: Fri, 10 Jul 2026 14:20:31 +0300 Subject: [PATCH 10/22] =?UTF-8?q?test(mcp):=20add=20the=20=C2=A79=20end-to?= =?UTF-8?q?-end=20integration=20sweep?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the cross-cutting MCP OAuth coverage no single component test owned: a full agent journey in one test (401 -> discovery -> DCR -> PKCE authorize -> token -> tools/list -> create_paste -> refresh, with the rotated-out refresh token proven dead), paste-host and non-canonical-host routing isolation, header-only bearer auth at /mcp, and a real captured-log proof that OAuth codes and MCP paste content never reach the request log unfiltered. Also extends oauth_resource_indicator_test.rb so the uppercase resource-indicator case is proven usable at /mcp, not just correctly stored. Co-Authored-By: Claude Fable 5 --- test/integration/mcp_agent_journey_test.rb | 338 ++++++++++++++++++ .../oauth_resource_indicator_test.rb | 26 ++ 2 files changed, 364 insertions(+) create mode 100644 test/integration/mcp_agent_journey_test.rb diff --git a/test/integration/mcp_agent_journey_test.rb b/test/integration/mcp_agent_journey_test.rb new file mode 100644 index 0000000..9260051 --- /dev/null +++ b/test/integration/mcp_agent_journey_test.rb @@ -0,0 +1,338 @@ +require "test_helper" + +# The §9 end-to-end integration sweep: cross-cutting scenarios that no single +# component test owns. Where the other OAuth/MCP test files each drive one +# layer (discovery, DCR, the authorization-code flow, the /mcp transport, +# tool wiring) against fixtures or a single hop, this file simulates what a +# real client (Claude Code, Codex CLI) actually does end-to-end: discover, +# self-register, authorize, exchange, call a tool, and refresh -- each step +# feeding the next, starting from nothing but a signed-in user. +class McpAgentJourneyTest < ActionDispatch::IntegrationTest + CANONICAL_RESOURCE = McpOauth::CONFIG[:resource_uri] + + test "a coding agent discovers, registers, authorizes, calls a tool, and refreshes" do + # --- a. Cold POST /mcp with no token ------------------------------------- + post "/mcp", params: initialize_body, headers: mcp_headers + assert_response :unauthorized + + challenge = response.headers["WWW-Authenticate"] + assert_not_includes challenge, "error=" + resource_metadata_url = challenge[/resource_metadata="([^"]+)"/, 1] + assert resource_metadata_url.present?, "expected a resource_metadata pointer in #{challenge}" + + # --- b. Follow it to the protected-resource metadata --------------------- + get resource_metadata_url + assert_response :success + authorization_server = response.parsed_body["authorization_servers"]&.first + assert authorization_server.present? + + # --- c. Discover the authorization server's endpoints -------------------- + get "#{authorization_server}/.well-known/oauth-authorization-server" + assert_response :success + as_metadata = response.parsed_body + assert_equal [ "S256" ], as_metadata["code_challenge_methods_supported"] + + authorization_endpoint = as_metadata["authorization_endpoint"] + token_endpoint = as_metadata["token_endpoint"] + registration_endpoint = as_metadata["registration_endpoint"] + + # --- d. Dynamic Client Registration --------------------------------------- + redirect_uri = "http://127.0.0.1:43217/callback" + post registration_endpoint, params: { redirect_uris: [ redirect_uri ] }, as: :json + assert_response :created + + registration = response.parsed_body + client_id = registration["client_id"] + assert client_id.present? + assert_not registration.key?("client_secret"), "DCR must never hand back a client_secret" + + # --- e. Sign in, then authorize with PKCE (S256) -------------------------- + sign_in_as users(:alice) + + verifier = SecureRandom.urlsafe_base64(48) + code_challenge = Base64.urlsafe_encode64(Digest::SHA256.digest(verifier), padding: false) + state = "agent-state-#{SecureRandom.hex(4)}" + + authorize_params = { + client_id: client_id, + redirect_uri: redirect_uri, + response_type: "code", + scope: "mcp:read mcp:write", + state: state, + code_challenge: code_challenge, + code_challenge_method: "S256", + resource: CANONICAL_RESOURCE + } + + get authorization_endpoint, params: authorize_params + assert_response :success + + # Mimic the consent form's approve submission: re-POST the exact param set + # the rendered hidden fields carry (see oauth_authorization_flow_test.rb). + post authorization_endpoint, params: authorize_params + assert_response :redirect + + location = URI.parse(response.location) + assert_equal "/callback", location.path + query = Rack::Utils.parse_query(location.query) + assert_equal state, query["state"], "state must round-trip through the redirect" + code = query["code"] + assert code.present? + + # --- f. Exchange the code for a token pair -------------------------------- + post token_endpoint, params: { + grant_type: "authorization_code", + client_id: client_id, + redirect_uri: redirect_uri, + code: code, + code_verifier: verifier, + resource: CANONICAL_RESOURCE + } + assert_response :success + + tokens = response.parsed_body + access_token = tokens["access_token"] + refresh_token = tokens["refresh_token"] + assert access_token.present? + assert refresh_token.present? + + # --- g. initialize --------------------------------------------------------- + post "/mcp", params: initialize_body, headers: mcp_headers(access_token) + assert_response :ok + assert response.parsed_body["result"].present? + + # --- h. tools/list, then tools/call create_paste --------------------------- + post "/mcp", params: tools_list_body, headers: mcp_headers(access_token) + assert_response :ok + tool_names = response.parsed_body.dig("result", "tools").map { |tool| tool["name"] } + assert_includes tool_names, "create_paste" + + post "/mcp", + params: tools_call_body("create_paste", content: "Agent Journey

hi

", format: "html"), + headers: mcp_headers(access_token) + assert_response :ok + + result = response.parsed_body["result"] + assert_not result["isError"] + paste_token = result.dig("structuredContent", "token") + assert paste_token.present? + + paste = Paste.find_by(token: paste_token) + assert paste.present? + assert_equal users(:alice), paste.user + + # --- i. Refresh: rotates the pair, kills the old refresh token ------------ + post token_endpoint, params: { + grant_type: "refresh_token", + client_id: client_id, + refresh_token: refresh_token, + resource: CANONICAL_RESOURCE + } + assert_response :success + + refreshed = response.parsed_body + new_access_token = refreshed["access_token"] + new_refresh_token = refreshed["refresh_token"] + assert_not_equal access_token, new_access_token + assert_not_equal refresh_token, new_refresh_token + + # The rotated-out refresh token is immediately dead (no grace window). + post token_endpoint, params: { + grant_type: "refresh_token", + client_id: client_id, + refresh_token: refresh_token, + resource: CANONICAL_RESOURCE + } + assert_response :bad_request + assert_equal "invalid_grant", response.parsed_body["error"] + + # The new access token authenticates at /mcp. + post "/mcp", params: initialize_body, headers: mcp_headers(new_access_token) + assert_response :ok + end + + # --- Paste-host isolation --------------------------------------------------- + + test "OAuth and MCP endpoints are unreachable from a paste-origin host" do + # A 32-lowercase-alphanumeric label is a valid *paste token* subdomain + # (Paste::TOKEN_LENGTH) -- here suffixed onto the app's own configured + # host, proving the paste_host routing constraint (which wins before the + # app-host constraint is ever consulted) shields these routes even from a + # subdomain of the literal MCP host string. + host! "#{"a" * 32}.www.example.com" + + get "/.well-known/oauth-authorization-server" + assert_response :not_found + + post "/oauth/register", params: { redirect_uris: [ "http://127.0.0.1:1234/callback" ] }, as: :json + assert_response :not_found + + post "/mcp", params: initialize_body, headers: mcp_headers + assert_response :not_found + + get "/oauth/authorize", params: { + client_id: "whatever", + redirect_uri: "http://127.0.0.1:1/cb", + response_type: "code", + scope: "mcp:read", + resource: CANONICAL_RESOURCE + } + assert_response :not_found + end + + test "OAuth and MCP endpoints route-404 on a non-canonical host that is neither the app host nor a paste host" do + # Genuinely missing §9 coverage (spec: "non-canonical Host -> routing 404 + # ... the transport's 403 is unreachable there -- it's defense-in-depth, + # not the tested behavior"). This is distinct from paste-host isolation + # above: McpOauth::CONFIG[:host] is "www.example.com" in test, and Rails' + # `constraints host:` is an exact match, not a suffix match -- a bare + # "example.com" is neither that host nor a paste-token/custom-subdomain + # host (only two labels, no subdomain at all), so the apex-constrained + # OAuth/MCP block in config/routes.rb simply has no route to offer it. + host! "example.com" + + get "/.well-known/oauth-authorization-server" + assert_response :not_found + + post "/oauth/register", params: { redirect_uris: [ "http://127.0.0.1:1234/callback" ] }, as: :json + assert_response :not_found + + post "/mcp", params: initialize_body, headers: mcp_headers + assert_response :not_found + + # Contrast: this isn't a blanket host failure -- ordinary, non-MCP app + # routes still resolve on the same host, only the apex-constrained + # OAuth/MCP surface does not. + get "/" + assert_response :success + end + + # --- Token-in-param rejection ------------------------------------------------- + + test "a token supplied only as a query parameter is rejected -- header-only bearer auth" do + token = mint_token + + post "/mcp?access_token=#{token.plaintext_token}", params: initialize_body, + headers: { "Content-Type" => "application/json", "Accept" => "application/json, text/event-stream" } + + assert_response :unauthorized + challenge = response.headers["WWW-Authenticate"] + assert challenge.present? + assert_not_includes challenge, "error=", "a token in a query param must not be picked up at all" + end + + # --- Log filtering proof ------------------------------------------------------ + + test "the token endpoint filters `code` and /mcp filters tool-call `content` out of request logs" do + application = oauth_applications(:mcp_client) + sign_in_as users(:alice) + + verifier = "wXyVZ0m3basmgTt5c8sJVzXvUqAHQu7hMYJhZpJp4NM-example-verifier" + code_challenge = Base64.urlsafe_encode64(Digest::SHA256.digest(verifier), padding: false) + + post "/oauth/authorize", params: { + client_id: application.uid, + redirect_uri: application.redirect_uri, + response_type: "code", + scope: "mcp:read mcp:write", + state: "log-filter-state", + code_challenge: code_challenge, + code_challenge_method: "S256", + resource: CANONICAL_RESOURCE + } + assert_response :redirect + code = Rack::Utils.parse_query(URI.parse(response.location).query)["code"] + assert code.present? + + token_log = capture_controller_log do + post "/oauth/token", params: { + grant_type: "authorization_code", + client_id: application.uid, + redirect_uri: application.redirect_uri, + code: code, + code_verifier: verifier, + resource: CANONICAL_RESOURCE + } + end + assert_response :success + access_token = response.parsed_body["access_token"] + + assert_includes token_log, "Parameters:" + assert_includes token_log, ActiveSupport::ParameterFilter::FILTERED + assert_not_includes token_log, code + assert_not_includes token_log, verifier + + secret_marker = "SECRET-PASTE-BODY-#{SecureRandom.hex(6)}" + mcp_log = capture_controller_log do + post "/mcp", + params: tools_call_body("create_paste", content: "Log Filter

#{secret_marker}

", format: "html"), + headers: mcp_headers(access_token) + end + assert_response :ok + + assert_includes mcp_log, "Parameters:" + assert_includes mcp_log, ActiveSupport::ParameterFilter::FILTERED + assert_not_includes mcp_log, secret_marker + end + + private + def sign_in_as(user) + post session_url, params: { email_address: user.email_address, password: "password" } + end + + def mint_token(user: users(:alice), application: oauth_applications(:mcp_client), scopes: "mcp:read mcp:write") + Doorkeeper::AccessToken.create!( + application: application, + resource_owner_id: user.id, + scopes: scopes, + expires_in: 3600, + resource: CANONICAL_RESOURCE + ) + end + + def mcp_headers(token = nil) + headers = { "Content-Type" => "application/json", "Accept" => "application/json, text/event-stream" } + headers["Authorization"] = "Bearer #{token}" if token + headers + end + + def initialize_body + { + jsonrpc: "2.0", + id: 1, + method: "initialize", + params: { + protocolVersion: "2025-11-25", + capabilities: {}, + clientInfo: { name: "test-agent", version: "1.0" } + } + }.to_json + end + + def tools_list_body + { jsonrpc: "2.0", id: 2, method: "tools/list", params: {} }.to_json + end + + def tools_call_body(name, **arguments) + { jsonrpc: "2.0", id: 3, method: "tools/call", params: { name: name, arguments: arguments } }.to_json + end + + # Swaps the logger the "Processing by ... / Parameters: ..." request log + # lines are written through. ActionController::LogSubscriber#logger is + # hardcoded to `ActionController::Base.logger` -- not Rails.logger, and + # not per-controller-class -- regardless of which ActionController::API + # subclass actually handled the request (McpController, Oauth::TokensController, + # ...), so that's the one seam that actually intercepts them. Safe under + # the suite's process-forked parallelization (test_helper.rb parallelizes + # by process, not threads): this only mutates state in the current worker + # process, and the block form always restores the previous logger. + def capture_controller_log + buffer = StringIO.new + previous_logger = ActionController::Base.logger + ActionController::Base.logger = ActiveSupport::Logger.new(buffer) + yield + buffer.string + ensure + ActionController::Base.logger = previous_logger + end +end diff --git a/test/integration/oauth_resource_indicator_test.rb b/test/integration/oauth_resource_indicator_test.rb index 4eaee7c..8a207ee 100644 --- a/test/integration/oauth_resource_indicator_test.rb +++ b/test/integration/oauth_resource_indicator_test.rb @@ -125,6 +125,32 @@ class OauthResourceIndicatorTest < ActionDispatch::IntegrationTest assert_equal CANONICAL_RESOURCE, token.resource end + test "an uppercase-equivalent resource end-to-end mints a token usable at /mcp" do + # Round-7 proof that canonical storage composes with the /mcp audience + # check: the whole authorize+token dance runs through the client's + # uppercase spelling, and the resulting token -- stored canonically -- + # must still authenticate, not merely persist correctly (the other tests + # in this file stop at grant/token storage assertions). + code = obtain_authorization_code(resource: UPPERCASED_RESOURCE) + + post "/oauth/token", params: token_params(code: code, resource: UPPERCASED_RESOURCE) + assert_response :success + access_token = response.parsed_body["access_token"] + + post "/mcp", params: { + jsonrpc: "2.0", id: 1, method: "initialize", + params: { protocolVersion: "2025-11-25", capabilities: {}, clientInfo: { name: "test", version: "1.0" } } + }.to_json, + headers: { + "Content-Type" => "application/json", + "Accept" => "application/json, text/event-stream", + "Authorization" => "Bearer #{access_token}" + } + + assert_response :ok + assert response.parsed_body["result"].present? + end + test "refresh without a resource parameter is rejected" do refresh_token = exchange_code_for_token["refresh_token"] From d7bc07eedb0794527cfb43b1bdeef6f721f05686 Mon Sep 17 00:00:00 2001 From: Ali Hamdi Ali Fadel Date: Fri, 10 Jul 2026 14:46:54 +0300 Subject: [PATCH 11/22] test(oauth): cover dev config derivation and RFC 7009 revocation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extract McpOauth::CONFIG's construction into a pure McpOauth.build_config(env:, env_vars:) so the §6.0 dev issuer derivation (localhost:3000) can be asserted without reloading the initializer; CONFIG's own construction stays byte-identical. Also add an integration test proving POST /oauth/revoke actually kills a token for a public (secret-less) MCP client, confirmed via a subsequent 401 invalid_token at /mcp. Co-Authored-By: Claude Fable 5 --- config/initializers/mcp_oauth.rb | 47 ++++++++++++++----------- test/config/mcp_oauth_test.rb | 25 +++++++++++++ test/controllers/mcp_controller_test.rb | 16 +++++++++ 3 files changed, 68 insertions(+), 20 deletions(-) diff --git a/config/initializers/mcp_oauth.rb b/config/initializers/mcp_oauth.rb index 3d94ad7..29dd896 100644 --- a/config/initializers/mcp_oauth.rb +++ b/config/initializers/mcp_oauth.rb @@ -10,30 +10,37 @@ # JSON documents, and audience validation all read from McpOauth::CONFIG, so # its shape is load-bearing -- don't change the keys without updating those. module McpOauth - default_issuer = - case Rails.env - when "production" - "https://pastehtml.dev" - when "test" - # Matches Rails' integration-test default host so route constraints - # keyed on CONFIG[:host] work in tests. - "http://www.example.com" - else - "http://localhost:3000" - end + # Pure derivation, extracted so tests can exercise the per-env branches + # (e.g. the §6.0 dev config) without reloading this initializer -- CONFIG + # below is built by calling this with the real Rails.env/ENV at boot. + def self.build_config(env:, env_vars:) + default_issuer = + case env + when "production" + "https://pastehtml.dev" + when "test" + # Matches Rails' integration-test default host so route constraints + # keyed on CONFIG[:host] work in tests. + "http://www.example.com" + else + "http://localhost:3000" + end - issuer = (ENV["MCP_OAUTH_ISSUER"].presence || default_issuer).freeze + issuer = (env_vars["MCP_OAUTH_ISSUER"].presence || default_issuer).freeze - default_host = URI(issuer).host + default_host = URI(issuer).host - host = (ENV["MCP_OAUTH_HOST"].presence || default_host).freeze + host = (env_vars["MCP_OAUTH_HOST"].presence || default_host).freeze - CONFIG = { - issuer: issuer, - resource_uri: "#{issuer}/mcp".freeze, - host: host, - protected_resource_metadata_url: "#{issuer}/.well-known/oauth-protected-resource".freeze - }.freeze + { + issuer: issuer, + resource_uri: "#{issuer}/mcp".freeze, + host: host, + protected_resource_metadata_url: "#{issuer}/.well-known/oauth-protected-resource".freeze + }.freeze + end + + CONFIG = build_config(env: Rails.env, env_vars: ENV).freeze # Loopback hosts for which RFC 8252 §7.3 permits plain-http redirect URIs on # any port -- native/CLI agents (Claude Code, Codex) receive their diff --git a/test/config/mcp_oauth_test.rb b/test/config/mcp_oauth_test.rb index 18e6ea5..ee4b496 100644 --- a/test/config/mcp_oauth_test.rb +++ b/test/config/mcp_oauth_test.rb @@ -27,4 +27,29 @@ class McpOauthTest < ActiveSupport::TestCase assert McpOauth::CONFIG[:protected_resource_metadata_url].start_with?(McpOauth::CONFIG[:issuer]) assert_equal "#{McpOauth::CONFIG[:issuer]}/.well-known/oauth-protected-resource", McpOauth::CONFIG[:protected_resource_metadata_url] end + + # CONFIG itself is built once at boot from the test env, so the §6.0 dev + # config (issuer http://localhost:3000) can't be observed by reloading the + # initializer. Call the same pure derivation the initializer uses instead. + test "build_config derives the dev issuer's parts from MCP_OAUTH_ISSUER=http://localhost:3000" do + config = McpOauth.build_config(env: "development", env_vars: { "MCP_OAUTH_ISSUER" => "http://localhost:3000" }) + + assert_equal "http://localhost:3000", config[:issuer] + assert_equal "localhost", config[:host] + assert_equal "http://localhost:3000/mcp", config[:resource_uri] + assert_equal "http://localhost:3000/.well-known/oauth-protected-resource", config[:protected_resource_metadata_url] + end + + # NOTE: the code review's assumption that Paste.hosted_subdomain?("localhost") + # is false does NOT hold -- "localhost" is a plain, unreserved custom-subdomain + # candidate at this layer, so the call returns true. The app is still safe: + # ApplicationController#paste_origin_request? never even calls + # Paste.hosted_subdomain? for the bare app host "localhost", because its + # `subdomainish_host` guard requires >= 2 labels ending in "localhost" (e.g. + # "slug.localhost"), which the single-label host isn't. See that method for + # the real invariant. Asserting the review's literal claim here would assert + # something false about Paste, so it's intentionally omitted -- flagged for + # the reviewer instead of silently "fixed" by reserving "localhost" in + # Paste::RESERVED_SUBDOMAINS (a user-facing behavior change out of scope for + # this test-only ticket). end diff --git a/test/controllers/mcp_controller_test.rb b/test/controllers/mcp_controller_test.rb index 57cd659..a69d7ef 100644 --- a/test/controllers/mcp_controller_test.rb +++ b/test/controllers/mcp_controller_test.rb @@ -107,6 +107,22 @@ class McpControllerTest < ActionDispatch::IntegrationTest assert_invalid_token end + test "POST /oauth/revoke kills a token immediately, even for a public client with no secret" do + token = read_write_token + plaintext = token.plaintext_token + + mcp_post(initialize_body, token: plaintext) + assert_response :ok + + # mcp_client is a public client (secret: null) -- RFC 7009 revocation + # must still work with just client_id, no client_secret. + post "/oauth/revoke", params: { token: plaintext, client_id: @application.uid } + assert_response :ok + + mcp_post(initialize_body, token: plaintext) + assert_invalid_token + end + test "an expired token yields error=invalid_token" do token = read_write_token # expires_in is 1 hour; backdating creation puts expiry in the past. From e21ff8f1328c389f1a154f8b8d64602ee3a12625 Mon Sep 17 00:00:00 2001 From: Ali Hamdi Ali Fadel Date: Fri, 10 Jul 2026 16:33:08 +0300 Subject: [PATCH 12/22] fix(mcp): close request-gating bypasses and harden the MCP/OAuth endpoints Addresses the blocking and several medium review findings on the public MCP surface: - Add an early Rack middleware (McpBodyLimit) that rejects oversized POST bodies to /mcp and /oauth/register at the front of the stack, before Rails materializes DCR params or the transport buffers the body (finding 1). - Align the /mcp pre-dispatch peek's JSON nesting bound with the transport's (both 64) so a body nested between the old peek cap and the transport cap can no longer skip the scope + write-rate gates while still dispatching; and classify a non-object `params` without raising (was a 500) (finding 2). - Route SDK/tool exceptions to Rails.error.report, dropping the SDK-supplied context (which can carry the raw request body / paste content) and keeping only the user id (finding 5). - Declare explicit server capabilities (tools, listChanged: false) so the initialize response stops advertising prompts/resources/logging the stateless server does not implement (finding 6). - Pin doorkeeper below the next major and mcp to the 0.23.x patch line, since the implementation relies on specific 5.9.x/0.23 behavior (finding 7). - Reject DCR redirect_uris with an out-of-range port (URI.parse accepts :99999) or an abusive length (finding 8). Co-Authored-By: Claude Fable 5 --- Gemfile | 13 ++- Gemfile.lock | 4 +- app/controllers/mcp_controller.rb | 65 ++++++++++-- .../oauth/registrations_controller.rb | 15 +++ config/application.rb | 11 ++- lib/middleware/mcp_body_limit.rb | 46 +++++++++ test/controllers/mcp_controller_test.rb | 98 ++++++++++++++++++- .../oauth/registrations_controller_test.rb | 12 +++ test/middleware/mcp_body_limit_test.rb | 49 ++++++++++ 9 files changed, 294 insertions(+), 19 deletions(-) create mode 100644 lib/middleware/mcp_body_limit.rb create mode 100644 test/middleware/mcp_body_limit_test.rb diff --git a/Gemfile b/Gemfile index 233f517..9a2e4d3 100644 --- a/Gemfile +++ b/Gemfile @@ -75,11 +75,14 @@ gem "meta-tags", "~> 2.23" gem "rails-i18n", "~> 8.0" # OAuth 2.1 authorization server for the MCP server. 5.9.1+ fixes a -# public-client revocation bypass. -gem "doorkeeper", ">= 5.9.1" - -# Official MCP Ruby SDK, used to implement the remote MCP server. -gem "mcp", "~> 0.23" +# public-client revocation bypass. The implementation depends on specific 5.9.x +# extension seams (custom_access_token_attributes, the refresh-rotation path), +# so hold below the next major. +gem "doorkeeper", ">= 5.9.1", "< 6.0" + +# Official MCP Ruby SDK, used to implement the remote MCP server. Still 0.x and +# changing shape release to release; pin to the 0.23.x patch line. +gem "mcp", "~> 0.23.0" # Trust Cloudflare's IP ranges so remote_ip (and the per-IP rate limits) see # real client addresses behind the CF proxy. Production-only: the gem fetches diff --git a/Gemfile.lock b/Gemfile.lock index bc45428..1b08e1e 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -408,10 +408,10 @@ DEPENDENCIES commonmarker (~> 2.0) cssbundling-rails debug - doorkeeper (>= 5.9.1) + doorkeeper (>= 5.9.1, < 6.0) jsbundling-rails kamal - mcp (~> 0.23) + mcp (~> 0.23.0) meta-tags (~> 2.23) pg (~> 1.1) propshaft diff --git a/app/controllers/mcp_controller.rb b/app/controllers/mcp_controller.rb index b58c8b3..d662306 100644 --- a/app/controllers/mcp_controller.rb +++ b/app/controllers/mcp_controller.rb @@ -34,10 +34,20 @@ class McpController < ActionController::API # pre-dispatch peek honors the same bound so it never becomes a bypass of it. MAX_REQUEST_BYTES = MCP::Server::Transports::StreamableHTTPTransport::DEFAULT_MAX_REQUEST_BYTES - # Conservative nesting bound for the peek. Deeper-but-still-parseable bodies - # simply cause the peek to step aside (returns nil) and reach the transport, - # which applies its own nesting cap. - PEEK_MAX_NESTING = 20 + # The peek MUST use the transport's own nesting bound, not a lower one. If the + # peek stopped parsing before the transport did, a body nested between the two + # limits would classify as nil here (skipping the scope + rate-limit gates) + # yet still parse and DISPATCH in the transport -- a gate bypass. Sharing the + # constant guarantees: anything the transport will execute, the peek can + # classify; anything too deep for the peek is also too deep for the transport + # (rejected there), so it never runs. + PEEK_MAX_NESTING = MCP::Server::Transports::StreamableHTTPTransport::MAX_JSON_NESTING + + # Only tools are implemented -- no prompts, resources, or logging, and the + # stateless server has no session to push list-change notifications over. The + # SDK's default capabilities advertise all of those; declare the real set so + # the initialize response does not promise what this server cannot do. + SERVER_CAPABILITIES = { tools: { listChanged: false } }.freeze # Throttle window for the `last_used_at` usage bump below -- heavy agent # traffic hits /mcp on every tool call, so writing on every request would be @@ -60,14 +70,20 @@ def handle name: "pastehtml", version: McpTools::VERSION, instructions: McpTools::INSTRUCTIONS, + capabilities: SERVER_CAPABILITIES, tools: McpTools.for_scopes(token_scopes), server_context: { user: current_token_user }, # Turn on the SDK's server-side result validation so a successful tool # result that does not match its declared output_schema is caught here # rather than shipped to the agent. Argument validation is already on by # the SDK default; error results are exempt (they follow the tool error - # contract, not the success schema). - configuration: MCP::Configuration.new(validate_tool_call_results: true) + # contract, not the success schema). exception_reporter routes tool/ + # transport exceptions -- which the SDK otherwise turns into JSON-RPC + # errors and swallows -- into Rails' error reporter. + configuration: MCP::Configuration.new( + validate_tool_call_results: true, + exception_reporter: method(:report_mcp_exception) + ) ) transport = MCP::Server::Transports::StreamableHTTPTransport.new( server, @@ -93,6 +109,24 @@ def handle end private + # --- SDK exception reporting -------------------------------------------- + + # The SDK turns tool/transport exceptions into JSON-RPC error responses and, + # by default, reports them nowhere (its default reporter is a no-op), so + # real bugs stay invisible to Rails' error tracking. Route them to + # Rails.error instead. Deliberately DROP the SDK-supplied context: some of + # its call sites pass the raw request body (`{ request: body_string }`), + # which can contain a private paste's full content and tool arguments. Only + # the safe, non-sensitive user id is attached. + def report_mcp_exception(exception, _sdk_context) + Rails.error.report( + exception, + handled: true, + source: "mcp", + context: { user_id: @current_access_token&.resource_owner_id } + ) + end + # --- Step 1: Origin guard ------------------------------------------------ # Absent Origin is the normal case for CLI agents and passes. A present @@ -168,14 +202,23 @@ def enforce_tool_scope! return if body.nil? return unless body[:method] == "tools/call" - required = McpTools.required_scope(body.dig(:params, :name)) - # Unknown tool (nil) falls through so the SDK answers "unknown tool"; a - # scope the token already holds is fine. + required = McpTools.required_scope(requested_tool_name(body)) + # Unknown/unclassifiable tool (nil) falls through so the SDK answers + # "unknown tool"; a scope the token already holds is fine. return if required.nil? || token_scopes.include?(required) challenge_insufficient_scope end + # The tool name from a tools/call body, or nil when params is missing or not + # an object. Guards against a malformed `"params": "not-an-object"` (String) + # -- Hash#dig would raise a TypeError on the String and 500 the request; + # returning nil lets the transport reject the malformed call cleanly. + def requested_tool_name(body) + params = body[:params] + params.is_a?(Hash) ? params[:name] : nil + end + def challenge_insufficient_scope response.headers["WWW-Authenticate"] = %(Bearer error="insufficient_scope", scope="#{CHALLENGE_SCOPE}", ) + @@ -187,7 +230,7 @@ def enforce_write_rate_limit! body = mcp_request_body return if body.nil? return unless body[:method] == "tools/call" - return unless McpTools.required_scope(body.dig(:params, :name)) == McpTools::WRITE_SCOPE + return unless McpTools.required_scope(requested_tool_name(body)) == McpTools::WRITE_SCOPE user_id = current_token_user&.id return if user_id.nil? @@ -199,6 +242,8 @@ def enforce_write_rate_limit! # only touched if the first passed. In the test env the cache is a # null_store whose `increment` returns nil, so this is a no-op unless a # real counter is injected (see the controller test). + # NOTE the write tool is identified via requested_tool_name (nil-safe), + # not dig, for the same malformed-params reason as enforce_tool_scope!. return render_rate_limited unless under_write_limit?(write_rate_key("minute", user_id), WRITE_LIMIT_PER_MINUTE, 1.minute) render_rate_limited unless under_write_limit?(write_rate_key("day", user_id), WRITE_LIMIT_PER_DAY, 1.day) end diff --git a/app/controllers/oauth/registrations_controller.rb b/app/controllers/oauth/registrations_controller.rb index fdbe26f..598b101 100644 --- a/app/controllers/oauth/registrations_controller.rb +++ b/app/controllers/oauth/registrations_controller.rb @@ -29,6 +29,11 @@ def initialize(code, description) # per-authorization, so a later step-up never needs a second registration. NORMALIZED_SCOPE = ALLOWED_SCOPES.join(" ").freeze MAX_REDIRECT_URIS = 10 + # A generous cap on a single redirect_uri -- real loopback/https callbacks are + # well under this; anything longer is abuse, not a legitimate client. + MAX_REDIRECT_URI_LENGTH = 2000 + # Valid TCP port range for an explicit :port in a redirect_uri. + VALID_PORT_RANGE = (1..65_535) MAX_CLIENT_NAME_LENGTH = 255 DEFAULT_CLIENT_NAME = "Dynamically registered client" @@ -97,6 +102,9 @@ def validated_redirect_uris def validate_redirect_uri!(value) raise invalid_redirect_uri("Each redirect_uri must be a string.") unless value.is_a?(String) + if value.length > MAX_REDIRECT_URI_LENGTH + raise invalid_redirect_uri("redirect_uri exceeds #{MAX_REDIRECT_URI_LENGTH} characters.") + end uri = URI.parse(value) raise invalid_redirect_uri("redirect_uri must be an absolute URI: #{value}") unless uri.absolute? @@ -107,6 +115,13 @@ def validate_redirect_uri!(value) host = uri.host.to_s.downcase raise invalid_redirect_uri("redirect_uri is missing a host: #{value}") if host.blank? + # URI.parse accepts out-of-range ports (e.g. :99999) without complaint; + # reject them explicitly. uri.port is the effective port (an in-range + # scheme default when none is given), so this only rejects real garbage. + unless VALID_PORT_RANGE.cover?(uri.port) + raise invalid_redirect_uri("redirect_uri has an invalid port: #{value}") + end + validate_redirect_scheme!(scheme, host, value) rescue URI::InvalidURIError raise invalid_redirect_uri("redirect_uri is not a valid URI: #{value}") diff --git a/config/application.rb b/config/application.rb index 50e172b..37da1b8 100644 --- a/config/application.rb +++ b/config/application.rb @@ -14,6 +14,11 @@ # you've limited to :test, :development, or :production. Bundler.require(*Rails.groups) +# Rack middleware, required explicitly (and excluded from autoload_lib below) +# because it is referenced while the middleware stack is built at boot, before +# Zeitwerk autoloading is available. +require_relative "../lib/middleware/mcp_body_limit" + module PasteHtmlDev class Application < Rails::Application # Initialize configuration defaults for originally generated Rails version. @@ -22,7 +27,11 @@ class Application < Rails::Application # Please, add to the `ignore` list any other `lib` subdirectories that do # not contain `.rb` files, or that should not be reloaded or eager loaded. # Common ones are `templates`, `generators`, or `middleware`, for example. - config.autoload_lib(ignore: %w[assets tasks]) + config.autoload_lib(ignore: %w[assets tasks middleware]) + + # Reject oversized bodies to /mcp and /oauth/register at the front of the + # stack, before anything parses or buffers them. + config.middleware.insert_before 0, McpBodyLimit # English (default) and Arabic. Fallbacks keep a half-finished Arabic # translation from ever breaking a page: a missing key falls back to English. diff --git a/lib/middleware/mcp_body_limit.rb b/lib/middleware/mcp_body_limit.rb new file mode 100644 index 0000000..406964b --- /dev/null +++ b/lib/middleware/mcp_body_limit.rb @@ -0,0 +1,46 @@ +# Rejects oversized POST bodies to the two public MCP/OAuth endpoints before any +# downstream middleware, Rails param parsing, or the MCP transport reads them. +# +# Both public endpoints otherwise read the whole body first and cap afterwards: +# Dynamic Client Registration materializes JSON params (Rails, unbounded size), +# and the MCP transport reads up to 4 MiB -- but only once the full request has +# already been received and buffered. This middleware runs at the very front of +# the stack and rejects a declared-oversize request outright, so a giant body is +# never parsed or copied by the app. +# +# It caps the declared Content-Length, which is the normal path for the CLI +# agents (Claude Code, Codex) that use these endpoints. A body sent with no +# Content-Length (e.g. chunked transfer) falls through to the transport's own +# 4 MiB streaming read cap on /mcp and Rails' 100-level nesting limit on the DCR +# JSON -- so it is defense-in-depth, not the only limit. +class McpBodyLimit + # Match the MCP transport's own request ceiling so the two agree. + MAX_BYTES = 4 * 1024 * 1024 + + # Exact top-level paths; both are unmounted, apex-host routes. + PROTECTED_PATHS = [ "/mcp", "/oauth/register" ].freeze + + def initialize(app) + @app = app + end + + def call(env) + return too_large if guarded?(env) && declared_oversize?(env) + + @app.call(env) + end + + private + def guarded?(env) + env["REQUEST_METHOD"] == "POST" && PROTECTED_PATHS.include?(env["PATH_INFO"]) + end + + def declared_oversize?(env) + length = env["CONTENT_LENGTH"] + !length.nil? && length.to_i > MAX_BYTES + end + + def too_large + [ 413, { "Content-Type" => "application/json" }, [ %({"error":"payload_too_large"}) ] ] + end +end diff --git a/test/controllers/mcp_controller_test.rb b/test/controllers/mcp_controller_test.rb index a69d7ef..a564cdb 100644 --- a/test/controllers/mcp_controller_test.rb +++ b/test/controllers/mcp_controller_test.rb @@ -22,6 +22,15 @@ def self.call(**_args) end end +class FakeMcpRaisingTool < MCP::Tool + tool_name "fake_raise" + description "A fake tool that raises, to exercise exception reporting." + + def self.call(**_args) + raise "boom" + end +end + class McpControllerTest < ActionDispatch::IntegrationTest RESOURCE = McpOauth::CONFIG[:resource_uri] CANONICAL_ORIGIN = "http://www.example.com".freeze @@ -188,7 +197,7 @@ class McpControllerTest < ActionDispatch::IntegrationTest # --- Bounded, rewind-safe peek robustness --------------------------------- test "a deeply nested body reaches the transport's error handling, no exception" do - # 80 levels: past the peek's cap (20) and the transport's cap (64), but + # 80 levels: past the peek's cap and the transport's cap (both 64), but # under Rails' own param-parser nesting limit, so the peek steps aside and # the transport returns a JSON-RPC parse error rather than the app 500ing. mcp_post(nested_json(80), token: read_write_token.plaintext_token) @@ -207,6 +216,82 @@ class McpControllerTest < ActionDispatch::IntegrationTest assert_response :content_too_large end + # --- Classifier is total: nesting-based gate bypass is closed -------------- + + test "a moderately nested write tools/call is still scope-gated (no bypass)" do + # ~31 levels: above the peek's OLD cap (20) but within the transport's 64, + # so before the alignment fix the peek gave up, the scope gate was skipped, + # and the write tool dispatched. Now the peek parses to the transport's + # depth, so a read-only token is correctly refused with the 403 step-up. + mcp_post(nested_tools_call_body("fake_write", depth: 30), token: read_token.plaintext_token) + + assert_response :forbidden + assert_equal "insufficient_scope", response.parsed_body["error"] + end + + test "a moderately nested write tools/call still increments the write meter" do + token = read_write_token + minute_key = "mcp-write-rate:minute:#{@user.id}" + + # Pre-seed at the cap: if the deeply nested body slipped past the peek the + # meter would never run and this would 200. It must be rejected instead. + with_counting_cache_store(minute_key => WRITE_LIMIT) do + mcp_post(nested_tools_call_body("fake_write", depth: 30), token: token.plaintext_token) + end + + assert_response :too_many_requests + end + + test "a tools/call whose params is not an object does not 500" do + body = %({"jsonrpc":"2.0","id":2,"method":"tools/call","params":"not-an-object"}) + + mcp_post(body, token: read_write_token.plaintext_token) + + # Previously Hash#dig on the String params raised and 500ed; now the peek + # yields no tool name, the gate steps aside, and the transport handles the + # malformed call as a JSON-RPC error. + assert_not_equal 500, response.status + end + + # --- Advertised capabilities match what the server implements ------------- + + test "initialize advertises only tools, not prompts/resources/logging" do + mcp_post(initialize_body, token: read_write_token.plaintext_token) + + assert_response :ok + capabilities = response.parsed_body.dig("result", "capabilities") + assert_equal({ "listChanged" => false }, capabilities["tools"]) + assert_not capabilities.key?("prompts"), "should not advertise prompts" + assert_not capabilities.key?("resources"), "should not advertise resources" + assert_not capabilities.key?("logging"), "should not advertise logging" + end + + # --- SDK exceptions reach Rails error reporting --------------------------- + + test "a tool exception is reported to Rails.error and not swallowed" do + McpTools.register(FakeMcpRaisingTool, scope: "mcp:read") + reported = [] + subscriber = Class.new do + define_method(:report) do |error, handled:, severity:, source: nil, context: {}| + reported << { error: error, source: source, context: context } + end + end.new + Rails.error.subscribe(subscriber) + + mcp_post(tools_call_body("fake_raise"), token: read_token.plaintext_token) + + boom = reported.find { |r| r[:error].is_a?(RuntimeError) && r[:error].message == "boom" } + assert boom, "expected the tool exception to be reported to Rails.error" + assert_equal "mcp", boom[:source] + assert_equal @user.id, boom[:context][:user_id] + # The SDK passes the raw request body as context at its call site; it must + # never be forwarded, since it can carry a private paste's full content. + assert_not boom[:context].key?(:request), "raw request body must not be reported" + ensure + Rails.error.unsubscribe(subscriber) if subscriber + McpTools.deregister(FakeMcpRaisingTool) + end + # --- Throttled last_used_at bump ------------------------------------------ test "a successful request sets last_used_at when it was nil" do @@ -342,6 +427,17 @@ def tools_call_body(name) { jsonrpc: "2.0", id: 2, method: "tools/call", params: { name: name, arguments: {} } }.to_json end + # A valid tools/call whose overall JSON nesting is `depth` levels deep, via a + # throwaway padding key, while keeping method/params classifiable. Used to + # prove a body deeper than the peek's old cap is still classified now that + # the peek shares the transport's nesting bound. + def nested_tools_call_body(name, depth:) + pad = "1" + depth.times { pad = %({"a":#{pad}}) } + %({"jsonrpc":"2.0","id":2,"method":"tools/call",) + + %("params":{"name":"#{name}","arguments":{}},"_pad":#{pad}}) + end + def nested_json(depth) inner = "1" depth.times { inner = %({"a":#{inner}}) } diff --git a/test/controllers/oauth/registrations_controller_test.rb b/test/controllers/oauth/registrations_controller_test.rb index d13a791..0b5524e 100644 --- a/test/controllers/oauth/registrations_controller_test.rb +++ b/test/controllers/oauth/registrations_controller_test.rb @@ -192,6 +192,18 @@ class Oauth::RegistrationsControllerTest < ActionDispatch::IntegrationTest assert_invalid_redirect_uri end + test "rejects a numeric but out-of-range port" do + # URI.parse happily accepts :99999 (> 65535); the controller must not. + register(redirect_uris: [ "http://127.0.0.1:99999/callback" ]) + assert_invalid_redirect_uri + end + + test "rejects a redirect uri longer than the per-uri cap" do + long_uri = "https://example.com/#{"a" * Oauth::RegistrationsController::MAX_REDIRECT_URI_LENGTH}" + register(redirect_uris: [ long_uri ]) + assert_invalid_redirect_uri + end + # --- Metadata rejections (invalid_client_metadata) ------------------------ test "rejects a non-none token_endpoint_auth_method" do diff --git a/test/middleware/mcp_body_limit_test.rb b/test/middleware/mcp_body_limit_test.rb new file mode 100644 index 0000000..b36b2fb --- /dev/null +++ b/test/middleware/mcp_body_limit_test.rb @@ -0,0 +1,49 @@ +require "test_helper" + +class McpBodyLimitTest < ActiveSupport::TestCase + DOWNSTREAM = ->(_env) { [ 200, { "Content-Type" => "text/plain" }, [ "ok" ] ] } + + test "rejects an oversize POST to /mcp with 413 before reaching downstream" do + status, _headers, body = call("/mcp", "POST", McpBodyLimit::MAX_BYTES + 1) + + assert_equal 413, status + assert_includes body.join, "payload_too_large" + end + + test "rejects an oversize POST to /oauth/register with 413" do + status, = call("/oauth/register", "POST", McpBodyLimit::MAX_BYTES + 1) + + assert_equal 413, status + end + + test "passes a body at the limit through to downstream" do + status, = call("/mcp", "POST", McpBodyLimit::MAX_BYTES) + + assert_equal 200, status + end + + test "passes a request with no Content-Length through (relies on transport cap)" do + status, = call("/mcp", "POST", nil) + + assert_equal 200, status + end + + test "ignores non-POST methods and unguarded paths" do + assert_equal 200, call("/mcp", "GET", McpBodyLimit::MAX_BYTES + 1).first + assert_equal 200, call("/api/pastes", "POST", McpBodyLimit::MAX_BYTES + 1).first + end + + test "is installed at the front of the application middleware stack" do + klasses = Rails.application.middleware.map(&:klass) + + assert_includes klasses, McpBodyLimit + assert_equal 0, klasses.index(McpBodyLimit), "should run before every other middleware" + end + + private + def call(path, method, content_length) + env = { "REQUEST_METHOD" => method, "PATH_INFO" => path } + env["CONTENT_LENGTH"] = content_length.to_s unless content_length.nil? + McpBodyLimit.new(DOWNSTREAM).call(env) + end +end From 4fbb7aaa28f82ff084cd9e6a4a6f72f9efc818c2 Mon Sep 17 00:00:00 2001 From: Ali Hamdi Ali Fadel Date: Fri, 10 Jul 2026 16:35:53 +0300 Subject: [PATCH 13/22] fix(mcp): address review findings 3,4,9,10 (cleanup expiry, www redirect, unique races, docs) Co-Authored-By: Claude Fable 5 --- README.md | 44 +++++++++++++ app/jobs/oauth_cleanup_job.rb | 39 +++++++----- app/models/mcp_tools/base_tool.rb | 17 +++++ app/models/mcp_tools/configure_paste.rb | 39 +++++++----- app/models/mcp_tools/create_folder.rb | 10 +-- app/models/mcp_tools/rename_folder.rb | 10 +-- config/routes.rb | 18 ++++++ public/llms.txt | 33 ++++++++++ test/integration/www_host_redirect_test.rb | 55 ++++++++++++++++ test/jobs/oauth_cleanup_job_test.rb | 63 ++++++++++++++++++- test/models/mcp_tools/configure_paste_test.rb | 33 ++++++++++ test/models/mcp_tools/create_folder_test.rb | 31 +++++++++ test/models/mcp_tools/rename_folder_test.rb | 32 ++++++++++ 13 files changed, 382 insertions(+), 42 deletions(-) create mode 100644 test/integration/www_host_redirect_test.rb diff --git a/README.md b/README.md index df03039..e97f5b5 100644 --- a/README.md +++ b/README.md @@ -117,6 +117,50 @@ Agents discover all of this on their own: the full integration guide lives at homepage, both visibly and in an HTML comment for raw fetchers). Telling an agent "publish this on pastehtml.dev" is enough. +## MCP server + +For agents that speak the [Model Context Protocol](https://modelcontextprotocol.io), +pastehtml.dev is also a remote MCP server at `https://pastehtml.dev/mcp` +(Streamable HTTP). Instead of a `pht_` key, the agent authorizes once through +your browser over OAuth and then works inside your account — the same folders, +view counts, and permanent pastes as the dashboard. + +```bash +# Claude Code +claude mcp add --transport http pastehtml https://pastehtml.dev/mcp + +# Codex +codex mcp add pastehtml --url https://pastehtml.dev/mcp +``` + +On first use the client opens a browser consent screen; approve it and the +agent is connected — no key to copy or store. Authorization is OAuth 2.1 with +PKCE and RFC 7591 Dynamic Client Registration, scoped to `mcp:read` and +`mcp:write`. Review or revoke connected agents any time under **Connected +agents** in the dashboard. + +Ten tools are exposed (pastes are permanent — there is no delete-paste tool): + +- `create_paste` — publish a new HTML or Markdown paste, optionally into a folder. +- `update_paste` — republish an existing paste's content (overwrites it in place). +- `configure_paste` — change a paste's password, custom subdomain, or folder. +- `get_paste` — fetch one paste's metadata, URLs, and stored content. +- `get_paste_stats` — aggregate view analytics for a paste. +- `list_pastes` — page through the account's pastes, optionally filtered by folder. +- `list_folders` — list folders with their paste counts. +- `create_folder` — create a new, empty folder. +- `rename_folder` — rename a folder. +- `delete_folder` — delete a folder (its pastes survive, unfiled). + +Dynamic Client Registration can be switched off in production with the +`MCP_DYNAMIC_REGISTRATION_DISABLED` environment variable (any already +pre-registered clients keep working). Smoke-test a deployment by fetching its +discovery document: + +```bash +curl https://pastehtml.dev/.well-known/oauth-protected-resource +``` + ## Stack - Ruby on Rails 8.1 · PostgreSQL · Hotwire (Turbo + Stimulus) diff --git a/app/jobs/oauth_cleanup_job.rb b/app/jobs/oauth_cleanup_job.rb index c47b8c5..fac7d7a 100644 --- a/app/jobs/oauth_cleanup_job.rb +++ b/app/jobs/oauth_cleanup_job.rb @@ -21,10 +21,21 @@ class OauthCleanupJob < ApplicationJob TOKEN_STALE_AFTER = 90.days # A dynamically registered application is abandoned once it is at least this - # old AND has no active token or grant left. Non-dynamic (pre-registered) - # applications are never considered, regardless of age or activity. + # old AND has no *effective* (unrevoked and unexpired) token or grant left. + # Non-dynamic (pre-registered) applications are never considered, regardless + # of age or activity. DYNAMIC_APPLICATION_STALE_AFTER = 30.days + # A token or grant is "effective" -- still able to authorize a request -- only + # while it is unrevoked AND unexpired. A nil expires_in means the credential + # never expires (Doorkeeper permits non-expiring access tokens), so it stays + # effective indefinitely. An expired-but-unrevoked credential is inaccessible, + # so it must NOT keep a stale dynamic app alive. Both Doorkeeper tables share + # these column names, so the one predicate drives both subqueries below. + EFFECTIVE_CREDENTIAL_SQL = + "revoked_at IS NULL AND " \ + "(expires_in IS NULL OR created_at + expires_in * interval '1 second' > now())" + def perform revoked_count = revoke_stale_tokens! deleted_count = delete_abandoned_dynamic_applications! @@ -50,23 +61,23 @@ def revoke_stale_tokens! count end - # Dynamic applications old enough, with no active token and no active - # grant, are destroyed outright. Doorkeeper's Application#destroy - # delete_all's its access_tokens/access_grants associations, so any - # already-revoked rows (including ones this same run just revoked above) - # disappear along with the application -- that composition is intended. + # Dynamic applications old enough, with no effective token and no effective + # grant, are destroyed outright. Abandonment is decided in a single bulk + # query (two `NOT EXISTS`-style subqueries) rather than per-candidate + # association loads, so it stays O(1) queries no matter how many candidates + # there are. Doorkeeper's Application#destroy delete_all's its + # access_tokens/access_grants associations, so any leftover (revoked or + # expired) rows -- including ones this same run just revoked above -- + # disappear along with the application; that composition is intended. def delete_abandoned_dynamic_applications! - candidates = Doorkeeper::Application + abandoned = Doorkeeper::Application .where(dynamic: true) .where("created_at < ?", DYNAMIC_APPLICATION_STALE_AFTER.ago) + .where.not(id: Doorkeeper::AccessToken.where(EFFECTIVE_CREDENTIAL_SQL).select(:application_id)) + .where.not(id: Doorkeeper::AccessGrant.where(EFFECTIVE_CREDENTIAL_SQL).select(:application_id)) + .to_a - abandoned = candidates.select { |application| abandoned?(application) } abandoned.each(&:destroy) abandoned.size end - - def abandoned?(application) - application.access_tokens.where(revoked_at: nil).none? && - application.access_grants.where(revoked_at: nil).none? - end end diff --git a/app/models/mcp_tools/base_tool.rb b/app/models/mcp_tools/base_tool.rb index 1ea73a8..0cbe9cd 100644 --- a/app/models/mcp_tools/base_tool.rb +++ b/app/models/mcp_tools/base_tool.rb @@ -81,6 +81,23 @@ def validation_error(record) ) end + # Persist through a unique-index race. The app-level uniqueness validation + # already turns an ordinary duplicate into a clean validation_error, but a + # concurrent writer can slip a duplicate past that SELECT, so the + # INSERT/UPDATE raises RecordNotUnique at the DB layer -- which, uncaught, + # becomes an internal MCP error the agent can't correct. The block + # persists `record` and returns the tool response; on that race we add the + # same `:taken` error to `attribute` and return the identical + # validation_error the plain-duplicate path returns. (find_or_create_folder + # keeps its own recover-by-lookup rescue -- it wants the existing folder, + # not an error.) + def translating_uniqueness_race(record, attribute:) + yield + rescue ActiveRecord::RecordNotUnique + record.errors.add(attribute, :taken) + validation_error(record) + end + # Look up a folder the user owns, by id or by (case-insensitive) name, for # read-side filtering. Returns [folder_or_nil, error_or_nil]; an unknown # (or another user's) folder is a not-found error, never a silent empty list. diff --git a/app/models/mcp_tools/configure_paste.rb b/app/models/mcp_tools/configure_paste.rb index c1fd1d8..dcf81f9 100644 --- a/app/models/mcp_tools/configure_paste.rb +++ b/app/models/mcp_tools/configure_paste.rb @@ -73,25 +73,30 @@ def call(token:, password: nil, clear_password: nil, custom_subdomain: nil, clea ) return settings_error if settings_error - result = nil - Paste.transaction do - apply_password!(paste, password, clear_password) - apply_custom_subdomain!(paste, custom_subdomain, clear_custom_subdomain) - - folder_created, folder_error = apply_folder!(paste, user, folder_id, folder_name, clear_folder) - if folder_error - result = folder_error - raise ActiveRecord::Rollback - end - - if paste.save - result = ok(paste_detail(paste, folder_created: folder_created)) - else - result = validation_error(paste) - raise ActiveRecord::Rollback + # A concurrent claim of the same custom_subdomain can slip past the + # uniqueness validation, so paste.save can raise RecordNotUnique -- fold + # that race into the same validation error the collision path returns. + translating_uniqueness_race(paste, attribute: :custom_subdomain) do + result = nil + Paste.transaction do + apply_password!(paste, password, clear_password) + apply_custom_subdomain!(paste, custom_subdomain, clear_custom_subdomain) + + folder_created, folder_error = apply_folder!(paste, user, folder_id, folder_name, clear_folder) + if folder_error + result = folder_error + raise ActiveRecord::Rollback + end + + if paste.save + result = ok(paste_detail(paste, folder_created: folder_created)) + else + result = validation_error(paste) + raise ActiveRecord::Rollback + end end + result end - result end private diff --git a/app/models/mcp_tools/create_folder.rb b/app/models/mcp_tools/create_folder.rb index 6b17c17..d2d46af 100644 --- a/app/models/mcp_tools/create_folder.rb +++ b/app/models/mcp_tools/create_folder.rb @@ -40,10 +40,12 @@ def call(name:, server_context:) user = user_for(server_context) folder = user.folders.new(name: name) - if folder.save - ok(id: folder.id, name: folder.name, pastes_count: 0) - else - validation_error(folder) + translating_uniqueness_race(folder, attribute: :name) do + if folder.save + ok(id: folder.id, name: folder.name, pastes_count: 0) + else + validation_error(folder) + end end end end diff --git a/app/models/mcp_tools/rename_folder.rb b/app/models/mcp_tools/rename_folder.rb index cb2e37d..40461e4 100644 --- a/app/models/mcp_tools/rename_folder.rb +++ b/app/models/mcp_tools/rename_folder.rb @@ -42,10 +42,12 @@ def call(folder_id:, name:, server_context:) folder = user.folders.find_by(id: folder_id) return folder_not_found(folder_id) if folder.nil? - if folder.update(name: name) - ok(id: folder.id, name: folder.name, pastes_count: folder.pastes.count) - else - validation_error(folder) + translating_uniqueness_race(folder, attribute: :name) do + if folder.update(name: name) + ok(id: folder.id, name: folder.name, pastes_count: folder.pastes.count) + else + validation_error(folder) + end end end diff --git a/config/routes.rb b/config/routes.rb index 6f9ed6c..2a20589 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -33,6 +33,24 @@ # nothing but their document/password gate, so untrusted content can't frame # the app's UI under its origin or reach the API from there. constraints ->(request) { !paste_host.call(request) } do + # deploy.yml also serves `www.` (and the `*.` wildcard), but the + # OAuth/MCP routes below are constrained to the canonical apex host only, so + # a signed-in user who reaches the www host and clicks a relative app link + # (e.g. "Connected agents") would 404. Fold `www.` back onto the apex + # with a 301 before any app route matches -- MUST stay at the TOP of this + # block so it wins over `root` and friends. The rule matches only the single + # host `www.`: it leaves the canonical host itself alone even + # when that host is literally `www.example.com` (the test apex) or the bare + # `pastehtml.dev` apex (production). This whole block is the non-paste + # branch, so paste origins and `*.` wildcard subdomains never reach it. + constraints host: "www.#{McpOauth::CONFIG[:host]}" do + match "/(*path)", via: :all, to: redirect(status: 301) { |_params, request| + apex = McpOauth::CONFIG[:host] + port = request.standard_port? ? "" : ":#{request.port}" + "#{request.protocol}#{apex}#{port}#{request.fullpath}" + } + end + # Dynamic PWA files rendered from app/views/pwa/*. They live at stable root # paths because a service worker's scope is bound to its path. get "manifest.json" => "pwa#manifest", as: :pwa_manifest diff --git a/public/llms.txt b/public/llms.txt index 8ae0fa6..b18fcad 100644 --- a/public/llms.txt +++ b/public/llms.txt @@ -99,6 +99,39 @@ Create a folder explicitly with an unscoped key. Nested form params and a top-le curl -X POST -H "Authorization: Bearer pht_..." \ -d "name=Roadmap" https://pastehtml.dev/api/folders +## MCP server (OAuth, no API key) + +If you are a coding agent that speaks the Model Context Protocol, you can skip +the HTTP API above and connect to the remote MCP server instead. It lives at +https://pastehtml.dev/mcp (Streamable HTTP) and authorizes over OAuth in the +user's browser -- there is no pht_ key to handle. + + # Claude Code + claude mcp add --transport http pastehtml https://pastehtml.dev/mcp + + # Codex + codex mcp add pastehtml --url https://pastehtml.dev/mcp + +The first call opens a browser consent screen; once the user approves it, the +agent acts inside their account. Auth is OAuth 2.1 + PKCE with Dynamic Client +Registration (no client secret), scopes mcp:read and mcp:write; the user manages +or revokes agents under "Connected agents". Ten tools are exposed -- pastes are +permanent, so there is no delete-paste tool: + + create_paste publish a new HTML or Markdown paste (optionally into a folder) + update_paste republish an existing paste's content (overwrites it) + configure_paste change a paste's password, custom subdomain, or folder + get_paste fetch one paste's metadata, URLs, and stored content + get_paste_stats aggregate view analytics for a paste + list_pastes page through the account's pastes (optionally by folder) + list_folders list folders with their paste counts + create_folder create a new, empty folder + rename_folder rename a folder + delete_folder delete a folder (its pastes survive, unfiled) + +Everything else in this guide -- self-contained documents, the 2 MB limit, +always showing the user the live_url -- applies equally when publishing over MCP. + ## Publish Markdown Upload Markdown and the server renders it into a styled, self-contained HTML diff --git a/test/integration/www_host_redirect_test.rb b/test/integration/www_host_redirect_test.rb new file mode 100644 index 0000000..881fb31 --- /dev/null +++ b/test/integration/www_host_redirect_test.rb @@ -0,0 +1,55 @@ +require "test_helper" + +# The apex host serves the app; deploy.yml also answers on `www.` and the +# `*.` wildcard. OAuth/MCP routes are constrained to the canonical apex +# host only, so a signed-in user who lands on the www host and clicks a +# relative app link (e.g. "Connected agents") would otherwise 404. A routes- +# level 301 folds `www.` back onto the apex before anything else runs. +class WwwHostRedirectTest < ActionDispatch::IntegrationTest + APEX = McpOauth::CONFIG[:host] + + test "a request on the www host 301-redirects to the apex, preserving the path" do + host! "www.#{APEX}" + + get "/oauth/authorized_applications" + + assert_response :moved_permanently + assert_equal "http://#{APEX}/oauth/authorized_applications", @response.location + end + + test "the www redirect preserves the query string" do + host! "www.#{APEX}" + + get "/oauth/authorized_applications?foo=bar" + + assert_response :moved_permanently + assert_equal "http://#{APEX}/oauth/authorized_applications?foo=bar", @response.location + end + + test "the root of the www host redirects to the apex root" do + host! "www.#{APEX}" + + get "/" + + assert_response :moved_permanently + assert_equal "http://#{APEX}/", @response.location + end + + test "the canonical apex host is not redirected" do + host! APEX + + get "/oauth/authorized_applications" + + assert_not_equal 301, @response.status + end + + test "a paste-origin host is not caught by the www rule" do + host! "#{'a' * 32}.#{APEX}" + + get "/" + + # An unknown token subdomain 404s through the paste routing; either way it is + # never our 301 back to the apex. + assert_not_equal 301, @response.status + end +end diff --git a/test/jobs/oauth_cleanup_job_test.rb b/test/jobs/oauth_cleanup_job_test.rb index fee9990..716f0e0 100644 --- a/test/jobs/oauth_cleanup_job_test.rb +++ b/test/jobs/oauth_cleanup_job_test.rb @@ -92,6 +92,62 @@ class OauthCleanupJobTest < ActiveJob::TestCase assert Doorkeeper::Application.exists?(application.id) end + # An EXPIRED (but never revoked) grant is inaccessible -- its short TTL has + # lapsed -- so it must NOT keep an abandoned dynamic app alive. This is the + # regression the old `revoked_at: nil` abandonment check missed. + test "a dynamic app 31 days old with only an expired grant is deleted" do + application = create_application(dynamic: true, created_at: 31.days.ago) + create_grant(application: application, created_at: 31.days.ago) + + OauthCleanupJob.perform_now + + assert_not Doorkeeper::Application.exists?(application.id) + end + + # Same for an unrevoked-but-expired access token: recent activity keeps phase + # 1 from revoking it, yet it is expired, so it cannot keep the app alive. + test "a dynamic app 31 days old with only an expired (unrevoked) token is deleted" do + application = create_application(dynamic: true, created_at: 31.days.ago) + create_token(application: application, last_used_at: 1.day.ago, created_at: 40.days.ago) + + OauthCleanupJob.perform_now + + assert_not Doorkeeper::Application.exists?(application.id) + end + + # A nil expires_in means the token never expires (Doorkeeper permits this), so + # it stays effective indefinitely and keeps the app. + test "a dynamic app 31 days old with a non-expiring (nil expires_in) token is kept" do + application = create_application(dynamic: true, created_at: 31.days.ago) + token = create_token(application: application, last_used_at: 1.day.ago) + token.update_columns(expires_in: nil) + + OauthCleanupJob.perform_now + + assert Doorkeeper::Application.exists?(application.id) + end + + # The age gate still wins: an expired credential on a too-young app is moot. + test "a dynamic app 29 days old with an expired grant is kept (too young)" do + application = create_application(dynamic: true, created_at: 29.days.ago) + create_grant(application: application, created_at: 40.days.ago) + + OauthCleanupJob.perform_now + + assert Doorkeeper::Application.exists?(application.id) + end + + # A mix: one effective token outweighs any number of expired credentials. + test "a dynamic app 31 days old with an expired grant but one active token is kept" do + application = create_application(dynamic: true, created_at: 31.days.ago) + create_token(application: application, last_used_at: 1.day.ago) + create_grant(application: application, created_at: 40.days.ago) + + OauthCleanupJob.perform_now + + assert Doorkeeper::Application.exists?(application.id) + end + # --- Composition: phase 1's revocation feeds phase 2's deletion ----------- test "phase 1 revoking a stale token lets phase 2 delete the now-abandoned dynamic app in the same run" do @@ -152,16 +208,17 @@ def create_token(application: nil, user: @user, last_used_at: nil, created_at: n token end - def create_grant(application:, user: @user, revoked_at: nil) + def create_grant(application:, user: @user, revoked_at: nil, created_at: nil, expires_in: 600) grant = Doorkeeper::AccessGrant.create!( application: application, resource_owner_id: user.id, redirect_uri: application.redirect_uri, - expires_in: 600, + expires_in: expires_in, scopes: "mcp:read mcp:write", resource: RESOURCE ) - grant.update_columns(revoked_at: revoked_at) if revoked_at + columns = { revoked_at: revoked_at, created_at: created_at }.compact + grant.update_columns(columns) if columns.any? grant end diff --git a/test/models/mcp_tools/configure_paste_test.rb b/test/models/mcp_tools/configure_paste_test.rb index 17392d1..6aa02e7 100644 --- a/test/models/mcp_tools/configure_paste_test.rb +++ b/test/models/mcp_tools/configure_paste_test.rb @@ -154,6 +154,20 @@ class McpTools::ConfigurePasteTest < ActiveSupport::TestCase assert_equal "custom_subdomain", response.structured_content[:field] end + # A concurrent claim of the same custom_subdomain can slip past the uniqueness + # validation, so the save raises RecordNotUnique at the DB layer. That race + # must surface as the same stable validation error on custom_subdomain, never + # an uncaught exception (which would become an internal MCP error). + test "a custom_subdomain RecordNotUnique race returns the validation error, not an exception" do + paste = create_paste_for(@alice) + + response = simulating_uniqueness_race(Paste, :save) { configure(token: paste.token, custom_subdomain: "fresh-sub") } + + assert response.error? + assert_equal "validation_failed", response.structured_content[:code] + assert_equal "custom_subdomain", response.structured_content[:field] + end + test "annotations mark it destructive, idempotent, non-read-only, closed-world" do annotations = McpTools::ConfigurePaste.annotations_value @@ -174,4 +188,23 @@ def create_paste_for(user, content: "

x

", **options) paste.save! paste end + + # Forces the next call to `method` on `klass` to raise RecordNotUnique once, + # the way a concurrent claim would at the DB layer after the app-level + # uniqueness validation already passed. Restores the method afterward. + def simulating_uniqueness_race(klass, method) + defined_here = klass.instance_methods(false).include?(method) + original = klass.instance_method(method) + raised = false + klass.send(:define_method, method) do |*args, **kwargs, &block| + unless raised + raised = true + raise ActiveRecord::RecordNotUnique, "PG::UniqueViolation" + end + original.bind(self).call(*args, **kwargs, &block) + end + yield + ensure + defined_here ? klass.send(:define_method, method, original) : klass.send(:remove_method, method) + end end diff --git a/test/models/mcp_tools/create_folder_test.rb b/test/models/mcp_tools/create_folder_test.rb index 168e495..b8eca88 100644 --- a/test/models/mcp_tools/create_folder_test.rb +++ b/test/models/mcp_tools/create_folder_test.rb @@ -36,6 +36,18 @@ class McpTools::CreateFolderTest < ActiveSupport::TestCase assert_not response.error? end + # A concurrent creator can slip a duplicate past the uniqueness validation's + # SELECT, so the INSERT raises RecordNotUnique at the DB layer. That race must + # surface as the SAME stable validation error a plain duplicate does -- never + # an uncaught exception (which would become an internal MCP error). + test "a RecordNotUnique race returns the same validation error, not an exception" do + response = simulating_uniqueness_race(Folder, :save) { create(name: "Distinct Name") } + + assert response.error? + assert_equal "validation_failed", response.structured_content[:code] + assert_equal "name", response.structured_content[:field] + end + test "annotations mark it a write, non-idempotent, non-destructive, closed-world tool" do annotations = McpTools::CreateFolder.annotations_value @@ -49,4 +61,23 @@ class McpTools::CreateFolderTest < ActiveSupport::TestCase def create(**args) McpTools::CreateFolder.call(**args, server_context: @ctx) end + + # Forces the next call to `method` on `klass` to raise RecordNotUnique once, + # the way a concurrent INSERT would at the DB layer after the app-level + # uniqueness validation already passed. Restores the method afterward. + def simulating_uniqueness_race(klass, method) + defined_here = klass.instance_methods(false).include?(method) + original = klass.instance_method(method) + raised = false + klass.send(:define_method, method) do |*args, **kwargs, &block| + unless raised + raised = true + raise ActiveRecord::RecordNotUnique, "PG::UniqueViolation" + end + original.bind(self).call(*args, **kwargs, &block) + end + yield + ensure + defined_here ? klass.send(:define_method, method, original) : klass.send(:remove_method, method) + end end diff --git a/test/models/mcp_tools/rename_folder_test.rb b/test/models/mcp_tools/rename_folder_test.rb index 9ac7577..7151b45 100644 --- a/test/models/mcp_tools/rename_folder_test.rb +++ b/test/models/mcp_tools/rename_folder_test.rb @@ -48,6 +48,19 @@ class McpTools::RenameFolderTest < ActiveSupport::TestCase assert_equal "folder_not_found", response.structured_content[:code] end + # A concurrent rename can slip a duplicate past the uniqueness validation, so + # the UPDATE raises RecordNotUnique at the DB layer. It must surface as the + # same stable validation error, never an uncaught exception. + test "a RecordNotUnique race returns the same validation error, not an exception" do + folder = @alice.folders.create!(name: "Renameable") + + response = simulating_uniqueness_race(Folder, :save) { rename(folder_id: folder.id, name: "Distinct Name") } + + assert response.error? + assert_equal "validation_failed", response.structured_content[:code] + assert_equal "name", response.structured_content[:field] + end + test "annotations mark it a write, idempotent, non-destructive, closed-world tool" do annotations = McpTools::RenameFolder.annotations_value @@ -61,4 +74,23 @@ class McpTools::RenameFolderTest < ActiveSupport::TestCase def rename(**args) McpTools::RenameFolder.call(**args, server_context: @ctx) end + + # Forces the next call to `method` on `klass` to raise RecordNotUnique once, + # the way a concurrent write would at the DB layer after the app-level + # uniqueness validation already passed. Restores the method afterward. + def simulating_uniqueness_race(klass, method) + defined_here = klass.instance_methods(false).include?(method) + original = klass.instance_method(method) + raised = false + klass.send(:define_method, method) do |*args, **kwargs, &block| + unless raised + raised = true + raise ActiveRecord::RecordNotUnique, "PG::UniqueViolation" + end + original.bind(self).call(*args, **kwargs, &block) + end + yield + ensure + defined_here ? klass.send(:define_method, method, original) : klass.send(:remove_method, method) + end end From 152b08bd1b1198866730540f3e6d31e71a8877bb Mon Sep 17 00:00:00 2001 From: Ali Hamdi Ali Fadel Date: Fri, 10 Jul 2026 17:18:09 +0300 Subject: [PATCH 14/22] fix(mcp): close body-limit bypasses, report transport errors, method-safe www redirect Round-4 review follow-ups: - McpBodyLimit now bounds the actual rack.input stream (rewind-safe, replacing the input with an in-memory copy) instead of trusting Content-Length, so a chunked or header-lying oversized body is rejected too; and it normalizes a trailing slash so /mcp/ and /oauth/register/ -- which Rails routes to the same endpoints -- can't slip past. Adds full-stack integration tests alongside the isolated middleware tests (finding 1). - create_paste wraps its save in the shared uniqueness-race translator, so a concurrent custom_subdomain collision returns the stable tool error and the auto-created folder rolls back with the paste (finding 2). - Configure the SDK's GLOBAL exception reporter (config/initializers/mcp.rb): transport-level failures report through it, not the per-server reporter, and were silently 500ing. Drops the SDK context (may carry the request body) (finding 3). - The canonical www -> apex redirect now uses 308, not 301, so a POST to www is replayed with its method and body instead of being downgraded to GET (finding 4). Co-Authored-By: Claude Fable 5 --- app/models/mcp_tools/create_paste.rb | 44 ++++++++----- config/initializers/mcp.rb | 17 +++++ config/routes.rb | 14 ++-- lib/middleware/mcp_body_limit.rb | 41 ++++++++---- test/config/mcp_configuration_test.rb | 31 +++++++++ .../mcp_body_limit_integration_test.rb | 64 +++++++++++++++++++ test/integration/www_host_redirect_test.rb | 26 ++++++-- test/middleware/mcp_body_limit_test.rb | 45 ++++++++----- test/models/mcp_tools/create_paste_test.rb | 42 ++++++++++++ 9 files changed, 270 insertions(+), 54 deletions(-) create mode 100644 config/initializers/mcp.rb create mode 100644 test/config/mcp_configuration_test.rb create mode 100644 test/integration/mcp_body_limit_integration_test.rb diff --git a/app/models/mcp_tools/create_paste.rb b/app/models/mcp_tools/create_paste.rb index d742079..b7790b6 100644 --- a/app/models/mcp_tools/create_paste.rb +++ b/app/models/mcp_tools/create_paste.rb @@ -90,32 +90,42 @@ def call(content:, format:, filename: nil, custom_subdomain: nil, password: nil, resolved_filename, filename_error = resolve_filename(format, filename) return filename_error if filename_error - result = nil - Paste.transaction do - folder, folder_created, folder_error = resolve_or_create_folder(user, folder_id, folder_name) - if folder_error - result = folder_error - raise ActiveRecord::Rollback - end + # Built before the transaction so the uniqueness-race translation has a + # record to attach the error to; the folder is assigned inside. + paste = build_paste(user, content, resolved_filename, custom_subdomain, password) + + # A concurrent writer can take the same custom_subdomain between our + # validation SELECT and the INSERT, raising RecordNotUnique at the DB + # layer. Translate it to the same stable tool error the plain-duplicate + # path returns; the exception rolls the whole transaction back, so any + # auto-created folder is discarded with the paste. + translating_uniqueness_race(paste, attribute: :custom_subdomain) do + result = nil + Paste.transaction do + folder, folder_created, folder_error = resolve_or_create_folder(user, folder_id, folder_name) + if folder_error + result = folder_error + raise ActiveRecord::Rollback + end - paste = build_paste(user, content, resolved_filename, folder, custom_subdomain, password) - if paste.save - result = ok(paste_detail(paste, folder_created: folder_created)) - else - result = validation_error(paste) - raise ActiveRecord::Rollback + paste.folder = folder + if paste.save + result = ok(paste_detail(paste, folder_created: folder_created)) + else + result = validation_error(paste) + raise ActiveRecord::Rollback + end end + result end - result end private - def build_paste(user, content, filename, folder, custom_subdomain, password) + def build_paste(user, content, filename, custom_subdomain, password) paste = Paste.new( content: Paste.render_content(content, filename), original_filename: filename, - user: user, - folder: folder + user: user ) paste.custom_subdomain = custom_subdomain if custom_subdomain.present? paste.password = password if password.present? diff --git a/config/initializers/mcp.rb b/config/initializers/mcp.rb new file mode 100644 index 0000000..f0bc2bb --- /dev/null +++ b/config/initializers/mcp.rb @@ -0,0 +1,17 @@ +# Global MCP SDK configuration. +# +# The Streamable HTTP transport rescues failures that happen BEFORE a request +# reaches the server -- reading and parsing the request body -- and reports them +# through the SDK's GLOBAL configuration (MCP.configuration.exception_reporter). +# That is distinct from the per-request reporter McpController installs on each +# MCP::Server, which only covers tool exceptions. Without a global reporter, +# transport-level failures 500 silently and never reach Rails' error tracking. +# +# Route them to Rails.error. Deliberately DROP the SDK-supplied context: some +# transport call sites pass the raw request body (`{ request: body_string }`), +# which can carry a private paste's full content. +MCP.configure do |config| + config.exception_reporter = lambda do |exception, _sdk_context| + Rails.error.report(exception, handled: true, source: "mcp-transport") + end +end diff --git a/config/routes.rb b/config/routes.rb index 2a20589..1f387bd 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -37,14 +37,18 @@ # OAuth/MCP routes below are constrained to the canonical apex host only, so # a signed-in user who reaches the www host and clicks a relative app link # (e.g. "Connected agents") would 404. Fold `www.` back onto the apex - # with a 301 before any app route matches -- MUST stay at the TOP of this - # block so it wins over `root` and friends. The rule matches only the single - # host `www.`: it leaves the canonical host itself alone even - # when that host is literally `www.example.com` (the test apex) or the bare + # before any app route matches -- MUST stay at the TOP of this block so it + # wins over `root` and friends. It matches ALL verbs, so it uses a 308 + # (Permanent Redirect), NOT a 301: a client following a 301 is allowed to + # rewrite POST to GET and drop the body, which would silently break a + # state-changing request (e.g. an API upload) aimed at www; 308 preserves + # the method and body. The rule matches only the single host + # `www.`: it leaves the canonical host itself alone even when + # that host is literally `www.example.com` (the test apex) or the bare # `pastehtml.dev` apex (production). This whole block is the non-paste # branch, so paste origins and `*.` wildcard subdomains never reach it. constraints host: "www.#{McpOauth::CONFIG[:host]}" do - match "/(*path)", via: :all, to: redirect(status: 301) { |_params, request| + match "/(*path)", via: :all, to: redirect(status: 308) { |_params, request| apex = McpOauth::CONFIG[:host] port = request.standard_port? ? "" : ":#{request.port}" "#{request.protocol}#{apex}#{port}#{request.fullpath}" diff --git a/lib/middleware/mcp_body_limit.rb b/lib/middleware/mcp_body_limit.rb index 406964b..3ea0cb1 100644 --- a/lib/middleware/mcp_body_limit.rb +++ b/lib/middleware/mcp_body_limit.rb @@ -4,20 +4,21 @@ # Both public endpoints otherwise read the whole body first and cap afterwards: # Dynamic Client Registration materializes JSON params (Rails, unbounded size), # and the MCP transport reads up to 4 MiB -- but only once the full request has -# already been received and buffered. This middleware runs at the very front of -# the stack and rejects a declared-oversize request outright, so a giant body is -# never parsed or copied by the app. +# been received. This middleware sits at the very front of the stack and bounds +# the request itself, so a giant body is never parsed or buffered by the app. # -# It caps the declared Content-Length, which is the normal path for the CLI -# agents (Claude Code, Codex) that use these endpoints. A body sent with no -# Content-Length (e.g. chunked transfer) falls through to the transport's own -# 4 MiB streaming read cap on /mcp and Rails' 100-level nesting limit on the DCR -# JSON -- so it is defense-in-depth, not the only limit. +# It bounds the ACTUAL `rack.input` stream rather than trusting the +# `Content-Length` header, so a chunked, Content-Length-less, or header-lying +# body is caught just the same. A within-limit body is read into memory and the +# stream is replaced with a rewound in-memory copy, so Rails param parsing and +# the MCP transport downstream still see the full, rewindable body. class McpBodyLimit # Match the MCP transport's own request ceiling so the two agree. MAX_BYTES = 4 * 1024 * 1024 - # Exact top-level paths; both are unmounted, apex-host routes. + # Canonical top-level paths; both are unmounted, apex-host routes. Rails routes + # the trailing-slash variants (`/mcp/`, `/oauth/register/`) to the same + # endpoints, so the guard normalizes a single trailing slash before matching. PROTECTED_PATHS = [ "/mcp", "/oauth/register" ].freeze def initialize(app) @@ -25,14 +26,32 @@ def initialize(app) end def call(env) - return too_large if guarded?(env) && declared_oversize?(env) + return @app.call(env) unless guarded?(env) + # Fast path: a declared oversize is rejected without reading the body at all. + return too_large if declared_oversize?(env) + + input = env["rack.input"] + return @app.call(env) if input.nil? + + input.rewind if input.respond_to?(:rewind) + # Read one byte past the cap: if anything remains, the body is too large. + buffer = input.read(MAX_BYTES + 1) || "".b + return too_large if buffer.bytesize > MAX_BYTES + + # Reading consumed the stream, so hand downstream a rewound in-memory copy. + env["rack.input"] = StringIO.new(buffer) @app.call(env) end private def guarded?(env) - env["REQUEST_METHOD"] == "POST" && PROTECTED_PATHS.include?(env["PATH_INFO"]) + env["REQUEST_METHOD"] == "POST" && PROTECTED_PATHS.include?(normalized_path(env)) + end + + def normalized_path(env) + path = env["PATH_INFO"].to_s + path.length > 1 ? path.chomp("/") : path end def declared_oversize?(env) diff --git a/test/config/mcp_configuration_test.rb b/test/config/mcp_configuration_test.rb new file mode 100644 index 0000000..8629d4c --- /dev/null +++ b/test/config/mcp_configuration_test.rb @@ -0,0 +1,31 @@ +require "test_helper" + +# config/initializers/mcp.rb wires the SDK's GLOBAL exception reporter -- the one +# the Streamable HTTP transport uses for pre-dispatch (body read/parse) failures, +# which the per-server reporter in McpController does not cover. +class McpConfigurationTest < ActiveSupport::TestCase + test "the global MCP reporter is configured (not the SDK no-op default)" do + assert MCP.configuration.exception_reporter?, "expected a global exception reporter" + end + + test "it routes transport failures to Rails.error and drops the raw request context" do + reported = [] + subscriber = Class.new do + define_method(:report) do |error, handled:, severity:, source: nil, context: {}| + reported << { error: error, source: source, context: context } + end + end.new + Rails.error.subscribe(subscriber) + + # The transport passes the raw body as context at its call site; simulate it. + MCP.configuration.exception_reporter.call(RuntimeError.new("transport boom"), { request: "SECRET PASTE BODY" }) + + boom = reported.find { |r| r[:error].is_a?(RuntimeError) && r[:error].message == "transport boom" } + assert boom, "expected the transport exception to be reported to Rails.error" + assert_equal "mcp-transport", boom[:source] + assert_not boom[:context].key?(:request), "raw request body must not be forwarded" + assert_not_includes boom[:context].values.map(&:to_s).join, "SECRET PASTE BODY" + ensure + Rails.error.unsubscribe(subscriber) if subscriber + end +end diff --git a/test/integration/mcp_body_limit_integration_test.rb b/test/integration/mcp_body_limit_integration_test.rb new file mode 100644 index 0000000..07f1436 --- /dev/null +++ b/test/integration/mcp_body_limit_integration_test.rb @@ -0,0 +1,64 @@ +require "test_helper" + +# Full-stack coverage for McpBodyLimit: a real oversized body driven through the +# entire Rails middleware stack and router must be rejected before the endpoint +# parses it, on both the canonical paths and their trailing-slash variants. +class McpBodyLimitIntegrationTest < ActionDispatch::IntegrationTest + OVERSIZE = ("a" * (McpBodyLimit::MAX_BYTES + 2048)).freeze + JSON_HEADERS = { "Content-Type" => "application/json" }.freeze + + test "an oversized DCR body is rejected with 413 before registration" do + before = Doorkeeper::Application.count + + post "/oauth/register", params: oversize_dcr_body, headers: JSON_HEADERS + + assert_response :content_too_large + assert_equal before, Doorkeeper::Application.count, "no application should be created" + end + + test "an oversized DCR body on the trailing-slash route is also rejected" do + before = Doorkeeper::Application.count + + post "/oauth/register/", params: oversize_dcr_body, headers: JSON_HEADERS + + assert_response :content_too_large + assert_equal before, Doorkeeper::Application.count + end + + test "an oversized /mcp body is rejected with 413" do + token = mint_token + post "/mcp", params: oversize_mcp_body, + headers: JSON_HEADERS.merge("Authorization" => "Bearer #{token.plaintext_token}") + + assert_response :content_too_large + end + + test "a normal DCR body still registers (regression: within-limit body passes through)" do + post "/oauth/register", + params: { redirect_uris: [ "http://127.0.0.1:51000/callback" ] }.to_json, + headers: JSON_HEADERS + + assert_response :created + assert response.parsed_body["client_id"].present? + end + + private + def oversize_dcr_body + { redirect_uris: [ "http://127.0.0.1:51000/callback" ], pad: OVERSIZE }.to_json + end + + def oversize_mcp_body + { jsonrpc: "2.0", id: 1, method: "initialize", params: { pad: OVERSIZE } }.to_json + end + + def mint_token + application = oauth_applications(:mcp_client) + Doorkeeper::AccessToken.create!( + application: application, + resource_owner_id: users(:alice).id, + scopes: "mcp:read mcp:write", + expires_in: 3600, + resource: McpOauth::CONFIG[:resource_uri] + ) + end +end diff --git a/test/integration/www_host_redirect_test.rb b/test/integration/www_host_redirect_test.rb index 881fb31..13dba30 100644 --- a/test/integration/www_host_redirect_test.rb +++ b/test/integration/www_host_redirect_test.rb @@ -4,16 +4,17 @@ # `*.` wildcard. OAuth/MCP routes are constrained to the canonical apex # host only, so a signed-in user who lands on the www host and clicks a # relative app link (e.g. "Connected agents") would otherwise 404. A routes- -# level 301 folds `www.` back onto the apex before anything else runs. +# level 308 folds `www.` back onto the apex before anything else runs. It +# is a 308 (not 301) so a POST is redirected without being rewritten to GET. class WwwHostRedirectTest < ActionDispatch::IntegrationTest APEX = McpOauth::CONFIG[:host] - test "a request on the www host 301-redirects to the apex, preserving the path" do + test "a request on the www host permanently redirects to the apex, preserving the path" do host! "www.#{APEX}" get "/oauth/authorized_applications" - assert_response :moved_permanently + assert_response :permanent_redirect assert_equal "http://#{APEX}/oauth/authorized_applications", @response.location end @@ -22,7 +23,7 @@ class WwwHostRedirectTest < ActionDispatch::IntegrationTest get "/oauth/authorized_applications?foo=bar" - assert_response :moved_permanently + assert_response :permanent_redirect assert_equal "http://#{APEX}/oauth/authorized_applications?foo=bar", @response.location end @@ -31,15 +32,27 @@ class WwwHostRedirectTest < ActionDispatch::IntegrationTest get "/" - assert_response :moved_permanently + assert_response :permanent_redirect assert_equal "http://#{APEX}/", @response.location end + test "a POST on the www host is redirected with 308, preserving the method" do + host! "www.#{APEX}" + + post "/api/pastes", params: { filename: "x.html" } + + # 308 tells the client to replay the POST (with its body) against the apex, + # rather than a 301 that browsers may downgrade to GET. + assert_response :permanent_redirect + assert_equal "http://#{APEX}/api/pastes", @response.location + end + test "the canonical apex host is not redirected" do host! APEX get "/oauth/authorized_applications" + assert_not_equal 308, @response.status assert_not_equal 301, @response.status end @@ -49,7 +62,8 @@ class WwwHostRedirectTest < ActionDispatch::IntegrationTest get "/" # An unknown token subdomain 404s through the paste routing; either way it is - # never our 301 back to the apex. + # never our redirect back to the apex. + assert_not_equal 308, @response.status assert_not_equal 301, @response.status end end diff --git a/test/middleware/mcp_body_limit_test.rb b/test/middleware/mcp_body_limit_test.rb index b36b2fb..dbb974e 100644 --- a/test/middleware/mcp_body_limit_test.rb +++ b/test/middleware/mcp_body_limit_test.rb @@ -1,36 +1,45 @@ require "test_helper" class McpBodyLimitTest < ActiveSupport::TestCase - DOWNSTREAM = ->(_env) { [ 200, { "Content-Type" => "text/plain" }, [ "ok" ] ] } + # Echoes back whatever body it receives, so tests can prove the within-limit + # stream was preserved and handed downstream intact. + DOWNSTREAM = ->(env) { [ 200, { "Content-Type" => "text/plain" }, [ env["rack.input"].read ] ] } - test "rejects an oversize POST to /mcp with 413 before reaching downstream" do - status, _headers, body = call("/mcp", "POST", McpBodyLimit::MAX_BYTES + 1) + test "rejects an oversize body declared via Content-Length (fast path)" do + status, _headers, body = call("/mcp", "POST", body: "", content_length: McpBodyLimit::MAX_BYTES + 1) assert_equal 413, status assert_includes body.join, "payload_too_large" end - test "rejects an oversize POST to /oauth/register with 413" do - status, = call("/oauth/register", "POST", McpBodyLimit::MAX_BYTES + 1) + test "rejects an oversize actual body even with NO Content-Length (stream bound)" do + status, = call("/oauth/register", "POST", body: oversize, content_length: :none) assert_equal 413, status end - test "passes a body at the limit through to downstream" do - status, = call("/mcp", "POST", McpBodyLimit::MAX_BYTES) + test "rejects an oversize actual body that lies about a small Content-Length" do + status, = call("/mcp", "POST", body: oversize, content_length: 10) - assert_equal 200, status + assert_equal 413, status + end + + test "guards the trailing-slash route variants Rails also routes" do + assert_equal 413, call("/mcp/", "POST", body: oversize, content_length: :none).first + assert_equal 413, call("/oauth/register/", "POST", body: oversize, content_length: :none).first end - test "passes a request with no Content-Length through (relies on transport cap)" do - status, = call("/mcp", "POST", nil) + test "passes an at-limit body through and preserves it for downstream" do + payload = "a" * McpBodyLimit::MAX_BYTES + status, _headers, body = call("/mcp", "POST", body: payload) assert_equal 200, status + assert_equal payload.bytesize, body.join.bytesize, "downstream must receive the full body" end test "ignores non-POST methods and unguarded paths" do - assert_equal 200, call("/mcp", "GET", McpBodyLimit::MAX_BYTES + 1).first - assert_equal 200, call("/api/pastes", "POST", McpBodyLimit::MAX_BYTES + 1).first + assert_equal 200, call("/mcp", "GET", body: oversize, content_length: :none).first + assert_equal 200, call("/api/pastes", "POST", body: oversize, content_length: :none).first end test "is installed at the front of the application middleware stack" do @@ -41,9 +50,15 @@ class McpBodyLimitTest < ActiveSupport::TestCase end private - def call(path, method, content_length) - env = { "REQUEST_METHOD" => method, "PATH_INFO" => path } - env["CONTENT_LENGTH"] = content_length.to_s unless content_length.nil? + def oversize + "a" * (McpBodyLimit::MAX_BYTES + 128) + end + + def call(path, method, body: "", content_length: :from_body) + env = { "REQUEST_METHOD" => method, "PATH_INFO" => path, "rack.input" => StringIO.new(body) } + unless content_length == :none + env["CONTENT_LENGTH"] = (content_length == :from_body ? body.bytesize : content_length).to_s + end McpBodyLimit.new(DOWNSTREAM).call(env) end end diff --git a/test/models/mcp_tools/create_paste_test.rb b/test/models/mcp_tools/create_paste_test.rb index e3a6b93..81c370a 100644 --- a/test/models/mcp_tools/create_paste_test.rb +++ b/test/models/mcp_tools/create_paste_test.rb @@ -131,8 +131,50 @@ class McpTools::CreatePasteTest < ActiveSupport::TestCase assert_not @alice.folders.exists?(name: "Doomed") end + # A concurrent writer can take the same custom_subdomain between our validation + # SELECT and the INSERT, so paste.save raises RecordNotUnique at the DB layer. + # That race must surface as the same stable tool error, not a leaked exception. + test "a custom_subdomain uniqueness race returns a stable error, not an exception" do + response = simulating_uniqueness_race(Paste, :save) do + create(content: "

x

", format: "html", custom_subdomain: "racy-sub") + end + + assert response.error? + assert_equal "validation_failed", response.structured_content[:code] + assert_equal "custom_subdomain", response.structured_content[:field] + end + + test "an auto-created folder is rolled back when a uniqueness race aborts the paste" do + assert_no_difference -> { @alice.folders.count } do + response = simulating_uniqueness_race(Paste, :save) do + create(content: "

x

", format: "html", custom_subdomain: "racy-sub", folder_name: "Doomed By Race") + end + assert response.error? + end + + assert_not @alice.folders.exists?(name: "Doomed By Race") + end + private def create(**args) McpTools::CreatePaste.call(**args, server_context: @ctx) end + + # Forces the next call to `method` on `klass` to raise RecordNotUnique once, + # then restores the original. Mirrors the folder tool tests. + def simulating_uniqueness_race(klass, method) + defined_here = klass.instance_methods(false).include?(method) + original = klass.instance_method(method) + raised = false + klass.send(:define_method, method) do |*args, **kwargs, &block| + unless raised + raised = true + raise ActiveRecord::RecordNotUnique, "PG::UniqueViolation" + end + original.bind(self).call(*args, **kwargs, &block) + end + yield + ensure + defined_here ? klass.send(:define_method, method, original) : klass.send(:remove_method, method) + end end From edd21fb039f9a29eefee35b748f6e11f269ca41c Mon Sep 17 00:00:00 2001 From: Ali Hamdi Ali Fadel Date: Fri, 10 Jul 2026 17:34:55 +0300 Subject: [PATCH 15/22] fix(mcp): normalize body-limit paths with the router's own normalization McpBodyLimit#normalized_path only stripped a single trailing slash, but Rails' router also collapses repeated and leading slashes before routing, so an oversized request in a repeated-slash form (e.g. /oauth//register, //mcp) slipped past the guard yet still reached the endpoint. Use ActionDispatch::Journey::Router::Utils.normalize_path -- the exact function the router applies -- so the guard matches every path the router routes. The integration harness normalizes request paths before dispatch, so the new full-stack repeated-slash tests drive Rails.application.call with a crafted env to exercise the real, un-normalized PATH_INFO a raw HTTP client can send. Co-Authored-By: Claude Fable 5 --- lib/middleware/mcp_body_limit.rb | 12 ++++--- .../mcp_body_limit_integration_test.rb | 33 +++++++++++++++++++ test/middleware/mcp_body_limit_test.rb | 8 +++-- 3 files changed, 45 insertions(+), 8 deletions(-) diff --git a/lib/middleware/mcp_body_limit.rb b/lib/middleware/mcp_body_limit.rb index 3ea0cb1..641b017 100644 --- a/lib/middleware/mcp_body_limit.rb +++ b/lib/middleware/mcp_body_limit.rb @@ -16,9 +16,7 @@ class McpBodyLimit # Match the MCP transport's own request ceiling so the two agree. MAX_BYTES = 4 * 1024 * 1024 - # Canonical top-level paths; both are unmounted, apex-host routes. Rails routes - # the trailing-slash variants (`/mcp/`, `/oauth/register/`) to the same - # endpoints, so the guard normalizes a single trailing slash before matching. + # Canonical top-level paths; both are unmounted, apex-host routes. PROTECTED_PATHS = [ "/mcp", "/oauth/register" ].freeze def initialize(app) @@ -49,9 +47,13 @@ def guarded?(env) env["REQUEST_METHOD"] == "POST" && PROTECTED_PATHS.include?(normalized_path(env)) end + # Match exactly what Rails' router matches. It normalizes PATH_INFO before + # routing -- collapsing repeated slashes and stripping a trailing one -- so + # forms like `/oauth//register`, `//mcp`, or `/mcp/` all reach the protected + # endpoints. Using the router's own normalization keeps the guard from being + # bypassed by any slash variant the router still routes. def normalized_path(env) - path = env["PATH_INFO"].to_s - path.length > 1 ? path.chomp("/") : path + ActionDispatch::Journey::Router::Utils.normalize_path(env["PATH_INFO"].to_s) end def declared_oversize?(env) diff --git a/test/integration/mcp_body_limit_integration_test.rb b/test/integration/mcp_body_limit_integration_test.rb index 07f1436..bee9c5c 100644 --- a/test/integration/mcp_body_limit_integration_test.rb +++ b/test/integration/mcp_body_limit_integration_test.rb @@ -33,6 +33,26 @@ class McpBodyLimitIntegrationTest < ActionDispatch::IntegrationTest assert_response :content_too_large end + # ActionDispatch's integration harness normalizes the request path before it is + # dispatched, so `post "/oauth//register"` would not actually exercise a + # repeated-slash PATH_INFO. Drive the real middleware stack directly with a + # crafted env -- the form a raw HTTP client (curl, Cloudflare) can send, which + # Rails' router still normalizes and routes -- to prove the guard catches it. + test "an oversized repeated-slash DCR request is rejected full-stack" do + before = Doorkeeper::Application.count + + status, = call_stack("/oauth//register", oversize_dcr_body) + + assert_equal 413, status + assert_equal before, Doorkeeper::Application.count + end + + test "an oversized repeated-slash /mcp request is rejected full-stack" do + status, = call_stack("//mcp", oversize_mcp_body) + + assert_equal 413, status + end + test "a normal DCR body still registers (regression: within-limit body passes through)" do post "/oauth/register", params: { redirect_uris: [ "http://127.0.0.1:51000/callback" ] }.to_json, @@ -43,6 +63,19 @@ class McpBodyLimitIntegrationTest < ActionDispatch::IntegrationTest end private + # Runs the full Rack middleware stack (McpBodyLimit included) against a raw + # env whose PATH_INFO keeps the repeated slash the integration harness would + # otherwise normalize away. + def call_stack(path_info, body) + env = Rack::MockRequest.env_for("/", method: "POST", "CONTENT_TYPE" => "application/json") + env["PATH_INFO"] = path_info + env["rack.input"] = StringIO.new(body) + env["CONTENT_LENGTH"] = body.bytesize.to_s + status, _headers, response_body = Rails.application.call(env) + response_body.close if response_body.respond_to?(:close) + [ status ] + end + def oversize_dcr_body { redirect_uris: [ "http://127.0.0.1:51000/callback" ], pad: OVERSIZE }.to_json end diff --git a/test/middleware/mcp_body_limit_test.rb b/test/middleware/mcp_body_limit_test.rb index dbb974e..8bf36c7 100644 --- a/test/middleware/mcp_body_limit_test.rb +++ b/test/middleware/mcp_body_limit_test.rb @@ -24,9 +24,11 @@ class McpBodyLimitTest < ActiveSupport::TestCase assert_equal 413, status end - test "guards the trailing-slash route variants Rails also routes" do - assert_equal 413, call("/mcp/", "POST", body: oversize, content_length: :none).first - assert_equal 413, call("/oauth/register/", "POST", body: oversize, content_length: :none).first + test "guards every slash variant Rails' router normalizes to a protected path" do + # Rails routes all of these to the same endpoints, so the guard must too. + %w[/mcp/ //mcp /mcp// /oauth/register/ /oauth//register //oauth/register /oauth///register/].each do |path| + assert_equal 413, call(path, "POST", body: oversize, content_length: :none).first, "#{path} should be guarded" + end end test "passes an at-limit body through and preserves it for downstream" do From 2bb2f62fceb6690a3fed0bbffbbdeedd22f9a80b Mon Sep 17 00:00:00 2001 From: Ali Hamdi Ali Fadel Date: Fri, 10 Jul 2026 18:58:45 +0300 Subject: [PATCH 16/22] fix(mcp): refresh-aware cleanup, per-path body limits, page clamp, session-safe return-to Round-6 review follow-ups: - OauthCleanupJob: an unrevoked token carrying a refresh_token keeps its dynamic app alive even after the 1-hour access token expires (the refresh token can still mint access). The effective-token predicate now accounts for this; genuine inactivity is still handled by phase 1's 90-day revocation. Stream the deletions with find_each and add supporting indexes (findings 1, 6). - McpBodyLimit now guards every OAuth POST endpoint (token/authorize/revoke/ introspect/register), not just register, with a tight 1 MB cap; the /mcp endpoint gets an 8 MB cap so a full 2 MB paste still fits after JSON-string escaping (which can double quote-heavy content). The transport's max_request_bytes is raised to match (findings 2, 3). - list_pastes clamps page and bounds it in the input schema, so an absurd page can't reach Postgres as an overflowing OFFSET and leak PG error text (finding 4). - Authentication caps the return-to path stored in the cookie session, so a multi-kilobyte OAuth state no longer overflows it into a 500 (finding 5). Co-Authored-By: Claude Fable 5 --- app/controllers/concerns/authentication.rb | 11 +++- app/controllers/mcp_controller.rb | 12 +++-- app/jobs/oauth_cleanup_job.rb | 38 ++++++++----- app/models/mcp_tools/list_pastes.rb | 14 +++-- ...0260710154513_add_oauth_cleanup_indexes.rb | 15 ++++++ db/schema.rb | 5 +- lib/middleware/mcp_body_limit.rb | 54 ++++++++++++------- public/llms.txt | 2 + test/controllers/mcp_controller_test.rb | 4 +- .../authentication_return_to_test.rb | 32 +++++++++++ .../mcp_body_limit_integration_test.rb | 29 ++++++++-- test/jobs/oauth_cleanup_job_test.rb | 31 ++++++++++- test/middleware/mcp_body_limit_test.rb | 41 +++++++++----- test/models/mcp_tools/list_pastes_test.rb | 19 +++++++ 14 files changed, 248 insertions(+), 59 deletions(-) create mode 100644 db/migrate/20260710154513_add_oauth_cleanup_indexes.rb create mode 100644 test/integration/authentication_return_to_test.rb diff --git a/app/controllers/concerns/authentication.rb b/app/controllers/concerns/authentication.rb index 53eb496..bf4c406 100644 --- a/app/controllers/concerns/authentication.rb +++ b/app/controllers/concerns/authentication.rb @@ -9,6 +9,13 @@ module Authentication # __Host- prefix makes browsers reject Domain-scoped variants. AUTH_COOKIE_NAME = Rails.env.production? ? "__Host-pastehtml_session_id" : "pastehtml_session_id" + # Cap the post-login return path stored in the (cookie) session. The whole + # session must fit in ~4 KB; a very long path -- e.g. an OAuth authorize URL + # with a multi-kilobyte `state` -- would raise CookieOverflow and 500 the + # sign-in redirect. Above this we skip storing it (login still works; resume + # falls back to the default landing page) rather than crash. + MAX_RETURN_TO_BYTES = 1500 + included do before_action :require_authentication helper_method :authenticated?, :current_user @@ -42,7 +49,9 @@ def find_session_by_cookie end def request_authentication - session[:return_to_after_authenticating] = request.fullpath if request.request_method == "GET" + if request.get? && request.fullpath.bytesize <= MAX_RETURN_TO_BYTES + session[:return_to_after_authenticating] = request.fullpath + end # 303 so an unauthenticated PATCH/DELETE (folders, api keys, sign-out, owned # paste updates) follows to sign-in as a GET instead of replaying the verb # against the GET-only /session/new. diff --git a/app/controllers/mcp_controller.rb b/app/controllers/mcp_controller.rb index d662306..4bf2197 100644 --- a/app/controllers/mcp_controller.rb +++ b/app/controllers/mcp_controller.rb @@ -30,9 +30,10 @@ class McpController < ActionController::API # (scope oscillation). CHALLENGE_SCOPE = "#{McpTools::READ_SCOPE} #{McpTools::WRITE_SCOPE}".freeze - # The transport reads at most this many bytes before rejecting (4 MiB); the - # pre-dispatch peek honors the same bound so it never becomes a bypass of it. - MAX_REQUEST_BYTES = MCP::Server::Transports::StreamableHTTPTransport::DEFAULT_MAX_REQUEST_BYTES + # The request ceiling for /mcp, shared with the front-of-stack McpBodyLimit so + # the transport, the pre-dispatch peek, and the middleware all agree. Sized to + # fit a 2 MB paste after JSON-string escaping (see McpBodyLimit::MCP_MAX_BYTES). + MAX_REQUEST_BYTES = McpBodyLimit::MCP_MAX_BYTES # The peek MUST use the transport's own nesting bound, not a lower one. If the # peek stopped parsing before the transport did, a body nested between the two @@ -90,7 +91,10 @@ def handle stateless: true, # The transport's default Host allowlist is loopback-only, so production # (and the test host) would 403 without this. Origin is validated above. - allowed_hosts: [ McpOauth::CONFIG[:host] ] + allowed_hosts: [ McpOauth::CONFIG[:host] ], + # Raise the transport's own body ceiling to match the middleware and the + # peek, so a legitimate 2 MB paste (JSON-escaped) is not rejected here. + max_request_bytes: MAX_REQUEST_BYTES ) status, headers, body = transport.handle_request(request) diff --git a/app/jobs/oauth_cleanup_job.rb b/app/jobs/oauth_cleanup_job.rb index fac7d7a..f8cdfd5 100644 --- a/app/jobs/oauth_cleanup_job.rb +++ b/app/jobs/oauth_cleanup_job.rb @@ -26,13 +26,22 @@ class OauthCleanupJob < ApplicationJob # of age or activity. DYNAMIC_APPLICATION_STALE_AFTER = 30.days - # A token or grant is "effective" -- still able to authorize a request -- only - # while it is unrevoked AND unexpired. A nil expires_in means the credential - # never expires (Doorkeeper permits non-expiring access tokens), so it stays - # effective indefinitely. An expired-but-unrevoked credential is inaccessible, - # so it must NOT keep a stale dynamic app alive. Both Doorkeeper tables share - # these column names, so the one predicate drives both subqueries below. - EFFECTIVE_CREDENTIAL_SQL = + # An access token keeps a dynamic app alive while it is still USABLE, not just + # while its short-lived access half is unexpired. An unrevoked token that + # carries a refresh_token can still mint new access tokens (refresh tokens do + # not expire in this app), so the connection is live even after the 1-hour + # access token lapses. Treating an expired-but-refreshable token as dead is + # what wrongly disconnected active clients. Genuine inactivity is handled by + # phase 1, which revokes tokens unused for TOKEN_STALE_AFTER; a revoked token + # then fails this predicate and lets phase 2 delete the app. + EFFECTIVE_TOKEN_SQL = + "revoked_at IS NULL AND " \ + "(refresh_token IS NOT NULL OR expires_in IS NULL OR " \ + "created_at + expires_in * interval '1 second' > now())" + + # A grant (authorization code) has no refresh capability, so it is effective + # only while unrevoked and unexpired. + EFFECTIVE_GRANT_SQL = "revoked_at IS NULL AND " \ "(expires_in IS NULL OR created_at + expires_in * interval '1 second' > now())" @@ -73,11 +82,16 @@ def delete_abandoned_dynamic_applications! abandoned = Doorkeeper::Application .where(dynamic: true) .where("created_at < ?", DYNAMIC_APPLICATION_STALE_AFTER.ago) - .where.not(id: Doorkeeper::AccessToken.where(EFFECTIVE_CREDENTIAL_SQL).select(:application_id)) - .where.not(id: Doorkeeper::AccessGrant.where(EFFECTIVE_CREDENTIAL_SQL).select(:application_id)) - .to_a + .where.not(id: Doorkeeper::AccessToken.where(EFFECTIVE_TOKEN_SQL).select(:application_id)) + .where.not(id: Doorkeeper::AccessGrant.where(EFFECTIVE_GRANT_SQL).select(:application_id)) - abandoned.each(&:destroy) - abandoned.size + # Stream rather than materialize the whole set (bounded job memory under + # high DCR churn); destroy so Doorkeeper's dependent cleanup runs. + deleted = 0 + abandoned.find_each do |application| + application.destroy + deleted += 1 + end + deleted end end diff --git a/app/models/mcp_tools/list_pastes.rb b/app/models/mcp_tools/list_pastes.rb index 4ef2e20..4c45594 100644 --- a/app/models/mcp_tools/list_pastes.rb +++ b/app/models/mcp_tools/list_pastes.rb @@ -4,6 +4,10 @@ module McpTools # the byte size is projected instead via the with_content_size scope. class ListPastes < BaseTool PAGE_SIZE = 20 + # Upper bound on the 1-based page number. Far beyond any real paste count, + # and small enough that (MAX_PAGE - 1) * PAGE_SIZE stays well within a + # Postgres bigint OFFSET. + MAX_PAGE = 1_000_000 tool_name "list_pastes" description <<~TEXT.strip @@ -18,7 +22,7 @@ class ListPastes < BaseTool properties: { folder_id: { type: "integer", description: "Optional: only pastes in this folder." }, folder_name: { type: "string", description: "Optional: only pastes in the folder with this name (case-insensitive)." }, - page: { type: "integer", minimum: 1, description: "1-based page number; page size is fixed at #{PAGE_SIZE}." } + page: { type: "integer", minimum: 1, maximum: MAX_PAGE, description: "1-based page number; page size is fixed at #{PAGE_SIZE}." } }, required: [], additionalProperties: false @@ -80,8 +84,12 @@ def call(folder_id: nil, folder_name: nil, page: nil, server_context:) private def normalize_page(page) - page = page.to_i - page < 1 ? 1 : page + # Clamp to a sane range. An unbounded page reaches Postgres as an + # OFFSET of (page - 1) * PAGE_SIZE; a huge value overflows bigint and + # raises PG::NumericValueOutOfRange, whose message the MCP gem would + # surface to the client. MAX_PAGE keeps the offset comfortably in range + # (and is far beyond any real paste count). + page.to_i.clamp(1, MAX_PAGE) end def page_of(scope, page) diff --git a/db/migrate/20260710154513_add_oauth_cleanup_indexes.rb b/db/migrate/20260710154513_add_oauth_cleanup_indexes.rb new file mode 100644 index 0000000..3da3bfb --- /dev/null +++ b/db/migrate/20260710154513_add_oauth_cleanup_indexes.rb @@ -0,0 +1,15 @@ +class AddOauthCleanupIndexes < ActiveRecord::Migration[8.1] + def change + # Supports OauthCleanupJob phase 2's candidate scan: dynamic apps past an + # age threshold. + add_index :oauth_applications, [ :dynamic, :created_at ] + + # Supports the "no effective credential" NOT-EXISTS subqueries: both filter + # by application_id among non-revoked rows. Partial indexes stay small (only + # live rows) and directly serve the `revoked_at IS NULL` predicate. + add_index :oauth_access_tokens, :application_id, + where: "revoked_at IS NULL", name: "index_oauth_access_tokens_active_by_application" + add_index :oauth_access_grants, :application_id, + where: "revoked_at IS NULL", name: "index_oauth_access_grants_active_by_application" + end +end diff --git a/db/schema.rb b/db/schema.rb index c039e23..c44b4ac 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.1].define(version: 2026_07_10_091356) do +ActiveRecord::Schema[8.1].define(version: 2026_07_10_154513) do # These are extensions that must be enabled in order to support this database enable_extension "pg_catalog.plpgsql" @@ -51,6 +51,7 @@ t.datetime "revoked_at" t.string "scopes", default: "", null: false t.string "token", null: false + t.index ["application_id"], name: "index_oauth_access_grants_active_by_application", where: "(revoked_at IS NULL)" t.index ["application_id"], name: "index_oauth_access_grants_on_application_id" t.index ["resource_owner_id"], name: "index_oauth_access_grants_on_resource_owner_id" t.index ["token"], name: "index_oauth_access_grants_on_token", unique: true @@ -67,6 +68,7 @@ t.datetime "revoked_at" t.string "scopes" t.string "token", null: false + t.index ["application_id"], name: "index_oauth_access_tokens_active_by_application", where: "(revoked_at IS NULL)" t.index ["application_id"], name: "index_oauth_access_tokens_on_application_id" t.index ["refresh_token"], name: "index_oauth_access_tokens_on_refresh_token", unique: true t.index ["resource_owner_id"], name: "index_oauth_access_tokens_on_resource_owner_id" @@ -83,6 +85,7 @@ t.string "secret" t.string "uid", null: false t.datetime "updated_at", null: false + t.index ["dynamic", "created_at"], name: "index_oauth_applications_on_dynamic_and_created_at" t.index ["uid"], name: "index_oauth_applications_on_uid", unique: true end diff --git a/lib/middleware/mcp_body_limit.rb b/lib/middleware/mcp_body_limit.rb index 641b017..83b0396 100644 --- a/lib/middleware/mcp_body_limit.rb +++ b/lib/middleware/mcp_body_limit.rb @@ -1,11 +1,13 @@ -# Rejects oversized POST bodies to the two public MCP/OAuth endpoints before any +# Rejects oversized POST bodies to the public MCP and OAuth endpoints before any # downstream middleware, Rails param parsing, or the MCP transport reads them. # -# Both public endpoints otherwise read the whole body first and cap afterwards: -# Dynamic Client Registration materializes JSON params (Rails, unbounded size), -# and the MCP transport reads up to 4 MiB -- but only once the full request has -# been received. This middleware sits at the very front of the stack and bounds -# the request itself, so a giant body is never parsed or buffered by the app. +# These endpoints otherwise read the whole body first and cap afterwards: the +# OAuth endpoints (/oauth/token, /oauth/authorize, /oauth/revoke, /oauth/ +# introspect, /oauth/register) materialize form/JSON params through Rails (and +# resource-indicator enforcement re-reads the form body), and the MCP transport +# reads the body only once the full request has arrived. This middleware sits at +# the very front of the stack and bounds the request itself, so a giant body is +# never parsed or buffered by the app. # # It bounds the ACTUAL `rack.input` stream rather than trusting the # `Content-Length` header, so a chunked, Content-Length-less, or header-lying @@ -13,29 +15,38 @@ # stream is replaced with a rewound in-memory copy, so Rails param parsing and # the MCP transport downstream still see the full, rewindable body. class McpBodyLimit - # Match the MCP transport's own request ceiling so the two agree. - MAX_BYTES = 4 * 1024 * 1024 + # The /mcp endpoint carries paste content (up to a 2 MB paste, which balloons + # under JSON-string escaping -- quote/backslash-heavy HTML can roughly double), + # so its ceiling is generous. It is authenticated and rate-limited, which + # bounds the abuse surface. The MCP transport is configured with this same + # ceiling (see McpController) so the two agree. + MCP_MAX_BYTES = 8 * 1024 * 1024 - # Canonical top-level paths; both are unmounted, apex-host routes. - PROTECTED_PATHS = [ "/mcp", "/oauth/register" ].freeze + # OAuth requests are tiny (a token exchange, a registration); a much tighter + # cap bounds the unauthenticated form-parsing DoS surface. + OAUTH_MAX_BYTES = 1 * 1024 * 1024 + + MCP_PATH = "/mcp" + OAUTH_PREFIX = "/oauth/" def initialize(app) @app = app end def call(env) - return @app.call(env) unless guarded?(env) + limit = limit_for(env) + return @app.call(env) if limit.nil? # Fast path: a declared oversize is rejected without reading the body at all. - return too_large if declared_oversize?(env) + return too_large if declared_oversize?(env, limit) input = env["rack.input"] return @app.call(env) if input.nil? input.rewind if input.respond_to?(:rewind) # Read one byte past the cap: if anything remains, the body is too large. - buffer = input.read(MAX_BYTES + 1) || "".b - return too_large if buffer.bytesize > MAX_BYTES + buffer = input.read(limit + 1) || "".b + return too_large if buffer.bytesize > limit # Reading consumed the stream, so hand downstream a rewound in-memory copy. env["rack.input"] = StringIO.new(buffer) @@ -43,8 +54,15 @@ def call(env) end private - def guarded?(env) - env["REQUEST_METHOD"] == "POST" && PROTECTED_PATHS.include?(normalized_path(env)) + # The byte ceiling for this request, or nil if the endpoint is not guarded. + def limit_for(env) + return nil unless env["REQUEST_METHOD"] == "POST" + + path = normalized_path(env) + return MCP_MAX_BYTES if path == MCP_PATH + return OAUTH_MAX_BYTES if path.start_with?(OAUTH_PREFIX) + + nil end # Match exactly what Rails' router matches. It normalizes PATH_INFO before @@ -56,9 +74,9 @@ def normalized_path(env) ActionDispatch::Journey::Router::Utils.normalize_path(env["PATH_INFO"].to_s) end - def declared_oversize?(env) + def declared_oversize?(env, limit) length = env["CONTENT_LENGTH"] - !length.nil? && length.to_i > MAX_BYTES + !length.nil? && length.to_i > limit end def too_large diff --git a/public/llms.txt b/public/llms.txt index b18fcad..72000e3 100644 --- a/public/llms.txt +++ b/public/llms.txt @@ -131,6 +131,8 @@ permanent, so there is no delete-paste tool: Everything else in this guide -- self-contained documents, the 2 MB limit, always showing the user the live_url -- applies equally when publishing over MCP. +(A whole MCP request is capped at 8 MB, which comfortably fits a 2 MB paste even +after JSON-string escaping roughly doubles quote/backslash-heavy content.) ## Publish Markdown diff --git a/test/controllers/mcp_controller_test.rb b/test/controllers/mcp_controller_test.rb index a564cdb..482810a 100644 --- a/test/controllers/mcp_controller_test.rb +++ b/test/controllers/mcp_controller_test.rb @@ -206,7 +206,9 @@ class McpControllerTest < ActionDispatch::IntegrationTest assert_response :bad_request end - test "an oversized body reaches the transport's oversize handling, no 500" do + test "an oversized body is rejected with 413, no 500" do + # Past the shared /mcp ceiling, so the front-of-stack McpBodyLimit rejects it + # (the transport's own cap is the same value, a defense-in-depth backstop). filler = "a" * (McpController::MAX_REQUEST_BYTES + 128) body = %({"jsonrpc":"2.0","id":1,"method":"initialize","params":{"filler":"#{filler}"}}) diff --git a/test/integration/authentication_return_to_test.rb b/test/integration/authentication_return_to_test.rb new file mode 100644 index 0000000..7ef96f3 --- /dev/null +++ b/test/integration/authentication_return_to_test.rb @@ -0,0 +1,32 @@ +require "test_helper" + +# Authentication#request_authentication stores the requested path in the cookie +# session for post-login resume. The OAuth authorize endpoint naturally produces +# very long paths (a multi-kilobyte `state`), which would overflow the ~4 KB +# cookie session and raise CookieOverflow -- an uncaught 500 on the sign-in +# redirect. The path is stored only when it fits. +class AuthenticationReturnToTest < ActionDispatch::IntegrationTest + test "a signed-out request to an authenticated route with a huge query does not 500" do + huge_state = "s" * 5_000 + + get "/oauth/authorized_applications", params: { state: huge_state } + + # Redirected to sign-in (not crashed): the over-long return path was simply + # not stored, so the session cookie never overflowed. + assert_response :see_other + assert_redirected_to new_session_path + end + + test "a normal-length path is still stored for post-login resume" do + get "/oauth/authorized_applications" + + assert_response :see_other + assert_equal "/oauth/authorized_applications", session[:return_to_after_authenticating] + end + + test "an over-long path is skipped rather than stored" do + get "/oauth/authorized_applications", params: { state: "s" * 5_000 } + + assert_nil session[:return_to_after_authenticating] + end +end diff --git a/test/integration/mcp_body_limit_integration_test.rb b/test/integration/mcp_body_limit_integration_test.rb index bee9c5c..5a971a9 100644 --- a/test/integration/mcp_body_limit_integration_test.rb +++ b/test/integration/mcp_body_limit_integration_test.rb @@ -4,7 +4,8 @@ # entire Rails middleware stack and router must be rejected before the endpoint # parses it, on both the canonical paths and their trailing-slash variants. class McpBodyLimitIntegrationTest < ActionDispatch::IntegrationTest - OVERSIZE = ("a" * (McpBodyLimit::MAX_BYTES + 2048)).freeze + OVERSIZE_OAUTH = ("a" * (McpBodyLimit::OAUTH_MAX_BYTES + 2048)).freeze + OVERSIZE_MCP = ("a" * (McpBodyLimit::MCP_MAX_BYTES + 2048)).freeze JSON_HEADERS = { "Content-Type" => "application/json" }.freeze test "an oversized DCR body is rejected with 413 before registration" do @@ -16,6 +17,28 @@ class McpBodyLimitIntegrationTest < ActionDispatch::IntegrationTest assert_equal before, Doorkeeper::Application.count, "no application should be created" end + test "an oversized /oauth/token body is rejected with 413 before form parsing" do + post "/oauth/token", params: { grant_type: "authorization_code", pad: OVERSIZE_OAUTH }.to_json, headers: JSON_HEADERS + + assert_response :content_too_large + end + + test "a 2 MB paste that JSON-escapes past the old 4 MB limit publishes through MCP" do + token = mint_token + # A full 2 MiB of quote characters -- the maximum valid paste content. Each + # byte escapes to \" in JSON, so the request serializes to just over 4 MiB: + # rejected by the old 4 MiB ceiling, accepted under the raised /mcp ceiling. + content = '"' * Paste::MAX_CONTENT_BYTES + body = { jsonrpc: "2.0", id: 1, method: "tools/call", + params: { name: "create_paste", arguments: { content: content, format: "html" } } }.to_json + assert_operator body.bytesize, :>, 4 * 1024 * 1024, "sanity: the request exceeds the old 4 MB limit" + + post "/mcp", params: body, headers: JSON_HEADERS.merge("Authorization" => "Bearer #{token.plaintext_token}") + + assert_response :ok + assert response.parsed_body.dig("result", "structuredContent", "token").present?, "the paste should be created" + end + test "an oversized DCR body on the trailing-slash route is also rejected" do before = Doorkeeper::Application.count @@ -77,11 +100,11 @@ def call_stack(path_info, body) end def oversize_dcr_body - { redirect_uris: [ "http://127.0.0.1:51000/callback" ], pad: OVERSIZE }.to_json + { redirect_uris: [ "http://127.0.0.1:51000/callback" ], pad: OVERSIZE_OAUTH }.to_json end def oversize_mcp_body - { jsonrpc: "2.0", id: 1, method: "initialize", params: { pad: OVERSIZE } }.to_json + { jsonrpc: "2.0", id: 1, method: "initialize", params: { pad: OVERSIZE_MCP } }.to_json end def mint_token diff --git a/test/jobs/oauth_cleanup_job_test.rb b/test/jobs/oauth_cleanup_job_test.rb index 716f0e0..8d536c2 100644 --- a/test/jobs/oauth_cleanup_job_test.rb +++ b/test/jobs/oauth_cleanup_job_test.rb @@ -148,6 +148,32 @@ class OauthCleanupJobTest < ActiveJob::TestCase assert Doorkeeper::Application.exists?(application.id) end + # An expired access token that still carries a refresh_token is NOT abandoned: + # the refresh token can mint new access tokens, so the connection is live. In + # production the token endpoint always issues refresh tokens (use_refresh_token), + # so this is the realistic shape -- unlike a bare create! which has none. + test "a dynamic app 31 days old with an expired but refresh-capable token is kept" do + application = create_application(dynamic: true, created_at: 31.days.ago) + # Access half expired (created 2 days ago, 1h expiry) but a live refresh token. + create_token(application: application, created_at: 2.days.ago, refresh_token: SecureRandom.hex(24)) + + OauthCleanupJob.perform_now + + assert Doorkeeper::Application.exists?(application.id), "a refresh-capable connection must not be disconnected" + end + + # But refresh capability is not immortality: once the token has been inactive + # past the 90-day window, phase 1 revokes it and phase 2 then deletes the app. + test "a refresh-capable token inactive for 91 days is revoked then its app deleted" do + application = create_application(dynamic: true, created_at: 100.days.ago) + token = create_token(application: application, created_at: 91.days.ago, refresh_token: SecureRandom.hex(24)) + + OauthCleanupJob.perform_now + + assert_not Doorkeeper::Application.exists?(application.id) + assert_not Doorkeeper::AccessToken.exists?(token.id) + end + # --- Composition: phase 1's revocation feeds phase 2's deletion ----------- test "phase 1 revoking a stale token lets phase 2 delete the now-abandoned dynamic app in the same run" do @@ -191,7 +217,7 @@ def create_application(dynamic:, created_at: Time.current) application end - def create_token(application: nil, user: @user, last_used_at: nil, created_at: nil, revoked_at: nil) + def create_token(application: nil, user: @user, last_used_at: nil, created_at: nil, revoked_at: nil, refresh_token: nil) application ||= create_application(dynamic: false) token = Doorkeeper::AccessToken.create!( application: application, @@ -203,7 +229,8 @@ def create_token(application: nil, user: @user, last_used_at: nil, created_at: n token.update_columns( last_used_at: last_used_at, created_at: created_at || token.created_at, - revoked_at: revoked_at + revoked_at: revoked_at, + refresh_token: refresh_token ) token end diff --git a/test/middleware/mcp_body_limit_test.rb b/test/middleware/mcp_body_limit_test.rb index 8bf36c7..6c26684 100644 --- a/test/middleware/mcp_body_limit_test.rb +++ b/test/middleware/mcp_body_limit_test.rb @@ -5,34 +5,47 @@ class McpBodyLimitTest < ActiveSupport::TestCase # stream was preserved and handed downstream intact. DOWNSTREAM = ->(env) { [ 200, { "Content-Type" => "text/plain" }, [ env["rack.input"].read ] ] } - test "rejects an oversize body declared via Content-Length (fast path)" do - status, _headers, body = call("/mcp", "POST", body: "", content_length: McpBodyLimit::MAX_BYTES + 1) + test "rejects an oversize DCR body declared via Content-Length (fast path)" do + status, _headers, body = call("/oauth/register", "POST", body: "", content_length: McpBodyLimit::OAUTH_MAX_BYTES + 1) assert_equal 413, status assert_includes body.join, "payload_too_large" end - test "rejects an oversize actual body even with NO Content-Length (stream bound)" do - status, = call("/oauth/register", "POST", body: oversize, content_length: :none) + test "rejects an oversize actual OAuth body even with NO Content-Length (stream bound)" do + status, = call("/oauth/token", "POST", body: over(McpBodyLimit::OAUTH_MAX_BYTES), content_length: :none) assert_equal 413, status end - test "rejects an oversize actual body that lies about a small Content-Length" do - status, = call("/mcp", "POST", body: oversize, content_length: 10) + test "rejects an oversize OAuth body that lies about a small Content-Length" do + status, = call("/oauth/token", "POST", body: over(McpBodyLimit::OAUTH_MAX_BYTES), content_length: 10) assert_equal 413, status end + test "guards every OAuth POST endpoint, not just registration" do + %w[/oauth/token /oauth/revoke /oauth/introspect /oauth/authorize /oauth/register].each do |path| + assert_equal 413, call(path, "POST", body: over(McpBodyLimit::OAUTH_MAX_BYTES), content_length: :none).first, "#{path} should be guarded" + end + end + test "guards every slash variant Rails' router normalizes to a protected path" do - # Rails routes all of these to the same endpoints, so the guard must too. %w[/mcp/ //mcp /mcp// /oauth/register/ /oauth//register //oauth/register /oauth///register/].each do |path| - assert_equal 413, call(path, "POST", body: oversize, content_length: :none).first, "#{path} should be guarded" + status = call(path, "POST", body: over(McpBodyLimit::MCP_MAX_BYTES), content_length: :none).first + assert_equal 413, status, "#{path} should be guarded" end end - test "passes an at-limit body through and preserves it for downstream" do - payload = "a" * McpBodyLimit::MAX_BYTES + test "the /mcp endpoint allows a larger body than the OAuth endpoints (fits a JSON-escaped 2 MB paste)" do + body = "a" * (McpBodyLimit::OAUTH_MAX_BYTES + 1_000_000) # bigger than the OAuth cap, within the MCP cap + + assert_equal 413, call("/oauth/token", "POST", body: body, content_length: :none).first, "OAuth cap should reject it" + assert_equal 200, call("/mcp", "POST", body: body, content_length: :none).first, "MCP cap should allow it" + end + + test "passes an at-limit /mcp body through and preserves it for downstream" do + payload = "a" * McpBodyLimit::MCP_MAX_BYTES status, _headers, body = call("/mcp", "POST", body: payload) assert_equal 200, status @@ -40,8 +53,8 @@ class McpBodyLimitTest < ActiveSupport::TestCase end test "ignores non-POST methods and unguarded paths" do - assert_equal 200, call("/mcp", "GET", body: oversize, content_length: :none).first - assert_equal 200, call("/api/pastes", "POST", body: oversize, content_length: :none).first + assert_equal 200, call("/mcp", "GET", body: over(McpBodyLimit::MCP_MAX_BYTES), content_length: :none).first + assert_equal 200, call("/api/pastes", "POST", body: over(McpBodyLimit::MCP_MAX_BYTES), content_length: :none).first end test "is installed at the front of the application middleware stack" do @@ -52,8 +65,8 @@ class McpBodyLimitTest < ActiveSupport::TestCase end private - def oversize - "a" * (McpBodyLimit::MAX_BYTES + 128) + def over(limit) + "a" * (limit + 128) end def call(path, method, body: "", content_length: :from_body) diff --git a/test/models/mcp_tools/list_pastes_test.rb b/test/models/mcp_tools/list_pastes_test.rb index ece576b..0d5a6c0 100644 --- a/test/models/mcp_tools/list_pastes_test.rb +++ b/test/models/mcp_tools/list_pastes_test.rb @@ -98,6 +98,25 @@ class McpTools::ListPastesTest < ActiveSupport::TestCase assert summary[:content_bytes].positive? end + test "an absurdly large page is clamped instead of overflowing the SQL offset" do + # Without clamping this reaches Postgres as an out-of-range bigint OFFSET and + # raises PG::NumericValueOutOfRange, whose message the MCP gem would leak. + response = list(page: 10**18) + + assert_not response.error?, "a huge page must not raise; it should clamp and return empty" + assert_equal McpTools::ListPastes::MAX_PAGE, response.structured_content[:page] + assert_empty response.structured_content[:pastes] + end + + test "a non-positive page is normalized to the first page" do + create_paste_for(@alice, "

x

") + + response = list(page: -5) + + assert_not response.error? + assert_equal 1, response.structured_content[:page] + end + private def list(**args) McpTools::ListPastes.call(**args, server_context: @ctx) From fab164f0534cfedee95b32560ce635641f6d7c5c Mon Sep 17 00:00:00 2001 From: Ali Hamdi Ali Fadel Date: Fri, 10 Jul 2026 20:56:18 +0300 Subject: [PATCH 17/22] fix(mcp): guard all verbs, sanitize tool exceptions, align OAuth caps, constrain docs Round-7 review follow-ups: - McpBodyLimit keys the body cap on PATH, not method, so body-bearing verbs other than POST are covered too -- notably DELETE /oauth/authorize, which Doorkeeper routes and which previously bypassed the OAuth cap (finding 1). - BaseTool wraps every tool's `call` (prepended per-subclass via inherited) so an unexpected exception is reported to Rails.error and returned as one generic tool error, instead of the SDK embedding the raw exception message -- e.g. a driver's "string contains null byte" -- in JSON-RPC error data (finding 2). - Align the DCR redirect_uri cap (now 512) with the login-resume return-path cap (now 2000) so any redirect_uri accepted at registration yields an authorize path that fits the cookie session and can resume after sign-in (finding 3). - Document the MCP request cap honestly: a paste's content travels as a JSON string, and a client whose encoder emits six-byte \uXXXX escapes for < > & should keep such content below ~1.3 MB to stay within the 8 MB request cap (finding 4). Co-Authored-By: Claude Fable 5 --- app/controllers/concerns/authentication.rb | 14 +++-- .../oauth/registrations_controller.rb | 9 ++-- app/models/mcp_tools/base_tool.rb | 22 ++++++++ lib/middleware/mcp_body_limit.rb | 6 ++- public/llms.txt | 12 +++-- .../authentication_return_to_test.rb | 23 ++++++++ test/middleware/mcp_body_limit_test.rb | 12 ++++- test/models/mcp_tools/error_sanitizer_test.rb | 54 +++++++++++++++++++ 8 files changed, 136 insertions(+), 16 deletions(-) create mode 100644 test/models/mcp_tools/error_sanitizer_test.rb diff --git a/app/controllers/concerns/authentication.rb b/app/controllers/concerns/authentication.rb index bf4c406..59dd67c 100644 --- a/app/controllers/concerns/authentication.rb +++ b/app/controllers/concerns/authentication.rb @@ -10,11 +10,15 @@ module Authentication AUTH_COOKIE_NAME = Rails.env.production? ? "__Host-pastehtml_session_id" : "pastehtml_session_id" # Cap the post-login return path stored in the (cookie) session. The whole - # session must fit in ~4 KB; a very long path -- e.g. an OAuth authorize URL - # with a multi-kilobyte `state` -- would raise CookieOverflow and 500 the - # sign-in redirect. Above this we skip storing it (login still works; resume - # falls back to the default landing page) rather than crash. - MAX_RETURN_TO_BYTES = 1500 + # session must fit in ~4 KB, so this is sized to hold an OAuth authorize path + # built from a max-length accepted redirect_uri + # (Oauth::RegistrationsController::MAX_REDIRECT_URI_LENGTH) plus a normal state + # and the fixed params -- so any client accepted at registration can resume + # login -- while staying comfortably within the cookie budget. A path above + # this (e.g. a pathologically large `state`) is skipped rather than stored, so + # sign-in still works (resume falls back to the default landing page) instead + # of raising CookieOverflow and 500ing. + MAX_RETURN_TO_BYTES = 2000 included do before_action :require_authentication diff --git a/app/controllers/oauth/registrations_controller.rb b/app/controllers/oauth/registrations_controller.rb index 598b101..4c6db9c 100644 --- a/app/controllers/oauth/registrations_controller.rb +++ b/app/controllers/oauth/registrations_controller.rb @@ -29,9 +29,12 @@ def initialize(code, description) # per-authorization, so a later step-up never needs a second registration. NORMALIZED_SCOPE = ALLOWED_SCOPES.join(" ").freeze MAX_REDIRECT_URIS = 10 - # A generous cap on a single redirect_uri -- real loopback/https callbacks are - # well under this; anything longer is abuse, not a legitimate client. - MAX_REDIRECT_URI_LENGTH = 2000 + # Cap on a single redirect_uri. Real loopback/https callbacks are well under + # this. It is deliberately kept small enough that an authorization request + # built from an accepted redirect_uri (plus a normal state) fits within + # Authentication::MAX_RETURN_TO_BYTES, so a client accepted here can always + # resume login when the user starts signed out -- see that constant. + MAX_REDIRECT_URI_LENGTH = 512 # Valid TCP port range for an explicit :port in a redirect_uri. VALID_PORT_RANGE = (1..65_535) MAX_CLIENT_NAME_LENGTH = 255 diff --git a/app/models/mcp_tools/base_tool.rb b/app/models/mcp_tools/base_tool.rb index 0cbe9cd..bb0ea54 100644 --- a/app/models/mcp_tools/base_tool.rb +++ b/app/models/mcp_tools/base_tool.rb @@ -37,6 +37,28 @@ class BaseTool < MCP::Tool SYNTHETIC_FILENAME = { "html" => "paste.html", "markdown" => "paste.md" }.freeze EXTENSION_FOR_FORMAT = { "html" => Paste::HTML_EXTENSION, "markdown" => Paste::MARKDOWN_EXTENSION }.freeze + # Wraps every tool's `call` so an UNEXPECTED exception never leaks its message + # to the client. The SDK embeds a raised exception's message in JSON-RPC error + # data, which can expose database/implementation details (e.g. a driver's + # "string contains null byte" or a Postgres error). Domain failures already + # return structured `failure`/`validation_error` responses and never raise, so + # only genuine surprises reach here: they are reported to Rails.error and + # returned as one generic, non-revealing tool error. Prepended to each + # subclass's singleton (see `inherited`), so `super` reaches the tool's `call`. + module ErrorSanitizer + def call(*args, **kwargs) + super + rescue StandardError => e + Rails.error.report(e, handled: true, source: "mcp-tool") + failure(code: "internal_error", message: "An unexpected error occurred while running this tool.") + end + end + + def self.inherited(subclass) + super + subclass.singleton_class.prepend(ErrorSanitizer) + end + class << self private diff --git a/lib/middleware/mcp_body_limit.rb b/lib/middleware/mcp_body_limit.rb index 83b0396..8956f14 100644 --- a/lib/middleware/mcp_body_limit.rb +++ b/lib/middleware/mcp_body_limit.rb @@ -55,9 +55,11 @@ def call(env) private # The byte ceiling for this request, or nil if the endpoint is not guarded. + # Keyed on PATH only, not method: Doorkeeper serves several verbs on the same + # OAuth paths (e.g. DELETE /oauth/authorize), and any body-bearing verb -- not + # just POST -- can carry an oversized payload. Body-less verbs (GET/HEAD) just + # see an empty stream, so guarding them costs nothing. def limit_for(env) - return nil unless env["REQUEST_METHOD"] == "POST" - path = normalized_path(env) return MCP_MAX_BYTES if path == MCP_PATH return OAUTH_MAX_BYTES if path.start_with?(OAUTH_PREFIX) diff --git a/public/llms.txt b/public/llms.txt index 72000e3..bbdc512 100644 --- a/public/llms.txt +++ b/public/llms.txt @@ -129,10 +129,14 @@ permanent, so there is no delete-paste tool: rename_folder rename a folder delete_folder delete a folder (its pastes survive, unfiled) -Everything else in this guide -- self-contained documents, the 2 MB limit, -always showing the user the live_url -- applies equally when publishing over MCP. -(A whole MCP request is capped at 8 MB, which comfortably fits a 2 MB paste even -after JSON-string escaping roughly doubles quote/backslash-heavy content.) +Everything else in this guide -- self-contained documents, the 2 MB paste +limit, always showing the user the live_url -- applies equally when publishing +over MCP, with one encoding caveat. A whole MCP request is capped at 8 MB, and a +paste's content travels as a JSON string, which escaping expands: quotes and +backslashes double, and some JSON encoders emit six-byte \uXXXX escapes for the +characters < > &. Typical MCP clients leave < > & raw, so they publish up to the +full 2 MB comfortably. A client whose encoder escapes < > & should keep a paste +that is heavy in those characters below ~1.3 MB to stay within the request cap. ## Publish Markdown diff --git a/test/integration/authentication_return_to_test.rb b/test/integration/authentication_return_to_test.rb index 7ef96f3..5fbaf44 100644 --- a/test/integration/authentication_return_to_test.rb +++ b/test/integration/authentication_return_to_test.rb @@ -29,4 +29,27 @@ class AuthenticationReturnToTest < ActionDispatch::IntegrationTest assert_nil session[:return_to_after_authenticating] end + + # The return-to cap and the DCR redirect_uri cap are aligned: an authorize path + # built from the LONGEST redirect_uri registration accepts, plus a normal + # state, still fits and resumes -- so no client accepted at registration is + # left unable to authenticate. + test "a max-length accepted redirect_uri still yields a resumable authorize path" do + max_uri_length = Oauth::RegistrationsController::MAX_REDIRECT_URI_LENGTH + prefix = "https://client.example.com/" + redirect_uri = prefix + ("a" * (max_uri_length - prefix.length)) + assert_equal max_uri_length, redirect_uri.length, "sanity: exactly the max accepted redirect_uri" + + get "/oauth/authorize", params: { + client_id: "c" * 43, redirect_uri: redirect_uri, response_type: "code", + scope: "mcp:read mcp:write", state: "s" * 128, + code_challenge: "d" * 43, code_challenge_method: "S256", + resource: McpOauth::CONFIG[:resource_uri] + } + + assert_response :see_other + stored = session[:return_to_after_authenticating] + assert_not_nil stored, "a max-redirect-uri authorize path must fit and resume" + assert_operator stored.bytesize, :<=, Authentication::MAX_RETURN_TO_BYTES + end end diff --git a/test/middleware/mcp_body_limit_test.rb b/test/middleware/mcp_body_limit_test.rb index 6c26684..14c409e 100644 --- a/test/middleware/mcp_body_limit_test.rb +++ b/test/middleware/mcp_body_limit_test.rb @@ -52,9 +52,17 @@ class McpBodyLimitTest < ActiveSupport::TestCase assert_equal payload.bytesize, body.join.bytesize, "downstream must receive the full body" end - test "ignores non-POST methods and unguarded paths" do - assert_equal 200, call("/mcp", "GET", body: over(McpBodyLimit::MCP_MAX_BYTES), content_length: :none).first + test "guards a guarded path on any verb, including a body-bearing DELETE" do + # DELETE /oauth/authorize is a real Doorkeeper route; a POST-only guard let an + # oversized DELETE body through. + assert_equal 413, call("/oauth/authorize", "DELETE", body: over(McpBodyLimit::OAUTH_MAX_BYTES), content_length: :none).first + # An abnormal oversized GET body on a guarded path is bounded too. + assert_equal 413, call("/mcp", "GET", body: over(McpBodyLimit::MCP_MAX_BYTES), content_length: :none).first + end + + test "ignores unguarded paths and lets an empty-body request through" do assert_equal 200, call("/api/pastes", "POST", body: over(McpBodyLimit::MCP_MAX_BYTES), content_length: :none).first + assert_equal 200, call("/mcp", "GET", body: "").first end test "is installed at the front of the application middleware stack" do diff --git a/test/models/mcp_tools/error_sanitizer_test.rb b/test/models/mcp_tools/error_sanitizer_test.rb new file mode 100644 index 0000000..52fb3a3 --- /dev/null +++ b/test/models/mcp_tools/error_sanitizer_test.rb @@ -0,0 +1,54 @@ +require "test_helper" + +class McpTools::ErrorSanitizerTest < ActiveSupport::TestCase + # A tool that raises an exception whose message contains sensitive detail, to + # prove the wrapper never forwards it to the client. + class BoomTool < McpTools::BaseTool + tool_name "boom_for_tests" + description "Raises, for exception-sanitization tests." + input_schema(type: "object", properties: {}, additionalProperties: false) + + def self.call(server_context:) + raise "PG::InternalError: string contains null byte in /secret/path" + end + end + + test "an unexpected tool exception returns a generic error, not the raw message" do + reported = [] + subscriber = Class.new do + define_method(:report) do |error, handled:, severity:, source: nil, context: {}| + reported << { error: error, source: source } + end + end.new + Rails.error.subscribe(subscriber) + + response = BoomTool.call(server_context: { user: users(:alice) }) + + assert response.error? + assert_equal "internal_error", response.structured_content[:code] + leaked = "#{response.structured_content.to_json} #{response.content.to_json}" + assert_not_includes leaked, "null byte" + assert_not_includes leaked, "PG::" + assert_not_includes leaked, "/secret/path" + + # The real error is still captured for operators, just not exposed. + assert(reported.any? { |r| r[:source] == "mcp-tool" && r[:error].message.include?("null byte") }) + ensure + Rails.error.unsubscribe(subscriber) if subscriber + end + + test "a real driver-level error (null byte in content) is sanitized, not leaked" do + # A null byte in text is rejected by the pg driver / Postgres, raising an + # exception whose message the SDK would otherwise embed in the response. + content = "before" + 0.chr + "after" + + response = McpTools::CreatePaste.call( + content: content, format: "html", server_context: { user: users(:alice) } + ) + + leaked = response.structured_content.to_json + assert_not_includes leaked, "null byte" + assert_not_includes leaked, "PG::" + assert_equal "internal_error", response.structured_content[:code] if response.error? + end +end From 641ac642615e03c2edea0ad62470b081a7f46c6f Mon Sep 17 00:00:00 2001 From: Ali Hamdi Ali Fadel Date: Fri, 10 Jul 2026 21:27:56 +0300 Subject: [PATCH 18/22] fix(mcp): sanitize internal errors at the JSON-RPC boundary; document MCP in full reference Round-8 review follow-ups: - The per-tool exception wrapper doesn't cover exceptions raised during SDK request validation/dispatch (before a request reaches a tool) -- e.g. a tools/call with JSON-RPC array `params` raises a TypeError the SDK embeds as the `data` of a -32603 error ("no implicit conversion of Symbol into Integer"). McpController now strips `data` from any -32603 internal-error response at the boundary (single or batch), fixing Content-Length, while leaving results and other protocol errors (e.g. -32602) untouched (finding 1). - Add the MCP/OAuth section to public/llms-full.txt, which billed itself as the complete reference but documented only the HTTP API (finding 2). - Comment cleanup: McpBodyLimit guards all verbs (not just POST), and the MAX_REQUEST_BYTES note carries the JSON-escaping caveat. Co-Authored-By: Claude Fable 5 --- app/controllers/mcp_controller.rb | 57 ++++++++++++++++++++++++- lib/middleware/mcp_body_limit.rb | 6 ++- public/llms-full.txt | 37 ++++++++++++++++ test/controllers/mcp_controller_test.rb | 16 +++++++ 4 files changed, 112 insertions(+), 4 deletions(-) diff --git a/app/controllers/mcp_controller.rb b/app/controllers/mcp_controller.rb index 4bf2197..58a33aa 100644 --- a/app/controllers/mcp_controller.rb +++ b/app/controllers/mcp_controller.rb @@ -32,7 +32,9 @@ class McpController < ActionController::API # The request ceiling for /mcp, shared with the front-of-stack McpBodyLimit so # the transport, the pre-dispatch peek, and the middleware all agree. Sized to - # fit a 2 MB paste after JSON-string escaping (see McpBodyLimit::MCP_MAX_BYTES). + # fit a full 2 MB paste for typical clients; a client whose JSON encoder emits + # six-byte \uXXXX escapes for < > & may not fit content dominated by those + # characters (see the encoding caveat in public/llms.txt and McpBodyLimit). MAX_REQUEST_BYTES = McpBodyLimit::MCP_MAX_BYTES # The peek MUST use the transport's own nesting bound, not a lower one. If the @@ -100,7 +102,16 @@ def handle status, headers, body = transport.handle_request(request) headers.each { |key, value| response.headers[key] = value } - if body.nil? || (body.respond_to?(:empty?) && body.empty?) + sanitized = sanitize_internal_error_body(body, response.headers["Content-Type"]) + + if sanitized + # A JSON-RPC internal error whose `data` carried a raw exception message + # (from SDK-level validation/dispatch, outside the per-tool wrapper) -- + # replaced with a leak-free copy. Content-Length must track the new body. + response.headers["Content-Length"] = sanitized.bytesize.to_s + self.status = status + self.response_body = [ sanitized ] + elsif body.nil? || (body.respond_to?(:empty?) && body.empty?) # An accepted notification is 202 with a truly empty body -- never a # literal JSON "null" or "{}". `head` renders no body. head status @@ -113,6 +124,48 @@ def handle end private + # --- JSON-RPC boundary error sanitizing ---------------------------------- + + # The SDK wraps any exception raised during request validation/dispatch -- + # BEFORE a request reaches a tool, so outside BaseTool's per-tool wrapper -- + # into a -32603 "Internal error" whose `data` holds the raw Ruby message + # (e.g. array `params` -> "no implicit conversion of Symbol into Integer"). + # Strip that `data` at the boundary so implementation details never ship. + # Returns the rewritten JSON string when it changed anything, else nil (the + # untouched body is passed through). Only JSON responses are inspected; + # SSE/empty bodies are left alone. + def sanitize_internal_error_body(body, content_type) + return nil unless content_type.to_s.include?("application/json") + return nil unless body.respond_to?(:each) + + raw = +"" + body.each { |part| raw << part.to_s } + return nil if raw.empty? + + parsed = JSON.parse(raw) + changed = redact_internal_error!(parsed) + changed ? JSON.generate(parsed) : nil + rescue JSON::ParserError + nil + end + + # Drops `data` from a -32603 error object, in a single response or a batch + # array. Returns whether anything was redacted. + def redact_internal_error!(parsed) + case parsed + when Array + parsed.map { |element| redact_internal_error!(element) }.any? + when Hash + error = parsed["error"] + return false unless error.is_a?(Hash) && error["code"] == -32_603 && error.key?("data") + + error.delete("data") + true + else + false + end + end + # --- SDK exception reporting -------------------------------------------- # The SDK turns tool/transport exceptions into JSON-RPC error responses and, diff --git a/lib/middleware/mcp_body_limit.rb b/lib/middleware/mcp_body_limit.rb index 8956f14..9801c20 100644 --- a/lib/middleware/mcp_body_limit.rb +++ b/lib/middleware/mcp_body_limit.rb @@ -1,5 +1,7 @@ -# Rejects oversized POST bodies to the public MCP and OAuth endpoints before any -# downstream middleware, Rails param parsing, or the MCP transport reads them. +# Rejects oversized request bodies to the public MCP and OAuth endpoints before +# any downstream middleware, Rails param parsing, or the MCP transport reads +# them. The cap is keyed on the request path, so it applies to every verb those +# endpoints serve (POST, and also e.g. DELETE /oauth/authorize), not just POST. # # These endpoints otherwise read the whole body first and cap afterwards: the # OAuth endpoints (/oauth/token, /oauth/authorize, /oauth/revoke, /oauth/ diff --git a/public/llms-full.txt b/public/llms-full.txt index 8c28101..900d8dc 100644 --- a/public/llms-full.txt +++ b/public/llms-full.txt @@ -123,6 +123,43 @@ diagrams). The stored paste is HTML, so the response shape is identical to an HTML publish. Raw HTML embedded in the Markdown is dropped — upload plain HTML for arbitrary markup. +## MCP server (OAuth, no API key) + +A coding agent that speaks the Model Context Protocol can skip the HTTP API +above and connect to the remote MCP server at `https://pastehtml.dev/mcp` +(Streamable HTTP). It authorizes over OAuth in the user's browser — there is no +`pht_` key to handle. + + claude mcp add --transport http pastehtml https://pastehtml.dev/mcp # Claude Code + codex mcp add pastehtml --url https://pastehtml.dev/mcp # Codex + +The first connection opens a browser consent screen; once approved, the agent +acts inside that account. Auth is OAuth 2.1 + PKCE with Dynamic Client +Registration (no client secret); scopes are `mcp:read` and `mcp:write`; the user +inspects and revokes agents under "Connected agents". Ten tools are exposed — +pastes are permanent, so there is deliberately no delete-paste tool: + +- `create_paste` — publish a new HTML or Markdown paste (optionally into a folder) +- `update_paste` — republish an existing paste's content (overwrites it) +- `configure_paste` — change a paste's password, custom subdomain, or folder +- `get_paste` — fetch one paste's metadata, URLs, and stored content +- `get_paste_stats` — aggregate view analytics for a paste +- `list_pastes` — page through the account's pastes (optionally by folder) +- `list_folders` — list folders with their paste counts +- `create_folder` — create a new, empty folder +- `rename_folder` — rename a folder +- `delete_folder` — delete a folder (its pastes survive, unfiled) + +Everything in this reference — the 2 MB content limit, self-contained +documents, always surfacing the `live_url` — applies equally over MCP, with one +encoding caveat: a whole MCP request is capped at 8 MB, and paste content travels +as a JSON string. Escaping expands it (quotes/backslashes double; some encoders +emit six-byte `\uXXXX` escapes for `<`, `>`, `&`). Typical clients leave `<`, `>`, +`&` raw and publish the full 2 MB comfortably; a client whose encoder escapes them +should keep such character-heavy content below ~1.3 MB to stay under the cap. +Operators can disable open Dynamic Client Registration with the +`MCP_DYNAMIC_REGISTRATION_DISABLED` environment variable. + ## Limits & rules - File: `.html`, `.htm`, `.md`, `.markdown`; UTF-8; at most 2 MB. diff --git a/test/controllers/mcp_controller_test.rb b/test/controllers/mcp_controller_test.rb index 482810a..d0c9466 100644 --- a/test/controllers/mcp_controller_test.rb +++ b/test/controllers/mcp_controller_test.rb @@ -255,6 +255,22 @@ class McpControllerTest < ActionDispatch::IntegrationTest assert_not_equal 500, response.status end + test "a tools/call with array params returns an error with no leaked exception data" do + # JSON-RPC permits array params. The SDK's dispatch (outside the per-tool + # wrapper) raises a TypeError indexing the array by a symbol and embeds the + # raw message in the error `data`; the boundary sanitizer strips it. + body = %({"jsonrpc":"2.0","id":7,"method":"tools/call","params":[1,2,3]}) + + mcp_post(body, token: read_write_token.plaintext_token) + + assert_not_equal 500, response.status + error = response.parsed_body["error"] + assert_equal(-32_603, error["code"]) + assert_not error.key?("data"), "raw exception data must be stripped at the JSON-RPC boundary" + assert_not_includes response.body, "no implicit conversion" + assert_not_includes response.body, "Symbol into Integer" + end + # --- Advertised capabilities match what the server implements ------------- test "initialize advertises only tools, not prompts/resources/logging" do From 188a601661eff363c185a73f54e1bcca9fcfe49c Mon Sep 17 00:00:00 2001 From: Ali Hamdi Ali Fadel Date: Fri, 10 Jul 2026 21:35:11 +0300 Subject: [PATCH 19/22] fix(mcp): answer malformed non-object params with -32602, not -32603 JSON-RPC/MCP method params must be a structured object. A tools/call or initialize with array or scalar params previously reached the SDK, which indexed the non-object by a symbol and surfaced a -32603 "Internal error" (its raw data stripped by the boundary sanitizer, but still the wrong code). A pre-dispatch check now answers the semantically correct -32602 "Invalid params" for any non-object params on a request, before dispatch. The boundary sanitizer stays as defense-in-depth for any other -32603 and is now covered by a direct unit test. Co-Authored-By: Claude Fable 5 --- app/controllers/mcp_controller.rb | 19 ++++++++++ test/controllers/mcp_controller_test.rb | 49 +++++++++++++++++++------ 2 files changed, 57 insertions(+), 11 deletions(-) diff --git a/app/controllers/mcp_controller.rb b/app/controllers/mcp_controller.rb index 58a33aa..f2b61e7 100644 --- a/app/controllers/mcp_controller.rb +++ b/app/controllers/mcp_controller.rb @@ -65,6 +65,7 @@ class McpController < ActionController::API before_action :enforce_origin! before_action :authenticate_token! + before_action :reject_non_object_params! before_action :enforce_tool_scope! before_action :enforce_write_rate_limit! @@ -254,6 +255,24 @@ def www_authenticate(error: nil) # --- Step 3: scope enforcement + write rate limits ---------------------- + # JSON-RPC/MCP method `params` must be a structured object. When a client + # sends an array or scalar (the JSON-RPC spec allows array params in general, + # but MCP methods take objects), the SDK indexes the non-object by a symbol + # and surfaces a -32603 "Internal error". Answer the semantically correct + # -32602 "Invalid params" ourselves instead, before dispatch. Only requests + # (those carrying an `id`) get a response; a malformed notification stays a + # no-response (the transport acks it 202). + def reject_non_object_params! + body = mcp_request_body + return if body.nil? || !body.key?(:id) + return unless body.key?(:params) + + params = body[:params] + return if params.nil? || params.is_a?(Hash) + + render json: { jsonrpc: "2.0", id: body[:id], error: { code: -32_602, message: "Invalid params" } } + end + def enforce_tool_scope! body = mcp_request_body return if body.nil? diff --git a/test/controllers/mcp_controller_test.rb b/test/controllers/mcp_controller_test.rb index d0c9466..fb2da0e 100644 --- a/test/controllers/mcp_controller_test.rb +++ b/test/controllers/mcp_controller_test.rb @@ -244,31 +244,58 @@ class McpControllerTest < ActionDispatch::IntegrationTest assert_response :too_many_requests end - test "a tools/call whose params is not an object does not 500" do + test "a tools/call whose params is a scalar returns -32602 Invalid params" do body = %({"jsonrpc":"2.0","id":2,"method":"tools/call","params":"not-an-object"}) mcp_post(body, token: read_write_token.plaintext_token) - # Previously Hash#dig on the String params raised and 500ed; now the peek - # yields no tool name, the gate steps aside, and the transport handles the - # malformed call as a JSON-RPC error. assert_not_equal 500, response.status + error = response.parsed_body["error"] + assert_equal(-32_602, error["code"]) + assert_equal "Invalid params", error["message"] end - test "a tools/call with array params returns an error with no leaked exception data" do - # JSON-RPC permits array params. The SDK's dispatch (outside the per-tool - # wrapper) raises a TypeError indexing the array by a symbol and embeds the - # raw message in the error `data`; the boundary sanitizer strips it. + test "a tools/call with array params returns -32602, not a leaked internal error" do + # JSON-RPC permits array params, but MCP methods take objects; the SDK would + # otherwise index the array by a symbol and surface a -32603 whose data leaks + # the raw Ruby message. We answer the semantically correct -32602 first. body = %({"jsonrpc":"2.0","id":7,"method":"tools/call","params":[1,2,3]}) mcp_post(body, token: read_write_token.plaintext_token) assert_not_equal 500, response.status error = response.parsed_body["error"] - assert_equal(-32_603, error["code"]) - assert_not error.key?("data"), "raw exception data must be stripped at the JSON-RPC boundary" + assert_equal(-32_602, error["code"]) + assert_equal 7, response.parsed_body["id"] + assert_not error.key?("data"), "no exception detail must be exposed" assert_not_includes response.body, "no implicit conversion" - assert_not_includes response.body, "Symbol into Integer" + end + + test "initialize with array params also returns -32602" do + body = %({"jsonrpc":"2.0","id":9,"method":"initialize","params":[1,2]}) + + mcp_post(body, token: read_write_token.plaintext_token) + + assert_equal(-32_602, response.parsed_body.dig("error", "code")) + end + + # The pre-dispatch check now answers malformed params as -32602, but the + # boundary sanitizer remains the net for any OTHER -32603 whose data could leak + # (e.g. a genuine dispatch-time bug). Exercise it directly so it stays covered. + test "the boundary sanitizer strips data from a -32603 error only" do + controller = McpController.new + + internal = { "jsonrpc" => "2.0", "id" => 1, "error" => { "code" => -32_603, "message" => "Internal error", "data" => "secret detail" } } + assert controller.send(:redact_internal_error!, internal), "should report a change" + assert_not internal["error"].key?("data"), "-32603 data must be stripped" + + other = { "error" => { "code" => -32_602, "message" => "Invalid params", "data" => "Tool not found: x" } } + assert_not controller.send(:redact_internal_error!, other), "other codes untouched" + assert_equal "Tool not found: x", other["error"]["data"] + + batch = [ internal.dup.tap { |h| h["error"] = { "code" => -32_603, "data" => "leak" } }, { "result" => {} } ] + assert controller.send(:redact_internal_error!, batch) + assert_not batch.first["error"].key?("data") end # --- Advertised capabilities match what the server implements ------------- From 6077c1a5c72c36b88cd1ba5282a1593fec5ee63a Mon Sep 17 00:00:00 2001 From: Ali Hamdi Ali Fadel Date: Fri, 10 Jul 2026 21:43:38 +0300 Subject: [PATCH 20/22] fix(mcp): require object params for initialize and tools/call (reject null/omitted) The MCP schema requires an object `params` for initialize (protocolVersion/ capabilities/clientInfo) and tools/call (name/arguments). The pre-dispatch check previously allowed a missing or explicit-null params through, so initialize would silently proceed without required client info and tools/call would surface a sanitized -32603. For these methods a missing/null/non-object params now returns -32602 Invalid params; params stays optional (only a present non-object is rejected) for methods like tools/list that permit it. Co-Authored-By: Claude Fable 5 --- app/controllers/mcp_controller.rb | 28 +++++++++++++++++-------- test/controllers/mcp_controller_test.rb | 22 +++++++++++++++++++ 2 files changed, 41 insertions(+), 9 deletions(-) diff --git a/app/controllers/mcp_controller.rb b/app/controllers/mcp_controller.rb index f2b61e7..0190a7f 100644 --- a/app/controllers/mcp_controller.rb +++ b/app/controllers/mcp_controller.rb @@ -255,20 +255,30 @@ def www_authenticate(error: nil) # --- Step 3: scope enforcement + write rate limits ---------------------- - # JSON-RPC/MCP method `params` must be a structured object. When a client - # sends an array or scalar (the JSON-RPC spec allows array params in general, - # but MCP methods take objects), the SDK indexes the non-object by a symbol - # and surfaces a -32603 "Internal error". Answer the semantically correct - # -32602 "Invalid params" ourselves instead, before dispatch. Only requests - # (those carrying an `id`) get a response; a malformed notification stays a - # no-response (the transport acks it 202). + # Methods whose MCP schema REQUIRES an object `params` (initialize needs + # protocolVersion/capabilities/clientInfo; tools/call needs name/arguments). + # For these, a missing or null params is as invalid as a non-object one. + PARAMS_REQUIRED_METHODS = %w[initialize tools/call].freeze + + # JSON-RPC/MCP method `params`, when present, must be a structured object + # (the JSON-RPC spec allows array params in general, but MCP methods take + # objects). If it is an array or scalar the SDK would index the non-object by + # a symbol and surface a -32603 "Internal error"; and for the methods that + # require params, a missing/null params would either 500-then-sanitize + # (tools/call) or silently proceed without required client info (initialize). + # Answer the semantically correct -32602 "Invalid params" ourselves, before + # dispatch. Only requests (those carrying an `id`) get a response; a malformed + # notification stays a no-response (the transport acks it 202). def reject_non_object_params! body = mcp_request_body return if body.nil? || !body.key?(:id) - return unless body.key?(:params) params = body[:params] - return if params.nil? || params.is_a?(Hash) + if PARAMS_REQUIRED_METHODS.include?(body[:method]) + return if params.is_a?(Hash) # required: missing/null/non-object all invalid + else + return if params.nil? || params.is_a?(Hash) # optional: only a present non-object is invalid + end render json: { jsonrpc: "2.0", id: body[:id], error: { code: -32_602, message: "Invalid params" } } end diff --git a/test/controllers/mcp_controller_test.rb b/test/controllers/mcp_controller_test.rb index fb2da0e..047356b 100644 --- a/test/controllers/mcp_controller_test.rb +++ b/test/controllers/mcp_controller_test.rb @@ -279,6 +279,28 @@ class McpControllerTest < ActionDispatch::IntegrationTest assert_equal(-32_602, response.parsed_body.dig("error", "code")) end + test "methods that require object params reject null or omitted params with -32602" do + # initialize needs protocolVersion/capabilities/clientInfo; tools/call needs + # name/arguments. Per the MCP schema a missing/null params is invalid. + [ + %({"jsonrpc":"2.0","id":9,"method":"initialize","params":null}), + %({"jsonrpc":"2.0","id":9,"method":"initialize"}), + %({"jsonrpc":"2.0","id":3,"method":"tools/call","params":null}), + %({"jsonrpc":"2.0","id":3,"method":"tools/call"}) + ].each do |body| + mcp_post(body, token: read_write_token.plaintext_token) + assert_equal(-32_602, response.parsed_body.dig("error", "code"), "expected -32602 for #{body}") + end + end + + test "methods with optional params still succeed when params is omitted" do + # tools/list takes an optional cursor; omitting params must not be rejected. + mcp_post(%({"jsonrpc":"2.0","id":4,"method":"tools/list"}), token: read_write_token.plaintext_token) + + assert_response :ok + assert response.parsed_body.dig("result", "tools").present?, "tools/list should still work with no params" + end + # The pre-dispatch check now answers malformed params as -32602, but the # boundary sanitizer remains the net for any OTHER -32603 whose data could leak # (e.g. a genuine dispatch-time bug). Exercise it directly so it stays covered. From 8bfd9690f6c1fecb2f7e64db4c0c73493aa5b805 Mon Sep 17 00:00:00 2001 From: Ali Hamdi Ali Fadel Date: Fri, 10 Jul 2026 21:48:54 +0300 Subject: [PATCH 21/22] fix(mcp): validate initialize's required lifecycle fields initialize required an object params but not its contents, so {} or an object missing clientInfo still completed the handshake. The MCP lifecycle requires protocolVersion (string), capabilities (object), and clientInfo (object); a request missing any of them now returns -32602 Invalid params. Presence and container type are checked, not inner fields, so a conforming client that shapes those objects differently is never over-rejected. Co-Authored-By: Claude Fable 5 --- app/controllers/mcp_controller.rb | 30 +++++++++++++++++++------ test/controllers/mcp_controller_test.rb | 27 ++++++++++++++++++++++ 2 files changed, 50 insertions(+), 7 deletions(-) diff --git a/app/controllers/mcp_controller.rb b/app/controllers/mcp_controller.rb index 0190a7f..84673b9 100644 --- a/app/controllers/mcp_controller.rb +++ b/app/controllers/mcp_controller.rb @@ -272,17 +272,33 @@ def www_authenticate(error: nil) def reject_non_object_params! body = mcp_request_body return if body.nil? || !body.key?(:id) - - params = body[:params] - if PARAMS_REQUIRED_METHODS.include?(body[:method]) - return if params.is_a?(Hash) # required: missing/null/non-object all invalid - else - return if params.nil? || params.is_a?(Hash) # optional: only a present non-object is invalid - end + return if params_acceptable?(body[:method], body[:params]) render json: { jsonrpc: "2.0", id: body[:id], error: { code: -32_602, message: "Invalid params" } } end + # Whether a request's params satisfy the method's schema shape. Methods in + # PARAMS_REQUIRED_METHODS must carry an object; initialize must additionally + # carry its three lifecycle fields. Every other method takes an optional + # object (a present non-object is still invalid). + def params_acceptable?(method, params) + return params.nil? || params.is_a?(Hash) unless PARAMS_REQUIRED_METHODS.include?(method) + return false unless params.is_a?(Hash) + return initialize_params_complete?(params) if method == "initialize" + + true + end + + # The MCP lifecycle requires an initialize with protocolVersion (string) plus + # capabilities and clientInfo objects. Presence and container type are + # checked; inner fields are left to the SDK, so this can't over-reject a + # conforming client that shapes those objects differently. + def initialize_params_complete?(params) + params[:protocolVersion].is_a?(String) && + params[:capabilities].is_a?(Hash) && + params[:clientInfo].is_a?(Hash) + end + def enforce_tool_scope! body = mcp_request_body return if body.nil? diff --git a/test/controllers/mcp_controller_test.rb b/test/controllers/mcp_controller_test.rb index 047356b..b0455ab 100644 --- a/test/controllers/mcp_controller_test.rb +++ b/test/controllers/mcp_controller_test.rb @@ -301,6 +301,33 @@ class McpControllerTest < ActionDispatch::IntegrationTest assert response.parsed_body.dig("result", "tools").present?, "tools/list should still work with no params" end + test "initialize rejects an object missing its required lifecycle fields" do + complete = { protocolVersion: "2025-11-25", capabilities: {}, clientInfo: { name: "x", version: "1" } } + + [ + {}, # missing all three + complete.except(:clientInfo), # missing clientInfo + complete.except(:protocolVersion), # missing protocolVersion + complete.except(:capabilities), # missing capabilities + complete.merge(protocolVersion: 1), # wrong type + complete.merge(clientInfo: "acme") # wrong type + ].each do |params| + body = { jsonrpc: "2.0", id: 9, method: "initialize", params: params }.to_json + mcp_post(body, token: read_write_token.plaintext_token) + assert_equal(-32_602, response.parsed_body.dig("error", "code"), "expected -32602 for initialize params #{params.inspect}") + end + end + + test "initialize with all required lifecycle fields succeeds" do + body = { jsonrpc: "2.0", id: 9, method: "initialize", + params: { protocolVersion: "2025-11-25", capabilities: {}, clientInfo: { name: "x", version: "1" } } }.to_json + + mcp_post(body, token: read_write_token.plaintext_token) + + assert_response :ok + assert response.parsed_body["result"].present?, "a complete initialize must still handshake" + end + # The pre-dispatch check now answers malformed params as -32602, but the # boundary sanitizer remains the net for any OTHER -32603 whose data could leak # (e.g. a genuine dispatch-time bug). Exercise it directly so it stays covered. From 51377f472fed9712353c7c4f45f0ceb964ec079c Mon Sep 17 00:00:00 2001 From: Ali Hamdi Ali Fadel Date: Fri, 10 Jul 2026 21:54:13 +0300 Subject: [PATCH 22/22] fix(mcp): validate initialize clientInfo name and version fields The MCP Implementation schema requires clientInfo to carry string `name` and `version`; they were previously unchecked, so a clientInfo of {} still handshook. Both are now validated (optional fields like clientInfo.title are left alone), so a request missing either returns -32602 Invalid params. Co-Authored-By: Claude Fable 5 --- app/controllers/mcp_controller.rb | 16 +++++++++++----- test/controllers/mcp_controller_test.rb | 5 ++++- 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/app/controllers/mcp_controller.rb b/app/controllers/mcp_controller.rb index 84673b9..f2c8825 100644 --- a/app/controllers/mcp_controller.rb +++ b/app/controllers/mcp_controller.rb @@ -289,14 +289,20 @@ def params_acceptable?(method, params) true end - # The MCP lifecycle requires an initialize with protocolVersion (string) plus - # capabilities and clientInfo objects. Presence and container type are - # checked; inner fields are left to the SDK, so this can't over-reject a - # conforming client that shapes those objects differently. + # The MCP lifecycle requires an initialize with protocolVersion (string), + # a capabilities object, and a clientInfo Implementation carrying string + # `name` and `version` fields. Optional fields (e.g. clientInfo.title) are + # left alone, so a conforming client is never over-rejected. def initialize_params_complete?(params) params[:protocolVersion].is_a?(String) && params[:capabilities].is_a?(Hash) && - params[:clientInfo].is_a?(Hash) + valid_client_info?(params[:clientInfo]) + end + + def valid_client_info?(client_info) + client_info.is_a?(Hash) && + client_info[:name].is_a?(String) && + client_info[:version].is_a?(String) end def enforce_tool_scope! diff --git a/test/controllers/mcp_controller_test.rb b/test/controllers/mcp_controller_test.rb index b0455ab..c6e26ef 100644 --- a/test/controllers/mcp_controller_test.rb +++ b/test/controllers/mcp_controller_test.rb @@ -310,7 +310,10 @@ class McpControllerTest < ActionDispatch::IntegrationTest complete.except(:protocolVersion), # missing protocolVersion complete.except(:capabilities), # missing capabilities complete.merge(protocolVersion: 1), # wrong type - complete.merge(clientInfo: "acme") # wrong type + complete.merge(clientInfo: "acme"), # wrong type + complete.merge(clientInfo: {}), # clientInfo missing name/version + complete.merge(clientInfo: { name: "x" }), # clientInfo missing version + complete.merge(clientInfo: { name: "x", version: 2 }) # version wrong type ].each do |params| body = { jsonrpc: "2.0", id: 9, method: "initialize", params: params }.to_json mcp_post(body, token: read_write_token.plaintext_token)