Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
e7d0a35
feat(mcp): add doorkeeper/mcp gems and McpOauth::CONFIG foundation
AliOsm Jul 10, 2026
b946ee5
feat(oauth): add Doorkeeper authorization server with RFC 8707 resour…
AliOsm Jul 10, 2026
ab0d47a
feat(oauth): add RFC 9728 + RFC 8414 discovery endpoints
AliOsm Jul 10, 2026
ed51a1f
feat(oauth): add RFC 7591 Dynamic Client Registration endpoint
AliOsm Jul 10, 2026
34ae11f
feat(mcp): add the /mcp Streamable HTTP endpoint with auth and guards
AliOsm Jul 10, 2026
2160746
feat(mcp): add Phase 1 tools (create_paste, list_pastes, list_folders)
AliOsm Jul 10, 2026
a132883
feat(mcp): add Phase 2 tools (update/configure/get_paste, get_paste_s…
AliOsm Jul 10, 2026
de0c967
feat(oauth): add the Connected agents screen (Doorkeeper authorized_a…
AliOsm Jul 10, 2026
b442feb
feat(oauth): throttle last_used_at bump and add nightly OAuth cleanup…
AliOsm Jul 10, 2026
76d0c42
test(mcp): add the §9 end-to-end integration sweep
AliOsm Jul 10, 2026
d7bc07e
test(oauth): cover dev config derivation and RFC 7009 revocation
AliOsm Jul 10, 2026
e21ff8f
fix(mcp): close request-gating bypasses and harden the MCP/OAuth endp…
AliOsm Jul 10, 2026
4fbb7aa
fix(mcp): address review findings 3,4,9,10 (cleanup expiry, www redir…
AliOsm Jul 10, 2026
152b08b
fix(mcp): close body-limit bypasses, report transport errors, method-…
AliOsm Jul 10, 2026
edd21fb
fix(mcp): normalize body-limit paths with the router's own normalization
AliOsm Jul 10, 2026
2bb2f62
fix(mcp): refresh-aware cleanup, per-path body limits, page clamp, se…
AliOsm Jul 10, 2026
fab164f
fix(mcp): guard all verbs, sanitize tool exceptions, align OAuth caps…
AliOsm Jul 10, 2026
641ac64
fix(mcp): sanitize internal errors at the JSON-RPC boundary; document…
AliOsm Jul 10, 2026
188a601
fix(mcp): answer malformed non-object params with -32602, not -32603
AliOsm Jul 10, 2026
6077c1a
fix(mcp): require object params for initialize and tools/call (reject…
AliOsm Jul 10, 2026
8bfd969
fix(mcp): validate initialize's required lifecycle fields
AliOsm Jul 10, 2026
51377f4
fix(mcp): validate initialize clientInfo name and version fields
AliOsm Jul 10, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
18 changes: 18 additions & 0 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
Expand All @@ -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)
Expand All @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
44 changes: 44 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
15 changes: 14 additions & 1 deletion app/controllers/concerns/authentication.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
62 changes: 62 additions & 0 deletions app/controllers/concerns/oauth/resource_indicator_enforcement.rb
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading