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 ."
+ },
+ custom_subdomain: {
+ type: "string",
+ description: "Optional vanity subdomain for the paste's live origin (.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 ."
+ }
+ },
+ 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 %>
+
+
+
+ <%= t(".eyebrow") %>
+
+
<%= t(".title") %>
+
+
+
+ <%= error_response.body[:error_description] %>
+
+
+
diff --git a/app/views/doorkeeper/authorizations/new.html.erb b/app/views/doorkeeper/authorizations/new.html.erb
new file mode 100644
index 0000000..755740f
--- /dev/null
+++ b/app/views/doorkeeper/authorizations/new.html.erb
@@ -0,0 +1,65 @@
+<% set_meta_tags title: t(".title"), noindex: true %>
+
+
+
+ <%= t(".eyebrow") %>
+
+
<%= t(".heading") %>
+
+
+ <% if @pre_auth.client.application.dynamic? %>
+ <%# DCR-minted clients pick their own name -- anyone can register as
+ "Codex". Lead with the unverified warning and the redirect host (the
+ one client-controlled value the flow actually verifies), and present
+ the self-asserted name only as such. %>
+
+
+ <%= t(".unverified_client") %>
+
+
+ <%= t(".redirect_host_html", host: tag.code(URI.parse(@pre_auth.redirect_uri).host, dir: "ltr", class: "font-mono font-semibold text-hero-blue")) %>
+
+
+ <%= t(".self_asserted_name", name: @pre_auth.client.name) %>
+
+
+ <% else %>
+
+ <%= t(".prompt_html", client_name: tag.strong(@pre_auth.client.name)) %>
+
+ <% end %>
+
+ <% if @pre_auth.scopes.count > 0 %>
+
+
<%= t(".able_to") %>
+
+ <% @pre_auth.scopes.each do |scope| %>
+ -
+ ▸
+ <%= t(scope, scope: [:doorkeeper, :scopes]) %>
+
+ <% end %>
+
+
+ <% end %>
+
+
+ <%# Both forms re-submit the whole pre-auth parameter set: the approve
+ POST is a fresh request, so anything missing here is missing from
+ the grant. That includes the hidden `resource` field -- always the
+ CANONICAL McpOauth::CONFIG[:resource_uri], never the client's
+ spelling -- which Doorkeeper's stock form would silently drop
+ (RFC 8707 round-trip). %>
+ <%= form_tag oauth_authorization_path, method: :post do %>
+ <%= render "authorization_params" %>
+ <%= submit_tag t(".authorize"),
+ class: "w-full rounded-md border-3 border-ink bg-hero-red px-4 py-3 font-display text-2xl tracking-wide text-white shadow-comic-sm hover:bg-hero-red/90 t-press" %>
+ <% end %>
+ <%= form_tag oauth_authorization_path, method: :delete do %>
+ <%= render "authorization_params" %>
+ <%= submit_tag t(".deny"),
+ class: "w-full rounded-md border-2 border-ink bg-white px-4 py-2 font-display text-xl tracking-wide text-ink shadow-comic-sm hover:bg-paper t-press" %>
+ <% end %>
+
+
+
diff --git a/app/views/doorkeeper/authorized_applications/index.html.erb b/app/views/doorkeeper/authorized_applications/index.html.erb
new file mode 100644
index 0000000..ab71bf5
--- /dev/null
+++ b/app/views/doorkeeper/authorized_applications/index.html.erb
@@ -0,0 +1,59 @@
+<% set_meta_tags title: t("connected_agents.title"), noindex: true %>
+
+
+
+
+
+ <%= t("connected_agents.eyebrow") %>
+
+
<%= t("connected_agents.heading") %>
+
<%= t("connected_agents.body") %>
+
+ <%= link_to t("connected_agents.back"), pastes_path,
+ class: "rounded-md border-3 border-ink bg-white px-4 py-2.5 font-display text-xl tracking-wide text-ink shadow-comic-sm hover:bg-hero-yellow" %>
+
+
+
+ <% if @applications.any? %>
+ <% @applications.each do |application| %>
+
+
+
+
<%= application.name %>
+ <% if application.dynamic? %>
+
+ <%= t("doorkeeper.authorizations.new.unverified_client") %>
+
+ <% end %>
+
<%= redirect_hosts(application) %>
+
+ <%= t("connected_agents.meta.registered", date: l(application.created_at.to_date, format: :long)) %>
+ ·
+ <% if (last_used_at = application_last_used_at(application, @tokens_by_application_id)).present? %>
+ <%= t("connected_agents.meta.last_used", date: l(last_used_at.to_date, format: :long)) %>
+ <% else %>
+ <%= t("connected_agents.meta.never_used") %>
+ <% end %>
+
+ <% scopes = granted_scope_labels(application, @tokens_by_application_id) %>
+ <% if scopes.any? %>
+
+ <%= t("connected_agents.scopes_heading") %>
+ <%= scopes.join(" · ") %>
+
+ <% end %>
+
+ <%= button_to t("connected_agents.revoke"), oauth_authorized_application_path(application), method: :delete,
+ form: { data: { turbo_confirm: t("connected_agents.revoke_confirm") } },
+ class: "shrink-0 rounded-md border-2 border-ink bg-white px-3 py-2 font-display text-lg tracking-wide text-hero-red shadow-comic-sm hover:bg-hero-yellow" %>
+
+
+ <% end %>
+ <% else %>
+
+
<%= t("connected_agents.empty_title") %>
+
<%= t("connected_agents.empty_body") %>
+
+ <% end %>
+
+
diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb
index cbc478a..11c17a0 100644
--- a/app/views/layouts/application.html.erb
+++ b/app/views/layouts/application.html.erb
@@ -62,6 +62,11 @@
class: "inline-flex items-center sm:-rotate-1 border-2 border-ink p-2.5 font-display text-xs tracking-wide shadow-comic-sm sm:px-2.5 sm:py-1 sm:text-sm #{api_keys_current ? "bg-ink text-paper" : "bg-white text-ink hover:bg-hero-yellow"}" do %>
<%= nav_icon(:api_keys) %><%= t("layout.api_keys") %>
<% end %>
+ <% connected_agents_current = current_nav?(:connected_agents) %>
+ <%= link_to oauth_authorized_applications_path, aria: { label: t("layout.connected_agents"), current: ("page" if connected_agents_current) },
+ class: "inline-flex items-center sm:rotate-1 border-2 border-ink p-2.5 font-display text-xs tracking-wide shadow-comic-sm sm:px-2.5 sm:py-1 sm:text-sm #{connected_agents_current ? "bg-ink text-paper" : "bg-white text-ink hover:bg-hero-yellow"}" do %>
+ <%= nav_icon(:connected_agents) %><%= t("layout.connected_agents") %>
+ <% end %>
<%= button_to session_path, method: :delete, aria: { label: t("layout.sign_out") },
class: "inline-flex items-center border-2 border-ink bg-white p-2.5 font-display text-xs tracking-wide text-ink shadow-comic-sm hover:bg-hero-yellow sm:px-2.5 sm:py-1 sm:text-sm" do %>
<%= nav_icon(:sign_out) %><%= t("layout.sign_out") %>
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.` (and the `*.` wildcard), but the
+ # OAuth/MCP routes below are constrained to the canonical apex host only, so
+ # a signed-in user who reaches the www host and clicks a relative app link
+ # (e.g. "Connected agents") would 404. Fold `www.` back onto the apex
+ # before any app route matches -- MUST stay at the TOP of this block so it
+ # wins over `root` and friends. It matches ALL verbs, so it uses a 308
+ # (Permanent Redirect), NOT a 301: a client following a 301 is allowed to
+ # rewrite POST to GET and drop the body, which would silently break a
+ # state-changing request (e.g. an API upload) aimed at www; 308 preserves
+ # the method and body. The rule matches only the single host
+ # `www.`: it leaves the canonical host itself alone even when
+ # that host is literally `www.example.com` (the test apex) or the bare
+ # `pastehtml.dev` apex (production). This whole block is the non-paste
+ # branch, so paste origins and `*.` wildcard subdomains never reach it.
+ constraints host: "www.#{McpOauth::CONFIG[:host]}" do
+ match "/(*path)", via: :all, to: redirect(status: 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: "Agent Journeyhi
", 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 MCPhi
", 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: "Beforeold
", original_filename: "paste.html")
+
+ mcp_post(
+ tools_call_body("update_paste", token: paste.token, content: "Afternew
", 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 "Afternew
", 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: "Reporthi
", format: "html")
+
+ assert_not response.error?
+ paste = Paste.find_by(token: response.structured_content[:token])
+ assert_equal @alice, paste.user
+ assert_equal "Reporthi
", 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: "Reporthi
", 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 "Reporthi
", structured[:content]
+ assert_equal "Reporthi
".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, "Oldold
", filename: "paste.html")
+
+ response = update(token: paste.token, content: "Newnew
", format: "html")
+
+ assert_not response.error?
+ paste.reload
+ assert_equal "Newnew
", 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