Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
67 changes: 67 additions & 0 deletions Dockerfile.test
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
# syntax=docker/dockerfile:1.7
# Test-only image: runs the full Rails/RSpec suite with every native lib the
# app loads at boot (libvips, libpdfium, onnxruntime, chromium for cuprite).
# Source code is mounted at runtime via docker-compose.test.yml — only the
# Gemfile is baked in so `bundle install` caches between runs.

FROM ruby:4.0.2-alpine AS pdfium

WORKDIR /download

RUN apk --no-cache add wget && \
wget -O pdfium-linux.tgz "https://github.com/bblanchon/pdfium-binaries/releases/latest/download/pdfium-linux-musl-$(uname -m | sed 's/x86_64/x64/;s/aarch64/arm64/').tgz" && \
mkdir -p /pdfium-linux && \
tar -xzf pdfium-linux.tgz -C /pdfium-linux

FROM ruby:4.0.2-alpine

ENV RAILS_ENV=test \
BUNDLE_WITHOUT="" \
LANG=C.UTF-8 \
TZ=UTC

WORKDIR /app

# System deps:
# - libpq / libpq-dev → pg gem
# - vips + vips-heif → image processing (ruby-vips FFI)
# - onnxruntime → field detection at boot
# - fontconfig + ttf-* → PDF rendering
# - build-base/git/yaml-dev → native gem compilation
#
# Capybara/cuprite system specs need chromium — add `chromium
# chromium-chromedriver` to this apk line if running `spec/system/*`.
RUN apk add --no-cache \
build-base \
git \
bash \
curl \
yaml-dev \
libpq \
libpq-dev \
vips \
vips-dev \
vips-heif \
redis \
fontconfig \
ttf-dejavu \
onnxruntime \
nodejs \
yarn \
tzdata

# libpdfium is not in Alpine packages — copy the prebuilt binary.
COPY --from=pdfium /pdfium-linux/lib/libpdfium.so /usr/lib/libpdfium.so

# Bundle deps. Copy only Gemfile/Gemfile.lock so this layer is cached while
# source code changes beneath it.
COPY Gemfile Gemfile.lock ./
RUN bundle config set --local without "" && \
bundle install --jobs 4 --retry 3

# Link onnxruntime.so into the onnxruntime gem's vendor dir (mirrors the
# production Dockerfile) so the gem finds it at runtime.
RUN ln -sf /usr/lib/libonnxruntime.so.1 \
"$(ruby -e "print Dir[Gem::Specification.find_by_name('onnxruntime').gem_dir + '/vendor/*.so'].first")"

CMD ["bundle", "exec", "rspec"]
3 changes: 2 additions & 1 deletion Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

source 'https://rubygems.org'

ruby '4.0.1'
ruby '4.0.2'

gem 'addressable'
gem 'arabic-letter-connector', require: false
Expand All @@ -15,6 +15,7 @@ gem 'csv', require: false
gem 'csv-safe', require: false
gem 'devise'
gem 'devise-two-factor'
gem 'doorkeeper', '~> 5.9'
gem 'dotenv', require: false
gem 'email_typo'
gem 'faraday'
Expand Down
5 changes: 4 additions & 1 deletion Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,8 @@ GEM
digest-crc (0.7.0)
rake (>= 12.0.0, < 14.0.0)
docile (1.4.1)
doorkeeper (5.9.0)
railties (>= 5)
dotenv (3.2.0)
drb (2.2.3)
email_typo (0.2.3)
Expand Down Expand Up @@ -614,6 +616,7 @@ DEPENDENCIES
debug
devise
devise-two-factor
doorkeeper (~> 5.9)
dotenv
email_typo
erb_lint
Expand Down Expand Up @@ -662,7 +665,7 @@ DEPENDENCIES
webmock

RUBY VERSION
ruby 4.0.1
ruby 4.0.2

BUNDLED WITH
4.0.3
28 changes: 22 additions & 6 deletions app/controllers/mcp_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,11 @@ def call
private

def authenticate_user!
render json: { error: 'Not authenticated' }, status: :unauthorized unless current_user
return if current_user

response.headers['WWW-Authenticate'] =
%(Bearer resource_metadata="#{request.base_url}/.well-known/oauth-protected-resource", error="invalid_token")
render json: { error: 'Not authenticated' }, status: :unauthorized
end

def verify_mcp_enabled!
Expand All @@ -43,16 +47,28 @@ def verify_mcp_enabled!
end

def current_user
@current_user ||= user_from_api_key
@current_user ||= user_from_oauth_token || user_from_mcp_token
end

