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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +61 -0
- data/LICENSE.txt +21 -0
- data/README.md +267 -0
- data/app/controllers/administrate/mcp/json_rpc_controller.rb +83 -0
- data/app/controllers/administrate/mcp/o_auth_controller.rb +199 -0
- data/app/lib/administrate/mcp/actions.rb +170 -0
- data/app/lib/administrate/mcp/admin_dashboard_tool.rb +114 -0
- data/app/lib/administrate/mcp/authentication.rb +146 -0
- data/app/lib/administrate/mcp/base_tool.rb +93 -0
- data/app/lib/administrate/mcp/clean_old_feedbacks.rb +26 -0
- data/app/lib/administrate/mcp/dashboard_registry.rb +102 -0
- data/app/lib/administrate/mcp/fast_search.rb +47 -0
- data/app/lib/administrate/mcp/field_serializer.rb +284 -0
- data/app/lib/administrate/mcp/o_auth_service.rb +103 -0
- data/app/lib/administrate/mcp/report_improvement.rb +58 -0
- data/app/lib/administrate/mcp/server_builder.rb +70 -0
- data/app/lib/administrate/mcp/tools/admin_resource_list.rb +194 -0
- data/app/lib/administrate/mcp/tools/admin_resource_list_resources.rb +107 -0
- data/app/lib/administrate/mcp/tools/admin_resource_show.rb +130 -0
- data/app/lib/administrate/mcp/tools/report_improvement.rb +43 -0
- data/app/lib/administrate/mcp/tools/sidekiq_retries.rb +50 -0
- data/app/lib/administrate/mcp/tools/sidekiq_stats.rb +75 -0
- data/app/models/administrate/mcp/api_key.rb +59 -0
- data/app/models/administrate/mcp/application_record.rb +23 -0
- data/app/models/administrate/mcp/feedback.rb +20 -0
- data/app/models/administrate/mcp/o_auth_access_grant.rb +76 -0
- data/app/models/administrate/mcp/o_auth_access_token.rb +84 -0
- data/app/models/administrate/mcp/o_auth_application.rb +64 -0
- data/app/views/administrate/mcp/o_auth/authorize.html.erb +63 -0
- data/config/routes.rb +6 -0
- data/db/migrate/20260101000001_create_administrate_model_context_protocol_api_keys.rb +20 -0
- data/db/migrate/20260101000002_create_administrate_model_context_protocol_feedbacks.rb +19 -0
- data/db/migrate/20260101000003_create_administrate_model_context_protocol_authorization_tables.rb +52 -0
- data/docs/admin-integration.md +56 -0
- data/docs/authentication.md +116 -0
- data/docs/configuration.md +220 -0
- data/docs/dashboards.md +50 -0
- data/docs/development.md +19 -0
- data/docs/oauth.md +54 -0
- data/docs/routes.md +30 -0
- data/lib/administrate/mcp/authorization/base.rb +45 -0
- data/lib/administrate/mcp/authorization/permissive.rb +13 -0
- data/lib/administrate/mcp/authorization/pundit.rb +32 -0
- data/lib/administrate/mcp/cloudflare_access.rb +140 -0
- data/lib/administrate/mcp/configuration.rb +156 -0
- data/lib/administrate/mcp/dashboard_extension.rb +25 -0
- data/lib/administrate/mcp/engine.rb +21 -0
- data/lib/administrate/mcp/errors.rb +21 -0
- data/lib/administrate/mcp/loopback_uri.rb +18 -0
- data/lib/administrate/mcp/rack_attack.rb +39 -0
- data/lib/administrate/mcp/routes.rb +48 -0
- data/lib/administrate/mcp/version.rb +7 -0
- data/lib/administrate/mcp.rb +35 -0
- data/lib/administrate-mcp.rb +3 -0
- 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,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
|
data/db/migrate/20260101000003_create_administrate_model_context_protocol_authorization_tables.rb
ADDED
|
@@ -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.
|