Skip to content

Add authenticated MCP server and OAuth integration#15

Merged
AliOsm merged 22 commits into
mainfrom
mcp-server
Jul 10, 2026
Merged

Add authenticated MCP server and OAuth integration#15
AliOsm merged 22 commits into
mainfrom
mcp-server

Conversation

@AliOsm

@AliOsm AliOsm commented Jul 10, 2026

Copy link
Copy Markdown
Owner

Summary

  • add a Streamable HTTP MCP endpoint at /mcp, backed by Doorkeeper OAuth and account-scoped bearer tokens
  • add OAuth discovery, dynamic client registration, PKCE authorization, RFC 8707 resource binding, token revocation, and connected-agent management
  • expose paste and folder tools for creating, updating, configuring, reading, listing, and organizing content
  • add request-size limits, method-aware parameter validation, rate limits, scope enforcement, safe error contracts, and OAuth cleanup
  • document MCP setup and production discovery for clients and operators

Why

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

  • canonical issuer and resource identity are boot-time configuration rather than request-header derived
  • authorization codes require PKCE and tokens are bound to the /mcp resource
  • redirect URIs, scopes, request bodies, pagination, write rates, and JSON-RPC parameter shapes are validated
  • internal tool and transport exceptions are sanitized at the protocol boundary
  • stale OAuth tokens, grants, and abandoned dynamic registrations are cleaned up by a recurring job
  • migrations add the OAuth tables and cleanup indexes

Validation

  • CI=1 bundle exec rails test — 439 runs, 2,039 assertions, 0 failures, 0 errors
  • focused MCP/OAuth suites — 78 runs, 312 assertions, 0 failures, 0 errors
  • bundle exec rubocop — 128 files, no offenses
  • bundle exec brakeman --no-pager — zero warnings
  • bundle exec bundler-audit check — no vulnerabilities
  • yarn npm audit --all — no audit suggestions
  • RAILS_ENV=test bundle exec rails zeitwerk:check — all good

Deployment notes

Database migrations run through the existing container db:prepare entrypoint. Production defaults to https://pastehtml.dev; dynamic client registration can optionally be disabled with MCP_DYNAMIC_REGISTRATION_DISABLED.

AliOsm and others added 22 commits July 10, 2026 12:03
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>
@AliOsm AliOsm marked this pull request as ready for review July 10, 2026 19:54
@AliOsm AliOsm merged commit c95d246 into main Jul 10, 2026
3 checks passed
@AliOsm AliOsm deleted the mcp-server branch July 10, 2026 19:54
@AliOsm AliOsm restored the mcp-server branch July 10, 2026 19:54
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant