administrate-mcp 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +61 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +267 -0
  5. data/app/controllers/administrate/mcp/json_rpc_controller.rb +83 -0
  6. data/app/controllers/administrate/mcp/o_auth_controller.rb +199 -0
  7. data/app/lib/administrate/mcp/actions.rb +170 -0
  8. data/app/lib/administrate/mcp/admin_dashboard_tool.rb +114 -0
  9. data/app/lib/administrate/mcp/authentication.rb +146 -0
  10. data/app/lib/administrate/mcp/base_tool.rb +93 -0
  11. data/app/lib/administrate/mcp/clean_old_feedbacks.rb +26 -0
  12. data/app/lib/administrate/mcp/dashboard_registry.rb +102 -0
  13. data/app/lib/administrate/mcp/fast_search.rb +47 -0
  14. data/app/lib/administrate/mcp/field_serializer.rb +284 -0
  15. data/app/lib/administrate/mcp/o_auth_service.rb +103 -0
  16. data/app/lib/administrate/mcp/report_improvement.rb +58 -0
  17. data/app/lib/administrate/mcp/server_builder.rb +70 -0
  18. data/app/lib/administrate/mcp/tools/admin_resource_list.rb +194 -0
  19. data/app/lib/administrate/mcp/tools/admin_resource_list_resources.rb +107 -0
  20. data/app/lib/administrate/mcp/tools/admin_resource_show.rb +130 -0
  21. data/app/lib/administrate/mcp/tools/report_improvement.rb +43 -0
  22. data/app/lib/administrate/mcp/tools/sidekiq_retries.rb +50 -0
  23. data/app/lib/administrate/mcp/tools/sidekiq_stats.rb +75 -0
  24. data/app/models/administrate/mcp/api_key.rb +59 -0
  25. data/app/models/administrate/mcp/application_record.rb +23 -0
  26. data/app/models/administrate/mcp/feedback.rb +20 -0
  27. data/app/models/administrate/mcp/o_auth_access_grant.rb +76 -0
  28. data/app/models/administrate/mcp/o_auth_access_token.rb +84 -0
  29. data/app/models/administrate/mcp/o_auth_application.rb +64 -0
  30. data/app/views/administrate/mcp/o_auth/authorize.html.erb +63 -0
  31. data/config/routes.rb +6 -0
  32. data/db/migrate/20260101000001_create_administrate_model_context_protocol_api_keys.rb +20 -0
  33. data/db/migrate/20260101000002_create_administrate_model_context_protocol_feedbacks.rb +19 -0
  34. data/db/migrate/20260101000003_create_administrate_model_context_protocol_authorization_tables.rb +52 -0
  35. data/docs/admin-integration.md +56 -0
  36. data/docs/authentication.md +116 -0
  37. data/docs/configuration.md +220 -0
  38. data/docs/dashboards.md +50 -0
  39. data/docs/development.md +19 -0
  40. data/docs/oauth.md +54 -0
  41. data/docs/routes.md +30 -0
  42. data/lib/administrate/mcp/authorization/base.rb +45 -0
  43. data/lib/administrate/mcp/authorization/permissive.rb +13 -0
  44. data/lib/administrate/mcp/authorization/pundit.rb +32 -0
  45. data/lib/administrate/mcp/cloudflare_access.rb +140 -0
  46. data/lib/administrate/mcp/configuration.rb +156 -0
  47. data/lib/administrate/mcp/dashboard_extension.rb +25 -0
  48. data/lib/administrate/mcp/engine.rb +21 -0
  49. data/lib/administrate/mcp/errors.rb +21 -0
  50. data/lib/administrate/mcp/loopback_uri.rb +18 -0
  51. data/lib/administrate/mcp/rack_attack.rb +39 -0
  52. data/lib/administrate/mcp/routes.rb +48 -0
  53. data/lib/administrate/mcp/version.rb +7 -0
  54. data/lib/administrate/mcp.rb +35 -0
  55. data/lib/administrate-mcp.rb +3 -0
  56. metadata +160 -0
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Administrate
4
+ module MCP
5
+ # Improvement feedback submitted by MCP users.
6
+ class Feedback < ApplicationRecord
7
+ self.table_name = 'administrate_mcp_feedbacks'
8
+
9
+ belongs_to_admin
10
+ belongs_to :api_key, class_name: 'Administrate::MCP::ApiKey', optional: true, inverse_of: :feedbacks
11
+
12
+ enum :category,
13
+ { description: 0, missing_filter: 1, missing_field: 2, missing_resource: 3, serialization: 4, other: 5 }
14
+ enum :status, { pending: 0, accepted: 1, rejected: 2, shipped: 3 }, prefix: true
15
+
16
+ validates :suggestion, presence: true
17
+ validates :category, presence: true
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Administrate
4
+ module MCP
5
+ # Short-lived authorization code for the MCP OAuth 2.1 flow (10-minute TTL).
6
+ class OAuthAccessGrant < ApplicationRecord
7
+ self.table_name = 'administrate_mcp_oauth_access_grants'
8
+
9
+ DEFAULT_EXPIRES_IN = 10.minutes.to_i
10
+
11
+ belongs_to_admin
12
+ belongs_to :application, class_name: 'Administrate::MCP::OAuthApplication', inverse_of: :access_grants
13
+
14
+ validates :token_digest, presence: true, uniqueness: true
15
+ validates :redirect_uri, presence: true
16
+ validates :code_challenge, presence: true
17
+ validates :expires_in, presence: true
18
+
19
+ attr_reader :plaintext_token
20
+
21
+ class << self
22
+ def generate_token
23
+ SecureRandom.hex(32)
24
+ end
25
+
26
+ def digest(plaintext)
27
+ Digest::SHA256.hexdigest(plaintext)
28
+ end
29
+
30
+ def find_by_token(plaintext)
31
+ find_by(token_digest: digest(plaintext))
32
+ end
33
+
34
+ def issue(
35
+ admin:, application:, redirect_uri:, code_challenge:, code_challenge_method:, scopes:,
36
+ expires_in: DEFAULT_EXPIRES_IN
37
+ )
38
+ plaintext_token = generate_token
39
+
40
+ create!(
41
+ admin:,
42
+ application:,
43
+ token_digest: digest(plaintext_token),
44
+ redirect_uri:,
45
+ code_challenge:,
46
+ code_challenge_method:,
47
+ scopes:,
48
+ expires_in:
49
+ ).tap { |grant| grant.instance_variable_set(:@plaintext_token, plaintext_token) }
50
+ end
51
+ end
52
+
53
+ def reload(...)
54
+ @plaintext_token = nil
55
+ super
56
+ end
57
+
58
+ def expired?
59
+ created_at + expires_in.seconds < Time.current
60
+ end
61
+
62
+ def revoked?
63
+ revoked_at.present?
64
+ end
65
+
66
+ def revoke!
67
+ update!(revoked_at: Time.current)
68
+ end
69
+
70
+ def verify_code_challenge(verifier)
71
+ digest = Base64.urlsafe_encode64(Digest::SHA256.digest(verifier), padding: false)
72
+ ActiveSupport::SecurityUtils.secure_compare(digest, code_challenge)
73
+ end
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Administrate
4
+ module MCP
5
+ # MCP OAuth 2.1 access token (1-week TTL).
6
+ class OAuthAccessToken < ApplicationRecord
7
+ self.table_name = 'administrate_mcp_oauth_access_tokens'
8
+
9
+ DEFAULT_EXPIRES_IN = 1.week.to_i
10
+
11
+ belongs_to_admin
12
+ belongs_to :application, class_name: 'Administrate::MCP::OAuthApplication', inverse_of: :access_tokens
13
+
14
+ validates :token_digest, presence: true, uniqueness: true
15
+ validates :refresh_token_digest, presence: true, uniqueness: true
16
+ validates :expires_in, presence: true
17
+
18
+ scope :active, -> { where(revoked_at: nil) }
19
+
20
+ attr_reader :plaintext_token, :plaintext_refresh_token
21
+
22
+ class << self
23
+ def generate_token
24
+ SecureRandom.hex(32)
25
+ end
26
+
27
+ def generate_refresh_token
28
+ SecureRandom.hex(32)
29
+ end
30
+
31
+ def digest(plaintext)
32
+ Digest::SHA256.hexdigest(plaintext)
33
+ end
34
+
35
+ def find_by_token(plaintext)
36
+ find_by(token_digest: digest(plaintext))
37
+ end
38
+
39
+ def find_by_refresh_token(plaintext)
40
+ find_by(refresh_token_digest: digest(plaintext))
41
+ end
42
+
43
+ def issue(admin:, application:, scopes:, expires_in: DEFAULT_EXPIRES_IN)
44
+ plaintext_token = generate_token
45
+ plaintext_refresh_token = generate_refresh_token
46
+
47
+ create!(
48
+ admin:,
49
+ application:,
50
+ token_digest: digest(plaintext_token),
51
+ refresh_token_digest: digest(plaintext_refresh_token),
52
+ expires_in:,
53
+ scopes:
54
+ ).tap do |token|
55
+ token.instance_variable_set(:@plaintext_token, plaintext_token)
56
+ token.instance_variable_set(:@plaintext_refresh_token, plaintext_refresh_token)
57
+ end
58
+ end
59
+ end
60
+
61
+ def reload(...)
62
+ @plaintext_token = nil
63
+ @plaintext_refresh_token = nil
64
+ super
65
+ end
66
+
67
+ def expired?
68
+ created_at + expires_in.seconds < Time.current
69
+ end
70
+
71
+ def revoked?
72
+ revoked_at.present?
73
+ end
74
+
75
+ def accessible?
76
+ !revoked? && !expired?
77
+ end
78
+
79
+ def revoke!
80
+ update!(revoked_at: Time.current)
81
+ end
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Administrate
4
+ module MCP
5
+ # OAuth 2.1 client application registered via Dynamic Client Registration.
6
+ class OAuthApplication < ApplicationRecord
7
+ self.table_name = 'administrate_mcp_oauth_applications'
8
+
9
+ MAX_REDIRECT_URIS = 5
10
+
11
+ has_many :access_grants,
12
+ class_name: 'Administrate::MCP::OAuthAccessGrant',
13
+ foreign_key: :application_id,
14
+ dependent: :destroy,
15
+ inverse_of: :application
16
+ has_many :access_tokens,
17
+ class_name: 'Administrate::MCP::OAuthAccessToken',
18
+ foreign_key: :application_id,
19
+ dependent: :destroy,
20
+ inverse_of: :application
21
+
22
+ validates :client_id, presence: true, uniqueness: true
23
+ validates :name, presence: true, length: { maximum: 255 }
24
+ validates :redirect_uris, presence: true
25
+ validate :validate_redirect_uris
26
+
27
+ before_validation :sanitize_name
28
+
29
+ def self.generate_client_id
30
+ SecureRandom.hex(16)
31
+ end
32
+
33
+ private
34
+
35
+ def sanitize_name
36
+ self.name = ActionController::Base.helpers.strip_tags(name)&.strip if name.present?
37
+ end
38
+
39
+ def validate_redirect_uris
40
+ return if redirect_uris.blank?
41
+
42
+ if redirect_uris.length > MAX_REDIRECT_URIS
43
+ errors.add(:redirect_uris, "must not exceed #{MAX_REDIRECT_URIS} URIs")
44
+ return
45
+ end
46
+
47
+ redirect_uris.each { |uri_string| validate_single_redirect_uri(uri_string) }
48
+ end
49
+
50
+ def validate_single_redirect_uri(uri_string)
51
+ uri = URI.parse(uri_string)
52
+ return if uri.scheme == 'http' && loopback_allowed?(uri.hostname)
53
+
54
+ errors.add(:redirect_uris, "#{uri_string} must use https scheme") unless uri.scheme == 'https'
55
+ rescue URI::InvalidURIError
56
+ errors.add(:redirect_uris, "#{uri_string} is not a valid URI")
57
+ end
58
+
59
+ def loopback_allowed?(host)
60
+ Administrate::MCP.config.allow_localhost_redirects && LoopbackUri.localhost?(host)
61
+ end
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,63 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <title>Authorize MCP access</title>
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <style>
7
+ body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; background: #f5f5f5; display: flex; justify-content: center; align-items: center; min-height: 100vh; margin: 0; }
8
+ .card { background: #fff; border-radius: 8px; box-shadow: 0 2px 8px rgba(0,0,0,0.1); padding: 2rem; max-width: 400px; width: 100%; }
9
+ h1 { font-size: 1.25rem; margin: 0 0 1rem; }
10
+ p { color: #555; line-height: 1.5; }
11
+ .actions { display: flex; gap: 0.75rem; margin-top: 1.5rem; }
12
+ button { flex: 1; padding: 0.75rem; border-radius: 6px; font-size: 1rem; cursor: pointer; border: 1px solid #ddd; }
13
+ .approve { background: #2563eb; color: #fff; border-color: #2563eb; }
14
+ .approve:hover { background: #1d4ed8; }
15
+ .deny { background: #fff; }
16
+ .deny:hover { background: #f5f5f5; }
17
+ .details { margin: 1.25rem 0 0; border: 1px solid #eee; border-radius: 6px; padding: 0.75rem 1rem; background: #fafafa; }
18
+ .details dt { font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.03em; color: #888; margin-top: 0.5rem; }
19
+ .details dt:first-child { margin-top: 0; }
20
+ .details dd { margin: 0.2rem 0 0; color: #222; word-break: break-all; }
21
+ .details .host { font-weight: 600; }
22
+ .details code { background: #eee; border-radius: 4px; padding: 0.05rem 0.35rem; font-size: 0.85rem; }
23
+ .scope-write { color: #b45309; font-weight: 600; }
24
+ </style>
25
+ </head>
26
+ <body>
27
+ <div class="card">
28
+ <h1>Authorize MCP access</h1>
29
+ <p><strong><%= @application.name %></strong> is requesting access to the admin MCP server on your behalf.</p>
30
+ <dl class="details">
31
+ <dt>Redirects to</dt>
32
+ <dd>
33
+ <span class="host"><%= @redirect_host || 'unknown host' %></span>
34
+ <div><%= @redirect_uri %></div>
35
+ </dd>
36
+ <dt>Access requested</dt>
37
+ <dd>
38
+ <% if @scopes.empty? %>
39
+ Read-only
40
+ <% else %>
41
+ <% @scopes.each do |scope| %>
42
+ <code class="<%= 'scope-write' if scope == 'write' %>"><%= scope %></code>
43
+ <% end %>
44
+ <% end %>
45
+ </dd>
46
+ </dl>
47
+ <p>Only authorize if you recognize this application and the destination above.</p>
48
+ <%= form_tag(request.fullpath, method: :post) do %>
49
+ <%= hidden_field_tag :client_id, params[:client_id] %>
50
+ <%= hidden_field_tag :redirect_uri, params[:redirect_uri] %>
51
+ <%= hidden_field_tag :state, params[:state] %>
52
+ <%= hidden_field_tag :code_challenge, params[:code_challenge] %>
53
+ <%= hidden_field_tag :code_challenge_method, params[:code_challenge_method] %>
54
+ <%= hidden_field_tag :scope, params[:scope] %>
55
+ <%= hidden_field_tag :response_type, params[:response_type] %>
56
+ <div class="actions">
57
+ <button type="submit" name="deny" value="1" class="deny">Deny</button>
58
+ <button type="submit" class="approve">Authorize</button>
59
+ </div>
60
+ <% end %>
61
+ </div>
62
+ </body>
63
+ </html>
data/config/routes.rb ADDED
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ Administrate::MCP::Engine.routes.draw do
4
+ Administrate::MCP::Routes.draw_mcp_origin(self)
5
+ Administrate::MCP::Routes.draw_admin_origin(self, path: '/oauth/authorize')
6
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ class CreateAdministrateModelContextProtocolApiKeys < ActiveRecord::Migration[8.1]
4
+ def change
5
+ create_table :administrate_mcp_api_keys, id: :uuid, if_not_exists: true do |t|
6
+ t.uuid :admin_id, null: false
7
+ t.string :token_digest, null: false
8
+ t.string :token_prefix, null: false
9
+ t.string :name, null: false
10
+ t.boolean :write_access, null: false, default: false
11
+ t.datetime :last_used_at
12
+ t.datetime :revoked_at
13
+
14
+ t.timestamps
15
+ end
16
+
17
+ add_index :administrate_mcp_api_keys, :admin_id, if_not_exists: true
18
+ add_index :administrate_mcp_api_keys, :token_digest, unique: true, if_not_exists: true
19
+ end
20
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ class CreateAdministrateModelContextProtocolFeedbacks < ActiveRecord::Migration[8.1]
4
+ def change
5
+ create_table :administrate_mcp_feedbacks, id: :uuid, if_not_exists: true do |t|
6
+ t.uuid :admin_id
7
+ t.uuid :api_key_id
8
+ t.integer :category, null: false
9
+ t.string :resource_name
10
+ t.text :suggestion, null: false
11
+ t.integer :status, null: false, default: 0
12
+
13
+ t.timestamps
14
+ end
15
+
16
+ add_index :administrate_mcp_feedbacks, :admin_id, if_not_exists: true
17
+ add_index :administrate_mcp_feedbacks, :api_key_id, if_not_exists: true
18
+ end
19
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ class CreateAdministrateModelContextProtocolAuthorizationTables < ActiveRecord::Migration[8.1]
4
+ def change
5
+ create_table :administrate_mcp_oauth_applications, id: :uuid, if_not_exists: true do |t|
6
+ t.string :client_id, null: false
7
+ t.text :client_secret_digest
8
+ t.text :redirect_uris, array: true, default: [], null: false
9
+ t.string :grant_types, array: true, default: ['authorization_code'], null: false
10
+ t.string :name, null: false
11
+
12
+ t.timestamps
13
+ end
14
+
15
+ add_index :administrate_mcp_oauth_applications, :client_id, unique: true, if_not_exists: true
16
+
17
+ create_table :administrate_mcp_oauth_access_grants, id: :uuid, if_not_exists: true do |t|
18
+ t.uuid :admin_id, null: false
19
+ t.uuid :application_id, null: false
20
+ t.string :token_digest, null: false
21
+ t.integer :expires_in, null: false
22
+ t.text :redirect_uri, null: false
23
+ t.string :scopes, default: ''
24
+ t.string :code_challenge, null: false
25
+ t.string :code_challenge_method, null: false, default: 'S256'
26
+ t.datetime :revoked_at
27
+
28
+ t.datetime :created_at, null: false
29
+ end
30
+
31
+ add_index :administrate_mcp_oauth_access_grants, :admin_id, if_not_exists: true
32
+ add_index :administrate_mcp_oauth_access_grants, :application_id, if_not_exists: true
33
+ add_index :administrate_mcp_oauth_access_grants, :token_digest, unique: true, if_not_exists: true
34
+
35
+ create_table :administrate_mcp_oauth_access_tokens, id: :uuid, if_not_exists: true do |t|
36
+ t.uuid :admin_id, null: false
37
+ t.uuid :application_id, null: false
38
+ t.string :token_digest, null: false
39
+ t.string :refresh_token_digest, null: false
40
+ t.integer :expires_in, null: false
41
+ t.string :scopes, default: ''
42
+ t.datetime :revoked_at
43
+
44
+ t.datetime :created_at, null: false
45
+ end
46
+
47
+ add_index :administrate_mcp_oauth_access_tokens, :admin_id, if_not_exists: true
48
+ add_index :administrate_mcp_oauth_access_tokens, :application_id, if_not_exists: true
49
+ add_index :administrate_mcp_oauth_access_tokens, :token_digest, unique: true, if_not_exists: true
50
+ add_index :administrate_mcp_oauth_access_tokens, :refresh_token_digest, unique: true, if_not_exists: true
51
+ end
52
+ end
@@ -0,0 +1,56 @@
1
+ # Admin integration
2
+
3
+ [Back to README](../README.md)
4
+
5
+ ## Exposing the engine's own tables in your admin
6
+
7
+ The engine's records keep their namespace in `model_name`, so `Administrate::MCP::ApiKey` routes as
8
+ `administrate_mcp_api_keys` and never shadows a resource of your own called `api_keys`. Administrate
9
+ needs three things to manage a namespaced model:
10
+
11
+ ```ruby
12
+ # config/routes.rb, inside your admin namespace
13
+ namespace :admin do
14
+ resources :administrate_mcp_api_keys
15
+ resources :administrate_mcp_feedbacks, only: %i[index show destroy]
16
+ end
17
+
18
+ # app/dashboards/administrate_mcp/api_key_dashboard.rb
19
+ module AdministrateMcp
20
+ class ApiKeyDashboard < Administrate::BaseDashboard
21
+ ATTRIBUTE_TYPES = { id: Field::String, name: Field::String, admin: Field::BelongsTo }.freeze
22
+ COLLECTION_ATTRIBUTES = %i[id name].freeze
23
+ SHOW_PAGE_ATTRIBUTES = %i[id name admin].freeze
24
+ FORM_ATTRIBUTES = %i[name].freeze
25
+
26
+ def self.model
27
+ Administrate::MCP::ApiKey
28
+ end
29
+ end
30
+ end
31
+
32
+ # app/controllers/admin/administrate_mcp_api_keys_controller.rb
33
+ module Admin
34
+ class AdministrateMcpApiKeysController < Admin::ApplicationController
35
+ def resource_class = Administrate::MCP::ApiKey
36
+ def dashboard_class = AdministrateMcp::ApiKeyDashboard
37
+ end
38
+ end
39
+ ```
40
+
41
+ The MCP registry finds the dashboard through `self.model`, registers it as
42
+ `administrate/mcp/api_key`, and builds record URLs from the namespaced route key.
43
+
44
+ ## Customising the consent screen
45
+
46
+ Override `app/views/administrate/mcp/o_auth/authorize.html.erb` in your application. The template
47
+ has `@application`, `@redirect_uri`, `@redirect_host` and `@scopes`.
48
+
49
+ ## Feedback housekeeping
50
+
51
+ `Administrate::MCP::CleanOldFeedbacks.call(till: 2.months.ago)` deletes one batch of 10,000 and
52
+ reports `more?`; schedule it however your app schedules work.
53
+
54
+ `ReportImprovement`, which backs the `report_mcp_improvement` tool, is a plain object in the same
55
+ style: it returns a result struct and does no scheduling of its own. Both services leave the decision
56
+ of how and when to run them to the host application.
@@ -0,0 +1,116 @@
1
+ # Authentication
2
+
3
+ [Back to README](../README.md)
4
+
5
+ A request is authenticated in this order: an API key, then, if `oauth` is enabled, the engine's own
6
+ OAuth access token, then `identity_fallback`. The first credential the request presents that the
7
+ engine recognises settles the call; `identity_fallback` is only consulted when nothing the engine
8
+ issued matched.
9
+
10
+ **API keys.** Create one yourself and hand the plaintext to the user once:
11
+
12
+ ```ruby
13
+ token = Administrate::MCP::ApiKey.generate_token
14
+ Administrate::MCP::ApiKey.create!(admin:, name: 'Laptop', token_digest: Administrate::MCP::ApiKey.digest_token(token),
15
+ token_prefix: token[0, 13])
16
+ ```
17
+
18
+ Only the digest is stored. Keys are read-only unless `write_access` is set.
19
+
20
+ If you are migrating keys that were issued before you adopted this gem, set `api_key_token_prefix`
21
+ to the prefix those keys already carry. The plaintext is not recoverable, only its digest and the
22
+ first 13 characters are stored in `token_prefix`, so a changed prefix makes every existing key stop
23
+ matching, and the holders have to be issued new ones.
24
+
25
+ **OAuth 2.1.** Clients register themselves, send the user to the consent screen on the admin origin,
26
+ and exchange the code with PKCE. Access tokens last a week, authorization codes ten minutes, and
27
+ refreshing revokes the old token. See [OAuth](oauth.md) for turning it off.
28
+
29
+ ## Identity fallback
30
+
31
+ Some deployments authenticate the caller before the request reaches Rails. Cloudflare Access managed
32
+ OAuth is the common case: Access resolves the client's bearer token at its own edge and forwards an
33
+ assertion, so the application only ever sees a token no row of ours matches. `identity_fallback` is
34
+ where you turn that assertion into an admin:
35
+
36
+ ```ruby
37
+ c.identity_fallback = lambda do |request|
38
+ email = CloudflareAccess.email(request) # your own verification of the Access JWT
39
+ next nil unless email
40
+
41
+ admin = Administrator.find_by(email:)
42
+ unless admin
43
+ raise Administrate::MCP::Authentication::ExternalIdentityError.new(
44
+ "No admin account for #{email}",
45
+ auth_error_type: 'cloudflare_access'
46
+ )
47
+ end
48
+
49
+ Administrate::MCP::Authentication::Identity.new(admin:, scopes: ['write'])
50
+ end
51
+ ```
52
+
53
+ It is consulted only when nothing the engine issued matched: an absent `Authorization` header, or a
54
+ bearer token that is neither an API key by prefix nor a row in the OAuth tokens table. A key or token
55
+ that _is_ recognised and then fails, revoked, expired, unknown digest, raises as before and never
56
+ reaches the fallback, so a revoked credential cannot be laundered into an Access identity. Returning
57
+ nil leaves that refusal in place. Raising `ExternalIdentityError` is how you say "I know who
58
+ this is and they have no account": it answers 401 with the `auth_error_type` you pass as
59
+ `X-Auth-Error` and JSON-RPC code `-32001`. `admin_active` applies to fallback identities too.
60
+
61
+ A credential outlives the admin who holds it: revoking someone's admin role, deactivating or
62
+ anonymizing their account does nothing to a key or token they were already issued, and a tool such as
63
+ `sidekiq_retries` never consults the authorization adapter. Set `admin_active` so every call
64
+ re-checks the person, not only the credential.
65
+
66
+ The JSON-RPC endpoint authenticates by bearer token only and never reads the session cookie: hosts
67
+ routinely share a session across sibling subdomains, and a browser signed into the admin UI must not
68
+ thereby be able to drive the protocol endpoint.
69
+
70
+ ### Cloudflare Access
71
+
72
+ Edge-managed OAuth in front of an MCP server is a common deployment, so one verifier for it ships
73
+ with the gem. `Administrate::MCP::CloudflareAccess` implements the `identity_fallback` contract shown
74
+ above: a callable taking the request, returning an `Authentication::Identity` or nil, and raising
75
+ `ExternalIdentityError` to signal an identity Access has asserted but that has no admin account. Use
76
+ it as a template for any other provider.
77
+
78
+ It answers `call(request)`, so it can be assigned to `identity_fallback` directly, in the same
79
+ `config/initializers/administrate_mcp.rb` as the rest of your configuration: no `to_prepare` wrapper
80
+ and no deferred reference, the class is requirable the moment the gem is. It lives in `lib/`, not
81
+ `app/`, alongside `Routes` and `RackAttack`, so an initializer can name it before autoloading is
82
+ available.
83
+
84
+ ```ruby
85
+ Administrate::MCP.configure do |c|
86
+ c.oauth = false
87
+ c.identity_fallback = Administrate::MCP::CloudflareAccess.new(
88
+ team_domain: -> { ENV.fetch('CLOUDFLARE_ACCESS_TEAM_DOMAIN', nil) },
89
+ audience: -> { ENV.fetch('CLOUDFLARE_ACCESS_MCP_AUD', nil) },
90
+ find_admin: ->(email) { Administrator.find_by(email:) },
91
+ scopes_for: ->(admin) { admin.mcp_write_access? ? ['write'] : [] }
92
+ )
93
+ end
94
+ ```
95
+
96
+ It reads the `Cf-Access-Jwt-Assertion` header, verifies the RS256 signature against the JWKS at
97
+ `<team_domain>/cdn-cgi/access/certs` (cached an hour, refetched once when a key id is unknown, which
98
+ is what a key rotation looks like), and checks the issuer and audience. `find_admin` receives the
99
+ lowercased email and `scopes_for` the admin it returned; returning no admin raises
100
+ `ExternalIdentityError` with `X-Auth-Error: cloudflare_access`.
101
+
102
+ `team_domain` and `audience` each take a value or a callable. Pass lambdas, as above. The object is
103
+ built once, in an initializer, and reads these two settings again on every call rather than at
104
+ construction: a plain `ENV.fetch` at construction time would be read once at boot, and in an
105
+ environment where those variables are set later, or not set at all, the object would be permanently
106
+ unconfigured and would quietly refuse every request. A lambda re-read on each call lets the settings
107
+ arrive after boot, and lets a spec change them.
108
+
109
+ A blank `team_domain` or `audience` means the verifier accepts **nothing**. It returns nil for every
110
+ assertion without calling Cloudflare; it does not fall through to an unverified one, and it does not
111
+ skip the audience check. Configuring it everywhere and enabling it per environment is therefore safe,
112
+ but so is getting it wrong: a typo in the audience variable refuses all callers rather than admitting
113
+ assertions minted for some other Access application.
114
+
115
+ Access must be in front of the JSON-RPC origin for this to mean anything: the assertion is only
116
+ trustworthy because nothing can reach the application without passing through Access.