diff --git a/Gemfile b/Gemfile index b944d5c..9a2e4d3 100644 --- a/Gemfile +++ b/Gemfile @@ -74,6 +74,16 @@ 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. 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 # the ranges over the network, which dev and test have no use for. diff --git a/Gemfile.lock b/Gemfile.lock index 5d9f14e..1b08e1e 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, < 6.0) jsbundling-rails kamal + mcp (~> 0.23.0) 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/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/controllers/concerns/authentication.rb b/app/controllers/concerns/authentication.rb index 53eb496..59dd67c 100644 --- a/app/controllers/concerns/authentication.rb +++ b/app/controllers/concerns/authentication.rb @@ -9,6 +9,17 @@ 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, 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 helper_method :authenticated?, :current_user @@ -42,7 +53,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/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/mcp_controller.rb b/app/controllers/mcp_controller.rb new file mode 100644 index 0000000..f2c8825 --- /dev/null +++ b/app/controllers/mcp_controller.rb @@ -0,0 +1,416 @@ +# 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 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 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 + # 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 + # 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. + WRITE_LIMIT_PER_MINUTE = 20 + WRITE_LIMIT_PER_DAY = 1000 + + 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! + + def handle + server = MCP::Server.new( + 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). 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, + 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] ], + # 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) + headers.each { |key, value| response.headers[key] = value } + + 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 + 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 + # --- 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, + # 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 + # 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 + # 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) + 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 ---------------------- + + # 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 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), + # 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) && + 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! + body = mcp_request_body + return if body.nil? + return unless body[:method] == "tools/call" + + 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}", ) + + %(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(requested_tool_name(body)) == 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). + # 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 + + 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/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/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/controllers/oauth/registrations_controller.rb b/app/controllers/oauth/registrations_controller.rb new file mode 100644 index 0000000..4c6db9c --- /dev/null +++ b/app/controllers/oauth/registrations_controller.rb @@ -0,0 +1,231 @@ +# 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 + # 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 + 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) + 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? + 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? + + # 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}") + 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/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/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/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/jobs/oauth_cleanup_job.rb b/app/jobs/oauth_cleanup_job.rb new file mode 100644 index 0000000..f8cdfd5 --- /dev/null +++ b/app/jobs/oauth_cleanup_job.rb @@ -0,0 +1,97 @@ +# 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 *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 + + # 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())" + + 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 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! + abandoned = Doorkeeper::Application + .where(dynamic: true) + .where("created_at < ?", DYNAMIC_APPLICATION_STALE_AFTER.ago) + .where.not(id: Doorkeeper::AccessToken.where(EFFECTIVE_TOKEN_SQL).select(:application_id)) + .where.not(id: Doorkeeper::AccessGrant.where(EFFECTIVE_GRANT_SQL).select(:application_id)) + + # 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.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/app/models/mcp_tools/base_tool.rb b/app/models/mcp_tools/base_tool.rb new file mode 100644 index 0000000..bb0ea54 --- /dev/null +++ b/app/models/mcp_tools/base_tool.rb @@ -0,0 +1,246 @@ +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 + + # 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 + + # 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 + + # 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 + + # 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. + 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 + + # 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 + + 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 + + # 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..dcf81f9 --- /dev/null +++ b/app/models/mcp_tools/configure_paste.rb @@ -0,0 +1,167 @@ +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 + + # 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 + 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..d2d46af --- /dev/null +++ b/app/models/mcp_tools/create_folder.rb @@ -0,0 +1,53 @@ +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) + + 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 + 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..b7790b6 --- /dev/null +++ b/app/models/mcp_tools/create_paste.rb @@ -0,0 +1,136 @@ +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 + 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 + + # 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.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 + end + + private + def build_paste(user, content, filename, custom_subdomain, password) + paste = Paste.new( + content: Paste.render_content(content, filename), + original_filename: filename, + user: user + ) + 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/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/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..4c45594 --- /dev/null +++ b/app/models/mcp_tools/list_pastes.rb @@ -0,0 +1,119 @@ +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 + # 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 + 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, maximum: MAX_PAGE, 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) + # 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) + 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/app/models/mcp_tools/rename_folder.rb b/app/models/mcp_tools/rename_folder.rb new file mode 100644 index 0000000..40461e4 --- /dev/null +++ b/app/models/mcp_tools/rename_folder.rb @@ -0,0 +1,60 @@ +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? + + 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 + + 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/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 %> + +<div class="mx-auto flex w-full max-w-md flex-1 flex-col justify-center px-6 py-14"> + <p class="inline-block w-fit -rotate-1 border-2 border-ink bg-hero-yellow px-3 py-1 font-mono text-xs font-medium tracking-wide uppercase shadow-comic-sm"> + <%= t(".eyebrow") %> + </p> + <h1 class="mt-5 font-display text-5xl text-balance text-ink text-kapow sm:text-6xl"><%= t(".title") %></h1> + + <div class="mt-8 rounded-lg border-3 border-ink bg-white p-5 shadow-comic"> + <p class="text-base text-ink sm:text-sm" dir="ltr"> + <%= error_response.body[:error_description] %> + </p> + </div> +</div> 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 %> + +<div class="mx-auto flex w-full max-w-md flex-1 flex-col justify-center px-6 py-14"> + <p class="inline-block w-fit -rotate-1 border-2 border-ink bg-hero-yellow px-3 py-1 font-mono text-xs font-medium tracking-wide uppercase shadow-comic-sm"> + <%= t(".eyebrow") %> + </p> + <h1 class="mt-5 font-display text-5xl text-balance text-ink text-kapow sm:text-6xl"><%= t(".heading") %></h1> + + <div class="mt-8 grid gap-5 rounded-lg border-3 border-ink bg-white p-5 shadow-comic"> + <% 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. %> + <div class="grid gap-2"> + <p class="inline-block w-fit rounded-md border-2 border-ink bg-paper px-2 py-1 font-mono text-xs font-medium tracking-wide uppercase"> + <%= t(".unverified_client") %> + </p> + <p class="text-base text-ink sm:text-sm"> + <%= t(".redirect_host_html", host: tag.code(URI.parse(@pre_auth.redirect_uri).host, dir: "ltr", class: "font-mono font-semibold text-hero-blue")) %> + </p> + <p class="text-base text-ink/70 sm:text-sm"> + <%= t(".self_asserted_name", name: @pre_auth.client.name) %> + </p> + </div> + <% else %> + <p class="text-base text-ink sm:text-sm"> + <%= t(".prompt_html", client_name: tag.strong(@pre_auth.client.name)) %> + </p> + <% end %> + + <% if @pre_auth.scopes.count > 0 %> + <div id="oauth-permissions"> + <p class="font-display text-2xl tracking-wide text-ink"><%= t(".able_to") %></p> + <ul class="mt-2 grid gap-1 text-base text-ink sm:text-sm"> + <% @pre_auth.scopes.each do |scope| %> + <li class="flex items-baseline gap-2"> + <span aria-hidden="true" class="text-hero-blue">▸</span> + <%= t(scope, scope: [:doorkeeper, :scopes]) %> + </li> + <% end %> + </ul> + </div> + <% end %> + + <div class="grid gap-3"> + <%# 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 %> + </div> + </div> +</div> 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 %> + +<div class="mx-auto w-full max-w-5xl flex-1 px-6 py-10"> + <div class="flex flex-wrap items-end justify-between gap-4"> + <div> + <p class="inline-block -rotate-1 border-2 border-ink bg-hero-yellow px-3 py-1 font-mono text-xs font-medium tracking-wide uppercase shadow-comic-sm"> + <%= t("connected_agents.eyebrow") %> + </p> + <h1 class="mt-5 font-display text-5xl text-balance text-ink text-kapow sm:text-6xl"><%= t("connected_agents.heading") %></h1> + <p class="mt-4 max-w-[64ch] text-base text-ink/70 sm:text-sm"><%= t("connected_agents.body") %></p> + </div> + <%= 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" %> + </div> + + <div class="mt-8 grid grid-cols-1 gap-4"> + <% if @applications.any? %> + <% @applications.each do |application| %> + <article class="rounded-lg border-3 border-ink bg-white p-4 shadow-comic-sm"> + <div class="flex flex-wrap items-start justify-between gap-4"> + <div class="min-w-0"> + <h2 class="truncate font-display text-2xl tracking-wide text-ink"><%= application.name %></h2> + <% if application.dynamic? %> + <p class="mt-1 inline-block w-fit rounded-md border-2 border-ink bg-paper px-2 py-1 font-mono text-xs font-medium tracking-wide uppercase"> + <%= t("doorkeeper.authorizations.new.unverified_client") %> + </p> + <% end %> + <p class="mt-2 font-mono text-xs text-ink/60" dir="ltr"><%= redirect_hosts(application) %></p> + <p class="mt-2 text-sm text-ink/60"> + <%= 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 %> + </p> + <% scopes = granted_scope_labels(application, @tokens_by_application_id) %> + <% if scopes.any? %> + <p class="mt-2 text-sm text-ink/70"> + <span class="font-semibold text-ink"><%= t("connected_agents.scopes_heading") %></span> + <%= scopes.join(" · ") %> + </p> + <% end %> + </div> + <%= 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" %> + </div> + </article> + <% end %> + <% else %> + <div class="rounded-lg border-3 border-ink bg-white p-6 text-center shadow-comic-sm"> + <h2 class="font-display text-3xl tracking-wide text-ink"><%= t("connected_agents.empty_title") %></h2> + <p class="mt-2 text-base text-ink/70 sm:text-sm"><%= t("connected_agents.empty_body") %></p> + </div> + <% end %> + </div> +</div> 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) %><span class="hidden sm:inline"><%= t("layout.api_keys") %></span> <% 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) %><span class="hidden sm:inline"><%= t("layout.connected_agents") %></span> + <% 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) %><span class="hidden sm:inline"><%= t("layout.sign_out") %></span> 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/config/initializers/doorkeeper.rb b/config/initializers/doorkeeper.rb new file mode 100644 index 0000000..efd8574 --- /dev/null +++ b/config/initializers/doorkeeper.rb @@ -0,0 +1,72 @@ +# 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 + + # 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" + + 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/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.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/initializers/mcp_oauth.rb b/config/initializers/mcp_oauth.rb new file mode 100644 index 0000000..29dd896 --- /dev/null +++ b/config/initializers/mcp_oauth.rb @@ -0,0 +1,53 @@ +# 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 + # 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_vars["MCP_OAUTH_ISSUER"].presence || default_issuer).freeze + + default_host = URI(issuer).host + + host = (env_vars["MCP_OAUTH_HOST"].presence || default_host).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 + # 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/initializers/mcp_tools.rb b/config/initializers/mcp_tools.rb new file mode 100644 index 0000000..5378240 --- /dev/null +++ b/config/initializers/mcp_tools.rb @@ -0,0 +1,22 @@ +# 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::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/config/locales/ar.yml b/config/locales/ar.yml index 550e643..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: مكتبتك @@ -299,3 +317,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..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" @@ -298,3 +316,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/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/config/routes.rb b/config/routes.rb index 587b008..1f387bd 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -33,6 +33,28 @@ # 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.<apex>` (and the `*.<apex>` 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.<apex>` back onto the apex + # 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.<canonical-host>`: 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 `*.<apex>` wildcard subdomains never reach it. + constraints host: "www.#{McpOauth::CONFIG[:host]}" do + 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}" + } + 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 @@ -63,6 +85,47 @@ 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, 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", + authorized_applications: "oauth/authorized_applications" + 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" + + # 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" + + # 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 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/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 b7eeeb8..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_06_090004) 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" @@ -39,6 +39,56 @@ 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_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 + 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_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" + 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 ["dynamic", "created_at"], name: "index_oauth_applications_on_dynamic_and_created_at" + 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 +144,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/lib/middleware/mcp_body_limit.rb b/lib/middleware/mcp_body_limit.rb new file mode 100644 index 0000000..9801c20 --- /dev/null +++ b/lib/middleware/mcp_body_limit.rb @@ -0,0 +1,89 @@ +# 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/ +# 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 +# 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 + # 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 + + # 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) + 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, 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(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) + @app.call(env) + end + + 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) + 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 + # 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) + ActionDispatch::Journey::Router::Utils.normalize_path(env["PATH_INFO"].to_s) + end + + def declared_oversize?(env, limit) + length = env["CONTENT_LENGTH"] + !length.nil? && length.to_i > limit + end + + def too_large + [ 413, { "Content-Type" => "application/json" }, [ %({"error":"payload_too_large"}) ] ] + end +end 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/public/llms.txt b/public/llms.txt index 8ae0fa6..bbdc512 100644 --- a/public/llms.txt +++ b/public/llms.txt @@ -99,6 +99,45 @@ 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 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 Upload Markdown and the server renders it into a styled, self-contained HTML 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_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/config/mcp_oauth_test.rb b/test/config/mcp_oauth_test.rb new file mode 100644 index 0000000..ee4b496 --- /dev/null +++ b/test/config/mcp_oauth_test.rb @@ -0,0 +1,55 @@ +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 + + # 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/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 new file mode 100644 index 0000000..c6e26ef --- /dev/null +++ b/test/controllers/mcp_controller_test.rb @@ -0,0 +1,562 @@ +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 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 + + 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 "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. + 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 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) + + assert_not_equal 500, response.status + assert_response :bad_request + end + + 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}"}}) + + mcp_post(body, token: read_write_token.plaintext_token) + + assert_not_equal 500, response.status + 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 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) + + 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 -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_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" + 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 + + 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 + + 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 + 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) + 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. + 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 ------------- + + 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 + 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 + 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 + + # 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}}) } + 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 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 diff --git a/test/controllers/oauth/registrations_controller_test.rb b/test/controllers/oauth/registrations_controller_test.rb new file mode 100644 index 0000000..0b5524e --- /dev/null +++ b/test/controllers/oauth/registrations_controller_test.rb @@ -0,0 +1,311 @@ +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 + + 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 + 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 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 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/authentication_return_to_test.rb b/test/integration/authentication_return_to_test.rb new file mode 100644 index 0000000..5fbaf44 --- /dev/null +++ b/test/integration/authentication_return_to_test.rb @@ -0,0 +1,55 @@ +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 + + # 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/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: "<title>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/mcp_body_limit_integration_test.rb b/test/integration/mcp_body_limit_integration_test.rb new file mode 100644 index 0000000..5a971a9 --- /dev/null +++ b/test/integration/mcp_body_limit_integration_test.rb @@ -0,0 +1,120 @@ +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_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 + 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 /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 + + 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 + + # 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, + headers: JSON_HEADERS + + assert_response :created + assert response.parsed_body["client_id"].present? + 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_OAUTH }.to_json + end + + def oversize_mcp_body + { jsonrpc: "2.0", id: 1, method: "initialize", params: { pad: OVERSIZE_MCP } }.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/mcp_tools_test.rb b/test/integration/mcp_tools_test.rb new file mode 100644 index 0000000..257571c --- /dev/null +++ b/test/integration/mcp_tools_test.rb @@ -0,0 +1,164 @@ +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 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[ + 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") + 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") + + 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 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[ get_paste get_paste_stats 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: "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 "update_paste republishes a user-owned paste's content through the real endpoint" do + paste = @user.pastes.create!(content: "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") + + 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/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..8a207ee --- /dev/null +++ b/test/integration/oauth_resource_indicator_test.rb @@ -0,0 +1,234 @@ +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 "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"] + + 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 diff --git a/test/integration/www_host_redirect_test.rb b/test/integration/www_host_redirect_test.rb new file mode 100644 index 0000000..13dba30 --- /dev/null +++ b/test/integration/www_host_redirect_test.rb @@ -0,0 +1,69 @@ +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 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 permanently redirects to the apex, preserving the path" do + host! "www.#{APEX}" + + get "/oauth/authorized_applications" + + assert_response :permanent_redirect + 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 :permanent_redirect + 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 :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 + + 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 redirect back to the apex. + assert_not_equal 308, @response.status + 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 new file mode 100644 index 0000000..8d536c2 --- /dev/null +++ b/test/jobs/oauth_cleanup_job_test.rb @@ -0,0 +1,263 @@ +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 + + # 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 + + # 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 + 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, refresh_token: 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, + refresh_token: refresh_token + ) + token + end + + 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: expires_in, + scopes: "mcp:read mcp:write", + resource: RESOURCE + ) + columns = { revoked_at: revoked_at, created_at: created_at }.compact + grant.update_columns(columns) if columns.any? + 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 diff --git a/test/middleware/mcp_body_limit_test.rb b/test/middleware/mcp_body_limit_test.rb new file mode 100644 index 0000000..14c409e --- /dev/null +++ b/test/middleware/mcp_body_limit_test.rb @@ -0,0 +1,87 @@ +require "test_helper" + +class McpBodyLimitTest < ActiveSupport::TestCase + # 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 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 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 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 + %w[/mcp/ //mcp /mcp// /oauth/register/ /oauth//register //oauth/register /oauth///register/].each do |path| + 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 "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 + assert_equal payload.bytesize, body.join.bytesize, "downstream must receive the full body" + end + + 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 + 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 over(limit) + "a" * (limit + 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/configure_paste_test.rb b/test/models/mcp_tools/configure_paste_test.rb new file mode 100644 index 0000000..6aa02e7 --- /dev/null +++ b/test/models/mcp_tools/configure_paste_test.rb @@ -0,0 +1,210 @@ +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 + + # 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 + + 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 + + # 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 new file mode 100644 index 0000000..b8eca88 --- /dev/null +++ b/test/models/mcp_tools/create_folder_test.rb @@ -0,0 +1,83 @@ +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 + + # 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 + + 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 + + # 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/create_paste_test.rb b/test/models/mcp_tools/create_paste_test.rb new file mode 100644 index 0000000..81c370a --- /dev/null +++ b/test/models/mcp_tools/create_paste_test.rb @@ -0,0 +1,180 @@ +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 + + # 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 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/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/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 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/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..0d5a6c0 --- /dev/null +++ b/test/models/mcp_tools/list_pastes_test.rb @@ -0,0 +1,128 @@ +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 + + 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) + end + + def create_paste_for(user, content, folder: nil) + Paste.create!(content: content, original_filename: "p.html", user: user, folder: folder) + 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..7151b45 --- /dev/null +++ b/test/models/mcp_tools/rename_folder_test.rb @@ -0,0 +1,96 @@ +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 + + # 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 + + 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 + + # 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 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