def user_from_api_key
token = request.headers['Authorization'].to_s[/\ABearer\s+(.+)\z/, 1]
def user_from_oauth_token
return if bearer_token.blank?

access_token = Doorkeeper::AccessToken.by_token(bearer_token)
return if access_token.nil? || access_token.revoked? || access_token.expired?
return unless access_token.scopes.exists?('mcp')

User.active.find_by(id: access_token.resource_owner_id)
end

return if token.blank?
def user_from_mcp_token
return if bearer_token.blank?

sha256 = Digest::SHA256.hexdigest(token)
sha256 = Digest::SHA256.hexdigest(bearer_token)

User.joins(:mcp_tokens).active.find_by(mcp_tokens: { sha256:, archived_at: nil })
end

def bearer_token
@bearer_token ||= request.headers['Authorization'].to_s[/\ABearer\s+(.+)\z/, 1]
end
end
63 changes: 63 additions & 0 deletions app/controllers/oauth/register_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
# frozen_string_literal: true

module Oauth
class RegisterController < ActionController::API
THROTTLE_LIMIT = 20
THROTTLE_WINDOW = 1.hour

rescue_from JSON::ParserError do
render json: { error: 'invalid_client_metadata' }, status: :bad_request
end

def create
return render_error('rate_limited', :too_many_requests) if throttled?

body = JSON.parse(request.raw_post.presence || '{}')

redirect_uris = Array(body['redirect_uris']).map(&:to_s).reject(&:blank?)
return render_error('invalid_redirect_uri') if redirect_uris.empty?
return render_error('invalid_redirect_uri') unless redirect_uris.all? { |u| valid_redirect?(u) }

app = Doorkeeper::Application.create!(
name: body['client_name'].to_s.presence || "MCP client #{SecureRandom.hex(4)}",
redirect_uri: redirect_uris.join("\n"),
scopes: 'mcp',
confidential: false
)

render json: {
client_id: app.uid,
client_id_issued_at: app.created_at.to_i,
client_secret_expires_at: 0,
redirect_uris: redirect_uris,
grant_types: %w[authorization_code refresh_token],
response_types: %w[code],
token_endpoint_auth_method: 'none',
scope: 'mcp',
client_name: app.name
}, status: :created
end

private

def render_error(code, status = :bad_request)
render json: { error: code }, status: status
end

def valid_redirect?(uri_str)
uri = URI.parse(uri_str)
return true if uri.scheme == 'https'
return true if uri.scheme == 'http' && %w[localhost 127.0.0.1 ::1].include?(uri.host)

false
rescue URI::InvalidURIError
false
end

def throttled?
key = "dcr:#{request.ip}"
count = Rails.cache.increment(key, 1, expires_in: THROTTLE_WINDOW)
count && count > THROTTLE_LIMIT
end
end
end
8 changes: 8 additions & 0 deletions app/controllers/oauth/token_proxy_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
# frozen_string_literal: true

module Oauth
# Root-path alias so clients (Claude.ai web) that ignore discovery metadata
# and POST to /token still hit Doorkeeper's token endpoint.
class TokenProxyController < Doorkeeper::TokensController
end
end
28 changes: 28 additions & 0 deletions app/controllers/well_known_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# frozen_string_literal: true

class WellKnownController < ActionController::API
def authorization_server
base = request.base_url
render json: {
issuer: base,
authorization_endpoint: "#{base}/oauth/authorize",
token_endpoint: "#{base}/oauth/token",
registration_endpoint: "#{base}/register",
response_types_supported: %w[code],
grant_types_supported: %w[authorization_code refresh_token],
code_challenge_methods_supported: %w[S256],
token_endpoint_auth_methods_supported: %w[none],
scopes_supported: %w[mcp]
}
end

def protected_resource
base = request.base_url
render json: {
resource: "#{base}/mcp",
authorization_servers: [base],
scopes_supported: %w[mcp],
bearer_methods_supported: %w[header]
}
end
end
19 changes: 19 additions & 0 deletions app/jobs/oauth_application_sweeper_job.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# frozen_string_literal: true

# Sweeper job for abandoned Dynamic Client Registration applications.
#
# Scheduling: this repo has no internal cron (no sidekiq-cron / whenever).
# Schedule externally, e.g. weekly:
# bin/rails runner 'OauthApplicationSweeperJob.perform_later'
class OauthApplicationSweeperJob < ApplicationJob
queue_as :default

