Add authenticated MCP server and OAuth integration#15
Merged
Conversation
Lays the OAuth/MCP groundwork: doorkeeper (>= 5.9.1, fixes a public-client revocation bypass) and the official mcp gem for the upcoming remote MCP server. McpOauth::CONFIG is a boot-time, frozen, env-aware source of truth for the issuer/host/resource identifiers so later routing, transport, and RFC 8707 audience checks never derive them from request headers. Also filters :code and :content from logs, since MCP tool calls and the OAuth code/PKCE exchange can leak authorization secrets and full paste bodies. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ce binding The MCP OAuth core (plan section 6.2/6.6): Doorkeeper 5.9.3 configured as an authorization-code + PKCE(S256)-only server for public clients, apex-host routed, with hand-rolled RFC 8707 resource-indicator enforcement. - Migration: NULLable application secret (public clients), `dynamic` DCR flag, PKCE columns, `resource` on grants AND tokens, `last_used_at`; the `previous_refresh_token` column is deliberately absent so Doorkeeper's feature detection gives immediate refresh rotation (no grace window). - Initializer: session-cookie resource_owner_authenticator that resumes the authorize URL via session[:return_to_after_authenticating]; hashed token secrets; header-only bearer tokens; mcp:read default / mcp:write optional scopes; custom_access_token_attributes carries `resource` through the grant -> token -> refresh chain. - Oauth::AuthorizationsController / Oauth::TokensController subclasses require exactly one `resource` parameter (raw query/body parse catches repeats Rails params would collapse), compare scheme/host case-insensitively with a byte-exact path, persist only the canonical McpOauth::CONFIG[:resource_uri], and reject with RFC 8707 invalid_target. - Consent screen in the app layout: hidden `resource` round-trip, scope labels (EN + AR), and "unverified dynamically registered client" + redirect-host labeling for DCR-minted clients. - Routes: use_doorkeeper under the apex-host constraint, applications controller skipped, authorized_applications kept for the later connected-agents screen. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
WellKnownController serves the protected-resource metadata (both the root and /mcp-suffixed well-known forms) and authorization-server metadata MCP clients probe before login. Both documents are built solely from McpOauth::CONFIG, never request headers. AS metadata advertises registration_endpoint ahead of the DCR route landing (same release) and mandatory code_challenge_methods_supported, without which spec-compliant clients abort discovery. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
POST /oauth/register lets MCP agents (Claude Code, Codex CLI, ...) self-register before the OAuth flow. It is public and internet-facing, so client metadata is validated strictly, not echoed: - redirect_uris: required non-empty array (<= 10), each an absolute URI with no fragment/userinfo/duplicates; https for any host, http only for RFC 8252 loopback (localhost, 127.0.0.1, [::1]) on any port. - token_endpoint_auth_method: absent or "none" only. - grant_types / response_types / scope: validated subsets, normalized to the full allowed sets; the app always persists "mcp:read mcp:write". - client_name: optional, stripped, <= 255 chars, stored unverified. Each registration mints a secretless public Doorkeeper::Application (confidential: false, dynamic: true) -- Doorkeeper skips secret generation because the column is nullable and the client is public. The 201 response returns the client_id plus an explicit "token_endpoint_auth_method": "none" (RFC 7591's omitted default of client_secret_basic would contradict these clients) and never a client_secret, with no-store cache headers. Guards: a 10/hour/IP rate limit (JSON 429) and an MCP_DYNAMIC_REGISTRATION_DISABLED kill switch that 403s before any validation. Doorkeeper's force_ssl_in_redirect_uri now exempts the same loopback hosts so validated loopback URIs survive its RedirectUriValidator. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Wire the MCP Streamable HTTP transport (mcp gem 0.23.0, stateless) behind a McpController that layers the guards the transport cannot do itself, in order: 1. Origin guard (first before_action): absent Origin passes (CLI agents); a present Origin must equal the canonical app origin or it is 403'd before any auth or DB work -- a foreign-Origin request with a valid token never reaches token lookup. 2. RFC 6750 split 401 challenges: no bearer credentials -> challenge without an error attribute; expired/revoked/unknown/malformed/wrong-audience (RFC 8707) or non-mcp-scoped token -> same challenge plus error="invalid_token". 3. Bounded, rewind-safe pre-dispatch peek (<= 4 MiB + 1, max_nesting 20, single top-level object; oversized/malformed/batched bodies step aside for the transport): pre-authorizes tools/call (out-of-scope -> 403 insufficient_scope naming the full scope list to avoid scope oscillation; unknown tool falls through to the SDK) and meters mcp:write calls (20/min + 1000/day per token-owning user, mirroring the REST API's rate_limit semantics). 4. Dispatch: hand the untouched request to the transport and return its Rack triple faithfully -- empty body -> head status (202 notifications stay truly empty), otherwise pass the body through unre-serialized. McpTools holds VERSION, INSTRUCTIONS (pastes are permanent, updates overwrite, Markdown source is not retained), and a tool-class -> required-scope registry (empty until Task 6) with register/deregister/for_scopes/required_scope. Route: match "mcp", via: %i[get post delete] inside the apex host constraint so GET yields the transport's 405, never a Rails 404. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Build the MCP tool infrastructure and the three Phase 1 tools on top of the
Task 5 /mcp endpoint. McpTools::BaseTool centralizes the conventions every
tool shares: a single success shape (structured content mirroring the tool's
output_schema, plus a JSON text block), one stable error shape
({ code, message, field? }) for all domain failures so they are
model-correctable rather than raised exceptions, and URL generation derived
from the trusted McpOauth::CONFIG[:issuer] (tools have no request context) --
app paths mirror config/routes.rb and the per-paste live origin mirrors
PasteLiveUrl, sourcing scheme/host/port from the issuer.
create_paste (mcp:write) publishes a paste owned by the token user with a
required explicit format, filename/format agreement checks, folder-by-id
ownership, folder-by-name auto-create (folder_created flag), password, and
custom subdomain. list_pastes / list_folders (mcp:read) never load paste
bodies (with_content_size) and page 20 newest-first. Each tool sets all four
annotation hints and is registered with its scope via a to_prepare block, so
tools/list filtering and the controller's pre-dispatch write step-up both work.
The server is now built with validate_tool_call_results enabled, so a success
result that violates its output_schema is caught server-side.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…tats, folder CRUD) Rounds out the MCP tool catalog to all 10 tools from the plan: update_paste (destructive republish, format always derives the rendering filename so an old .md-sourced paste can never be silently re-rendered as Markdown when updated with format: "html"), configure_paste (password/subdomain/folder settings, independent of content, with conflicting-argument and no-settings-supplied guards), get_paste (stored HTML plus an optional best-effort Markdown conversion), get_paste_stats (aggregate-only view analytics, zero-filled by source, no referrers/user-agents/IPs), and create_folder/rename_folder/delete_folder (delete nullifies pastes and revokes folder-scoped API keys, gated on confirm: true). Extracted filename-resolution and folder-resolve-or-create logic from create_paste into BaseTool so update_paste and configure_paste share the exact same semantics rather than reimplementing them. Extends the Phase 1 integration test to assert the full-scope tools/list now returns all 10 tools and a read-only token sees exactly the 4 read tools, plus an end-to-end update_paste call through POST /mcp. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…pplications) Restyles Doorkeeper's stock authorized_applications index/destroy into the account's "Connected agents" screen: a custom Oauth::AuthorizedApplicationsController (pinning the app layout like the consent screen) lists each authorized OAuth client with its redirect host, registered date, granted scopes, and last-used time (one grouped-tokens query, no N+1), labels dynamically-registered clients as unverified, and revokes an app's tokens/grants with a confirm dialog. Links the screen from the header nav next to API keys, bilingual EN/AR throughout. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… job McpController now bumps an access token's last_used_at on every successful /mcp authentication, but only when the existing value is stale by more than 15 minutes (nil counts as stale), so heavy agent traffic isn't one UPDATE per request. Failure paths never bump. Add OauthCleanupJob (solid_queue), scheduled nightly via config/recurring.yml: phase 1 revokes access tokens inactive for 90+ days (COALESCE(last_used_at, created_at)), phase 2 deletes dynamic (DCR) applications 30+ days old with no active tokens or grants left -- absorbing Claude Code's re-registration churn without ever touching pre-registered clients. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adds the cross-cutting MCP OAuth coverage no single component test owned: a full agent journey in one test (401 -> discovery -> DCR -> PKCE authorize -> token -> tools/list -> create_paste -> refresh, with the rotated-out refresh token proven dead), paste-host and non-canonical-host routing isolation, header-only bearer auth at /mcp, and a real captured-log proof that OAuth codes and MCP paste content never reach the request log unfiltered. Also extends oauth_resource_indicator_test.rb so the uppercase resource-indicator case is proven usable at /mcp, not just correctly stored. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Extract McpOauth::CONFIG's construction into a pure McpOauth.build_config(env:, env_vars:) so the §6.0 dev issuer derivation (localhost:3000) can be asserted without reloading the initializer; CONFIG's own construction stays byte-identical. Also add an integration test proving POST /oauth/revoke actually kills a token for a public (secret-less) MCP client, confirmed via a subsequent 401 invalid_token at /mcp. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…oints Addresses the blocking and several medium review findings on the public MCP surface: - Add an early Rack middleware (McpBodyLimit) that rejects oversized POST bodies to /mcp and /oauth/register at the front of the stack, before Rails materializes DCR params or the transport buffers the body (finding 1). - Align the /mcp pre-dispatch peek's JSON nesting bound with the transport's (both 64) so a body nested between the old peek cap and the transport cap can no longer skip the scope + write-rate gates while still dispatching; and classify a non-object `params` without raising (was a 500) (finding 2). - Route SDK/tool exceptions to Rails.error.report, dropping the SDK-supplied context (which can carry the raw request body / paste content) and keeping only the user id (finding 5). - Declare explicit server capabilities (tools, listChanged: false) so the initialize response stops advertising prompts/resources/logging the stateless server does not implement (finding 6). - Pin doorkeeper below the next major and mcp to the 0.23.x patch line, since the implementation relies on specific 5.9.x/0.23 behavior (finding 7). - Reject DCR redirect_uris with an out-of-range port (URI.parse accepts :99999) or an abusive length (finding 8). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ect, unique races, docs) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…safe www redirect Round-4 review follow-ups: - McpBodyLimit now bounds the actual rack.input stream (rewind-safe, replacing the input with an in-memory copy) instead of trusting Content-Length, so a chunked or header-lying oversized body is rejected too; and it normalizes a trailing slash so /mcp/ and /oauth/register/ -- which Rails routes to the same endpoints -- can't slip past. Adds full-stack integration tests alongside the isolated middleware tests (finding 1). - create_paste wraps its save in the shared uniqueness-race translator, so a concurrent custom_subdomain collision returns the stable tool error and the auto-created folder rolls back with the paste (finding 2). - Configure the SDK's GLOBAL exception reporter (config/initializers/mcp.rb): transport-level failures report through it, not the per-server reporter, and were silently 500ing. Drops the SDK context (may carry the request body) (finding 3). - The canonical www -> apex redirect now uses 308, not 301, so a POST to www is replayed with its method and body instead of being downgraded to GET (finding 4). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
McpBodyLimit#normalized_path only stripped a single trailing slash, but Rails' router also collapses repeated and leading slashes before routing, so an oversized request in a repeated-slash form (e.g. /oauth//register, //mcp) slipped past the guard yet still reached the endpoint. Use ActionDispatch::Journey::Router::Utils.normalize_path -- the exact function the router applies -- so the guard matches every path the router routes. The integration harness normalizes request paths before dispatch, so the new full-stack repeated-slash tests drive Rails.application.call with a crafted env to exercise the real, un-normalized PATH_INFO a raw HTTP client can send. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ssion-safe return-to Round-6 review follow-ups: - OauthCleanupJob: an unrevoked token carrying a refresh_token keeps its dynamic app alive even after the 1-hour access token expires (the refresh token can still mint access). The effective-token predicate now accounts for this; genuine inactivity is still handled by phase 1's 90-day revocation. Stream the deletions with find_each and add supporting indexes (findings 1, 6). - McpBodyLimit now guards every OAuth POST endpoint (token/authorize/revoke/ introspect/register), not just register, with a tight 1 MB cap; the /mcp endpoint gets an 8 MB cap so a full 2 MB paste still fits after JSON-string escaping (which can double quote-heavy content). The transport's max_request_bytes is raised to match (findings 2, 3). - list_pastes clamps page and bounds it in the input schema, so an absurd page can't reach Postgres as an overflowing OFFSET and leak PG error text (finding 4). - Authentication caps the return-to path stored in the cookie session, so a multi-kilobyte OAuth state no longer overflows it into a 500 (finding 5). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…, constrain docs Round-7 review follow-ups: - McpBodyLimit keys the body cap on PATH, not method, so body-bearing verbs other than POST are covered too -- notably DELETE /oauth/authorize, which Doorkeeper routes and which previously bypassed the OAuth cap (finding 1). - BaseTool wraps every tool's `call` (prepended per-subclass via inherited) so an unexpected exception is reported to Rails.error and returned as one generic tool error, instead of the SDK embedding the raw exception message -- e.g. a driver's "string contains null byte" -- in JSON-RPC error data (finding 2). - Align the DCR redirect_uri cap (now 512) with the login-resume return-path cap (now 2000) so any redirect_uri accepted at registration yields an authorize path that fits the cookie session and can resume after sign-in (finding 3). - Document the MCP request cap honestly: a paste's content travels as a JSON string, and a client whose encoder emits six-byte \uXXXX escapes for < > & should keep such content below ~1.3 MB to stay within the 8 MB request cap (finding 4). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… MCP in full reference
Round-8 review follow-ups:
- The per-tool exception wrapper doesn't cover exceptions raised during SDK
request validation/dispatch (before a request reaches a tool) -- e.g. a
tools/call with JSON-RPC array `params` raises a TypeError the SDK embeds as
the `data` of a -32603 error ("no implicit conversion of Symbol into
Integer"). McpController now strips `data` from any -32603 internal-error
response at the boundary (single or batch), fixing Content-Length, while
leaving results and other protocol errors (e.g. -32602) untouched (finding 1).
- Add the MCP/OAuth section to public/llms-full.txt, which billed itself as the
complete reference but documented only the HTTP API (finding 2).
- Comment cleanup: McpBodyLimit guards all verbs (not just POST), and the
MAX_REQUEST_BYTES note carries the JSON-escaping caveat.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
JSON-RPC/MCP method params must be a structured object. A tools/call or initialize with array or scalar params previously reached the SDK, which indexed the non-object by a symbol and surfaced a -32603 "Internal error" (its raw data stripped by the boundary sanitizer, but still the wrong code). A pre-dispatch check now answers the semantically correct -32602 "Invalid params" for any non-object params on a request, before dispatch. The boundary sanitizer stays as defense-in-depth for any other -32603 and is now covered by a direct unit test. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… null/omitted) The MCP schema requires an object `params` for initialize (protocolVersion/ capabilities/clientInfo) and tools/call (name/arguments). The pre-dispatch check previously allowed a missing or explicit-null params through, so initialize would silently proceed without required client info and tools/call would surface a sanitized -32603. For these methods a missing/null/non-object params now returns -32602 Invalid params; params stays optional (only a present non-object is rejected) for methods like tools/list that permit it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
initialize required an object params but not its contents, so {} or an object
missing clientInfo still completed the handshake. The MCP lifecycle requires
protocolVersion (string), capabilities (object), and clientInfo (object); a
request missing any of them now returns -32602 Invalid params. Presence and
container type are checked, not inner fields, so a conforming client that shapes
those objects differently is never over-rejected.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The MCP Implementation schema requires clientInfo to carry string `name` and
`version`; they were previously unchecked, so a clientInfo of {} still handshook.
Both are now validated (optional fields like clientInfo.title are left alone), so
a request missing either returns -32602 Invalid params.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
/mcp, backed by Doorkeeper OAuth and account-scoped bearer tokensWhy
This lets MCP clients such as coding agents connect to PasteHTML, authorize through the existing user account flow, and manage paste content without sharing account credentials or API keys.
Security and reliability
/mcpresourceValidation
CI=1 bundle exec rails test— 439 runs, 2,039 assertions, 0 failures, 0 errorsbundle exec rubocop— 128 files, no offensesbundle exec brakeman --no-pager— zero warningsbundle exec bundler-audit check— no vulnerabilitiesyarn npm audit --all— no audit suggestionsRAILS_ENV=test bundle exec rails zeitwerk:check— all goodDeployment notes
Database migrations run through the existing container
db:prepareentrypoint. Production defaults tohttps://pastehtml.dev; dynamic client registration can optionally be disabled withMCP_DYNAMIC_REGISTRATION_DISABLED.