def perform
cutoff = 90.days.ago
live_app_ids = Doorkeeper::AccessToken.where(revoked_at: nil).select(:application_id)
Doorkeeper::Application
.where('created_at < ?', cutoff)
.where.not(id: live_app_ids)
.delete_all
end
end
4 changes: 4 additions & 0 deletions app/models/user.rb
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,10 @@ class User < ApplicationRecord
has_one :access_token, dependent: :destroy
has_many :access_tokens, dependent: :destroy
has_many :mcp_tokens, dependent: :destroy
has_many :oauth_access_grants, class_name: 'Doorkeeper::AccessGrant',
foreign_key: :resource_owner_id, dependent: :delete_all
has_many :oauth_access_tokens, class_name: 'Doorkeeper::AccessToken',
foreign_key: :resource_owner_id, dependent: :delete_all
has_many :templates, dependent: :destroy, foreign_key: :author_id, inverse_of: :author
has_many :template_folders, dependent: :destroy, foreign_key: :author_id, inverse_of: :author
has_many :user_configs, dependent: :destroy
Expand Down
3 changes: 3 additions & 0 deletions app/views/mcp_settings/index.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@
<%= t('mcp_server') %>
</h1>
<div class="flex flex-col md:flex-row gap-y-2 gap-x-4 md:items-center">
<%= link_to t('connected_apps', default: 'Connected apps'),
oauth_authorized_applications_path,
class: 'btn btn-ghost btn-md w-full md:w-fit' %>
<div class="tooltip">
<%= link_to new_settings_mcp_path, class: 'btn btn-primary btn-md gap-2 w-full md:w-fit', data: { turbo_frame: 'modal' } do %>
<%= svg_icon('plus', class: 'w-6 h-6') %>
Expand Down
60 changes: 60 additions & 0 deletions config/initializers/doorkeeper.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
# frozen_string_literal: true

Doorkeeper.configure do
orm :active_record

# Runs inside Warden context. Redirect to the Devise sign-in page if the
# visitor is not logged in, remembering where to return after auth.
resource_owner_authenticator do
if current_user
current_user
else
session[:user_return_to] = request.fullpath
redirect_to(main_app.new_user_session_url)
nil
end
end

resource_owner_from_credentials { nil } # no Resource Owner Password Credentials grant

# Doorkeeper's built-in controllers (Authorizations, TokenInfo, AuthorizedApps)
# inherit from this. Must be an HTML controller so the consent view renders.
base_controller 'ApplicationController'

grant_flows %w[authorization_code refresh_token]

# PKCE: S256 only; required for all non-confidential (public) clients.
pkce_code_challenge_methods %w[S256]
force_pkce

# Require HTTPS for redirect_uri except for loopback (OAuth 2.1 §8.4.2).
force_ssl_in_redirect_uri do |uri|
!%w[localhost 127.0.0.1 ::1].include?(uri.host)
end

default_scopes :mcp
optional_scopes :mcp

access_token_expires_in 1.hour
use_refresh_token

# Hash access-token and refresh-token secrets in the DB.
hash_token_secrets using: '::Doorkeeper::SecretStoring::Sha256Hash'

# Always show the consent screen.
skip_authorization { false }
end

# Doorkeeper's own controllers inherit ApplicationController which enables CanCan
# check_authorization. Exempt them — they have no CanCan subjects.
Rails.application.config.to_prepare do
%w[
Doorkeeper::AuthorizationsController
Doorkeeper::TokensController
Doorkeeper::TokenInfoController
Doorkeeper::AuthorizedApplicationsController
].each do |name|
klass = name.safe_constantize
klass.skip_authorization_check if klass && klass.respond_to?(:skip_authorization_check)
end
end
14 changes: 14 additions & 0 deletions config/routes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,20 @@
end
end

use_doorkeeper do
skip_controllers :applications
end

# Claude.ai web connector strips paths — expose root aliases for endpoints it
# will try to hit regardless of discovery metadata.
get '/authorize', to: redirect { |_p, req| "/oauth/authorize?#{req.query_string}" }
post '/token', to: 'oauth/token_proxy#create'
post '/register', to: 'oauth/register#create'

# Discovery metadata (RFC 8414 + RFC 9728). Must be at these exact paths.
get '/.well-known/oauth-authorization-server', to: 'well_known#authorization_server'
get '/.well-known/oauth-protected-resource', to: 'well_known#protected_resource'

match '/mcp', to: 'mcp#call', via: %i[get post]

get '/js/:filename', to: 'embed_scripts#show', as: :embed_script
Expand Down
Loading