administrate-mcp 0.1.0 → 0.3.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 +4 -4
- data/CHANGELOG.md +63 -1
- data/README.md +51 -7
- data/app/controllers/concerns/administrate/mcp/api_keys_admin.rb +93 -0
- data/app/controllers/concerns/administrate/mcp/feedbacks_admin.rb +25 -0
- data/app/controllers/concerns/administrate/mcp/resource_controller.rb +44 -0
- data/app/dashboards/administrate/mcp/api_key_dashboard.rb +53 -0
- data/app/dashboards/administrate/mcp/feedback_dashboard.rb +43 -0
- data/app/lib/administrate/mcp/admin_dashboard_tool.rb +49 -3
- data/app/lib/administrate/mcp/base_tool.rb +3 -2
- data/app/lib/administrate/mcp/feedback_categories.rb +19 -0
- data/app/lib/administrate/mcp/feedback_report.rb +11 -0
- data/app/lib/administrate/mcp/report_improvement.rb +22 -11
- data/app/lib/administrate/mcp/resource_resolver.rb +29 -0
- data/app/lib/administrate/mcp/server_builder.rb +8 -2
- data/app/lib/administrate/mcp/tools/admin_resource_list.rb +2 -6
- data/app/lib/administrate/mcp/tools/admin_resource_list_resources.rb +3 -6
- data/app/lib/administrate/mcp/tools/admin_resource_show.rb +3 -6
- data/app/lib/administrate/mcp/tools/report_improvement.rb +5 -2
- data/app/models/administrate/mcp/application_record.rb +4 -1
- data/app/models/administrate/mcp/feedback.rb +1 -2
- data/db/migrate/20260101000001_create_administrate_model_context_protocol_api_keys.rb +4 -2
- data/db/migrate/20260101000002_create_administrate_model_context_protocol_feedbacks.rb +4 -2
- data/db/migrate/20260101000003_create_administrate_model_context_protocol_authorization_tables.rb +6 -4
- data/docs/admin-integration.md +108 -16
- data/docs/configuration.md +34 -5
- data/lib/administrate/mcp/authorization/base.rb +7 -1
- data/lib/administrate/mcp/authorization/pundit.rb +1 -1
- data/lib/administrate/mcp/configuration.rb +7 -1
- data/lib/administrate/mcp/errors.rb +6 -0
- data/lib/administrate/mcp/version.rb +1 -1
- metadata +9 -1
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Administrate
|
|
4
|
+
module MCP
|
|
5
|
+
# Administrate derives the model from the controller path, which cannot name a class living
|
|
6
|
+
# inside the engine's namespace. This resolver takes the model from the controller instead, and
|
|
7
|
+
# leaves the dashboard to Administrate: a dashboard's constant name follows the host's own
|
|
8
|
+
# inflections, which only the host can spell.
|
|
9
|
+
class ResourceResolver < Administrate::ResourceResolver
|
|
10
|
+
attr_reader :resource_class
|
|
11
|
+
|
|
12
|
+
def initialize(controller_path, resource_class:, dashboard_class: nil)
|
|
13
|
+
super(controller_path)
|
|
14
|
+
@resource_class = resource_class
|
|
15
|
+
@dashboard_class = dashboard_class
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def dashboard_class
|
|
19
|
+
@dashboard_class || super
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# The form builder names its parameters after the record, so the controller has to read them
|
|
23
|
+
# under the same key whatever path the host mounted it at.
|
|
24
|
+
def resource_name
|
|
25
|
+
resource_class.model_name.param_key.to_sym
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -8,9 +8,10 @@ module Administrate
|
|
|
8
8
|
Administrate::MCP::Tools::AdminResourceList
|
|
9
9
|
Administrate::MCP::Tools::AdminResourceShow
|
|
10
10
|
Administrate::MCP::Tools::AdminResourceListResources
|
|
11
|
-
Administrate::MCP::Tools::ReportImprovement
|
|
12
11
|
].freeze
|
|
13
12
|
|
|
13
|
+
REPORT_IMPROVEMENT_TOOL = 'Administrate::MCP::Tools::ReportImprovement'
|
|
14
|
+
|
|
14
15
|
SIDEKIQ_RETRIES_TOOL = 'Administrate::MCP::Tools::SidekiqRetries'
|
|
15
16
|
|
|
16
17
|
SIDEKIQ_STATS_TOOL = 'Administrate::MCP::Tools::SidekiqStats'
|
|
@@ -34,7 +35,11 @@ module Administrate
|
|
|
34
35
|
# The host's own route constraints already restrict which Host values reach the
|
|
35
36
|
# controller, and this is not loopback-bound. Passing the incoming request's own host
|
|
36
37
|
# back as `allowed_hosts:` would just validate the Host header against itself.
|
|
37
|
-
dns_rebinding_protection: false
|
|
38
|
+
dns_rebinding_protection: false,
|
|
39
|
+
# `subscriptions/listen` answers with a streaming SSE body that is held open. The
|
|
40
|
+
# controller buffers the response and builds a stateless transport per request, so it
|
|
41
|
+
# cannot serve that stream; refusing answers the method as unimplemented (-32601).
|
|
42
|
+
serve_subscriptions_listen: false
|
|
38
43
|
)
|
|
39
44
|
server
|
|
40
45
|
end
|
|
@@ -46,6 +51,7 @@ module Administrate
|
|
|
46
51
|
|
|
47
52
|
def built_in_tools
|
|
48
53
|
names = BUILT_IN_TOOLS.dup
|
|
54
|
+
names << REPORT_IMPROVEMENT_TOOL if Administrate::MCP.config.feedback_tool
|
|
49
55
|
names << SIDEKIQ_RETRIES_TOOL if defined?(::Sidekiq)
|
|
50
56
|
names << SIDEKIQ_STATS_TOOL if defined?(::Sidekiq) && Administrate::MCP.config.sidekiq_stats_provider
|
|
51
57
|
names.filter_map(&:safe_constantize)
|
|
@@ -24,10 +24,7 @@ module Administrate
|
|
|
24
24
|
description:
|
|
25
25
|
'Resource type name (e.g., "card", "user", "player"). ' \
|
|
26
26
|
'Use admin_resource_list_resources with no arguments to see available ones. ' \
|
|
27
|
-
'Many resources are namespaced (e.g. "shop/order") and bare names are not aliased.
|
|
28
|
-
'An unrecognised name currently surfaces as an authorization error, not a "not found" ' \
|
|
29
|
-
'error, so treat an auth failure here as a likely wrong resource name and re-check the ' \
|
|
30
|
-
'catalog rather than assuming the server is unavailable.'
|
|
27
|
+
'Many resources are namespaced (e.g. "shop/order") and bare names are not aliased.'
|
|
31
28
|
},
|
|
32
29
|
query: {
|
|
33
30
|
type: 'string',
|
|
@@ -86,7 +83,6 @@ module Administrate
|
|
|
86
83
|
required: %w[resource]
|
|
87
84
|
)
|
|
88
85
|
|
|
89
|
-
# rubocop:disable-next Lint/UnusedMethodArgument
|
|
90
86
|
def self.execute(
|
|
91
87
|
admin:,
|
|
92
88
|
resource:,
|
|
@@ -99,7 +95,7 @@ module Administrate
|
|
|
99
95
|
page: 1,
|
|
100
96
|
per_page: 10
|
|
101
97
|
)
|
|
102
|
-
entry = find_dashboard_entry!(resource)
|
|
98
|
+
entry = find_dashboard_entry!(admin, resource)
|
|
103
99
|
dashboard = entry.dashboard_class.new
|
|
104
100
|
per_page = per_page.to_i.clamp(1, MAX_PER_PAGE)
|
|
105
101
|
page = [page.to_i, 1].max
|
|
@@ -8,7 +8,7 @@ module Administrate
|
|
|
8
8
|
class AdminResourceListResources < AdminDashboardTool
|
|
9
9
|
tool_name 'admin_resource_list_resources'
|
|
10
10
|
description 'Discover available admin resources. ' \
|
|
11
|
-
'Without a resource param: returns
|
|
11
|
+
'Without a resource param: returns the resource names you can read (lightweight catalog). ' \
|
|
12
12
|
'With a resource param: returns fields and filters for that resource.'
|
|
13
13
|
annotations read_only_hint: true, destructive_hint: false, open_world_hint: true
|
|
14
14
|
|
|
@@ -19,10 +19,7 @@ module Administrate
|
|
|
19
19
|
description:
|
|
20
20
|
'Resource name to inspect (optional). When provided, returns fields and filters for that ' \
|
|
21
21
|
'resource. Must be an exact name from the no-argument catalog — many resources are ' \
|
|
22
|
-
'namespaced (e.g. "shop/order") and bare names are not aliased.
|
|
23
|
-
'currently surfaces as an authorization error, not a "not found" error, so treat an auth ' \
|
|
24
|
-
'failure here as a likely wrong resource name and re-check the catalog rather than ' \
|
|
25
|
-
'assuming the server is unavailable.'
|
|
22
|
+
'namespaced (e.g. "shop/order") and bare names are not aliased.'
|
|
26
23
|
}
|
|
27
24
|
}
|
|
28
25
|
)
|
|
@@ -49,7 +46,7 @@ module Administrate
|
|
|
49
46
|
end
|
|
50
47
|
|
|
51
48
|
def self.detail_response(admin, resource_name)
|
|
52
|
-
entry = find_dashboard_entry!(resource_name)
|
|
49
|
+
entry = find_dashboard_entry!(admin, resource_name)
|
|
53
50
|
authorize_resource!(admin, entry.model_class, :index?)
|
|
54
51
|
|
|
55
52
|
json_response(build_detail(resource_name, entry))
|
|
@@ -24,10 +24,7 @@ module Administrate
|
|
|
24
24
|
description:
|
|
25
25
|
'Resource type name (e.g., "card", "user", "player"). ' \
|
|
26
26
|
'Use admin_resource_list_resources with no arguments to see available ones. ' \
|
|
27
|
-
'Many resources are namespaced (e.g. "shop/order") and bare names are not aliased.
|
|
28
|
-
'An unrecognised name currently surfaces as an authorization error, not a "not found" ' \
|
|
29
|
-
'error, so treat an auth failure here as a likely wrong resource name and re-check the ' \
|
|
30
|
-
'catalog rather than assuming the server is unavailable.'
|
|
27
|
+
'Many resources are namespaced (e.g. "shop/order") and bare names are not aliased.'
|
|
31
28
|
},
|
|
32
29
|
id: {
|
|
33
30
|
oneOf: [
|
|
@@ -75,8 +72,8 @@ module Administrate
|
|
|
75
72
|
required: %w[resource id]
|
|
76
73
|
)
|
|
77
74
|
|
|
78
|
-
def self.execute(admin:, resource:, id:, fields: nil, expand: nil)
|
|
79
|
-
entry = find_dashboard_entry!(resource)
|
|
75
|
+
def self.execute(admin:, resource:, id:, fields: nil, expand: nil)
|
|
76
|
+
entry = find_dashboard_entry!(admin, resource)
|
|
80
77
|
dashboard = entry.dashboard_class.new
|
|
81
78
|
attrs, expand_set =
|
|
82
79
|
resolve_attributes_and_expansions(dashboard, fields, expand, :show_page_attributes, MAX_EXPAND)
|
|
@@ -15,7 +15,7 @@ module Administrate
|
|
|
15
15
|
properties: {
|
|
16
16
|
category: {
|
|
17
17
|
type: 'string',
|
|
18
|
-
enum:
|
|
18
|
+
enum: FeedbackCategories::CATEGORIES.keys,
|
|
19
19
|
description: 'The type of improvement being reported.'
|
|
20
20
|
},
|
|
21
21
|
resource_name: {
|
|
@@ -35,7 +35,10 @@ module Administrate
|
|
|
35
35
|
|
|
36
36
|
return error_response(result.errors.join(', ')) unless result.success?
|
|
37
37
|
|
|
38
|
-
|
|
38
|
+
payload = { status: 'created' }
|
|
39
|
+
payload[:feedback_id] = result.report.record.id if result.report.record
|
|
40
|
+
|
|
41
|
+
json_response(payload)
|
|
39
42
|
end
|
|
40
43
|
end
|
|
41
44
|
end
|
|
@@ -15,7 +15,10 @@ module Administrate
|
|
|
15
15
|
end
|
|
16
16
|
|
|
17
17
|
def belongs_to_admin
|
|
18
|
-
belongs_to :admin, class_name: Administrate::MCP.config.admin_class_name,
|
|
18
|
+
belongs_to :admin, class_name: Administrate::MCP.config.admin_class_name,
|
|
19
|
+
foreign_key: Administrate::MCP.config.admin_foreign_key,
|
|
20
|
+
inverse_of: false,
|
|
21
|
+
optional: false
|
|
19
22
|
end
|
|
20
23
|
end
|
|
21
24
|
end
|
|
@@ -9,8 +9,7 @@ module Administrate
|
|
|
9
9
|
belongs_to_admin
|
|
10
10
|
belongs_to :api_key, class_name: 'Administrate::MCP::ApiKey', optional: true, inverse_of: :feedbacks
|
|
11
11
|
|
|
12
|
-
enum :category,
|
|
13
|
-
{ description: 0, missing_filter: 1, missing_field: 2, missing_resource: 3, serialization: 4, other: 5 }
|
|
12
|
+
enum :category, FeedbackCategories::CATEGORIES
|
|
14
13
|
enum :status, { pending: 0, accepted: 1, rejected: 2, shipped: 3 }, prefix: true
|
|
15
14
|
|
|
16
15
|
validates :suggestion, presence: true
|
|
@@ -2,8 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
class CreateAdministrateModelContextProtocolApiKeys < ActiveRecord::Migration[8.1]
|
|
4
4
|
def change
|
|
5
|
+
admin_foreign_key = Administrate::MCP.config.admin_foreign_key
|
|
6
|
+
|
|
5
7
|
create_table :administrate_mcp_api_keys, id: :uuid, if_not_exists: true do |t|
|
|
6
|
-
t.uuid
|
|
8
|
+
t.uuid admin_foreign_key, null: false
|
|
7
9
|
t.string :token_digest, null: false
|
|
8
10
|
t.string :token_prefix, null: false
|
|
9
11
|
t.string :name, null: false
|
|
@@ -14,7 +16,7 @@ class CreateAdministrateModelContextProtocolApiKeys < ActiveRecord::Migration[8.
|
|
|
14
16
|
t.timestamps
|
|
15
17
|
end
|
|
16
18
|
|
|
17
|
-
add_index :administrate_mcp_api_keys,
|
|
19
|
+
add_index :administrate_mcp_api_keys, admin_foreign_key, if_not_exists: true
|
|
18
20
|
add_index :administrate_mcp_api_keys, :token_digest, unique: true, if_not_exists: true
|
|
19
21
|
end
|
|
20
22
|
end
|
|
@@ -2,8 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
class CreateAdministrateModelContextProtocolFeedbacks < ActiveRecord::Migration[8.1]
|
|
4
4
|
def change
|
|
5
|
+
admin_foreign_key = Administrate::MCP.config.admin_foreign_key
|
|
6
|
+
|
|
5
7
|
create_table :administrate_mcp_feedbacks, id: :uuid, if_not_exists: true do |t|
|
|
6
|
-
t.uuid
|
|
8
|
+
t.uuid admin_foreign_key
|
|
7
9
|
t.uuid :api_key_id
|
|
8
10
|
t.integer :category, null: false
|
|
9
11
|
t.string :resource_name
|
|
@@ -13,7 +15,7 @@ class CreateAdministrateModelContextProtocolFeedbacks < ActiveRecord::Migration[
|
|
|
13
15
|
t.timestamps
|
|
14
16
|
end
|
|
15
17
|
|
|
16
|
-
add_index :administrate_mcp_feedbacks,
|
|
18
|
+
add_index :administrate_mcp_feedbacks, admin_foreign_key, if_not_exists: true
|
|
17
19
|
add_index :administrate_mcp_feedbacks, :api_key_id, if_not_exists: true
|
|
18
20
|
end
|
|
19
21
|
end
|
data/db/migrate/20260101000003_create_administrate_model_context_protocol_authorization_tables.rb
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
class CreateAdministrateModelContextProtocolAuthorizationTables < ActiveRecord::Migration[8.1]
|
|
4
4
|
def change
|
|
5
|
+
admin_foreign_key = Administrate::MCP.config.admin_foreign_key
|
|
6
|
+
|
|
5
7
|
create_table :administrate_mcp_oauth_applications, id: :uuid, if_not_exists: true do |t|
|
|
6
8
|
t.string :client_id, null: false
|
|
7
9
|
t.text :client_secret_digest
|
|
@@ -15,7 +17,7 @@ class CreateAdministrateModelContextProtocolAuthorizationTables < ActiveRecord::
|
|
|
15
17
|
add_index :administrate_mcp_oauth_applications, :client_id, unique: true, if_not_exists: true
|
|
16
18
|
|
|
17
19
|
create_table :administrate_mcp_oauth_access_grants, id: :uuid, if_not_exists: true do |t|
|
|
18
|
-
t.uuid
|
|
20
|
+
t.uuid admin_foreign_key, null: false
|
|
19
21
|
t.uuid :application_id, null: false
|
|
20
22
|
t.string :token_digest, null: false
|
|
21
23
|
t.integer :expires_in, null: false
|
|
@@ -28,12 +30,12 @@ class CreateAdministrateModelContextProtocolAuthorizationTables < ActiveRecord::
|
|
|
28
30
|
t.datetime :created_at, null: false
|
|
29
31
|
end
|
|
30
32
|
|
|
31
|
-
add_index :administrate_mcp_oauth_access_grants,
|
|
33
|
+
add_index :administrate_mcp_oauth_access_grants, admin_foreign_key, if_not_exists: true
|
|
32
34
|
add_index :administrate_mcp_oauth_access_grants, :application_id, if_not_exists: true
|
|
33
35
|
add_index :administrate_mcp_oauth_access_grants, :token_digest, unique: true, if_not_exists: true
|
|
34
36
|
|
|
35
37
|
create_table :administrate_mcp_oauth_access_tokens, id: :uuid, if_not_exists: true do |t|
|
|
36
|
-
t.uuid
|
|
38
|
+
t.uuid admin_foreign_key, null: false
|
|
37
39
|
t.uuid :application_id, null: false
|
|
38
40
|
t.string :token_digest, null: false
|
|
39
41
|
t.string :refresh_token_digest, null: false
|
|
@@ -44,7 +46,7 @@ class CreateAdministrateModelContextProtocolAuthorizationTables < ActiveRecord::
|
|
|
44
46
|
t.datetime :created_at, null: false
|
|
45
47
|
end
|
|
46
48
|
|
|
47
|
-
add_index :administrate_mcp_oauth_access_tokens,
|
|
49
|
+
add_index :administrate_mcp_oauth_access_tokens, admin_foreign_key, if_not_exists: true
|
|
48
50
|
add_index :administrate_mcp_oauth_access_tokens, :application_id, if_not_exists: true
|
|
49
51
|
add_index :administrate_mcp_oauth_access_tokens, :token_digest, unique: true, if_not_exists: true
|
|
50
52
|
add_index :administrate_mcp_oauth_access_tokens, :refresh_token_digest, unique: true, if_not_exists: true
|
data/docs/admin-integration.md
CHANGED
|
@@ -2,22 +2,116 @@
|
|
|
2
2
|
|
|
3
3
|
[Back to README](../README.md)
|
|
4
4
|
|
|
5
|
-
##
|
|
5
|
+
## The API key and feedback consoles
|
|
6
6
|
|
|
7
|
-
The engine
|
|
8
|
-
|
|
9
|
-
|
|
7
|
+
The engine ships the console for its own tables: two Administrate dashboards and two controller
|
|
8
|
+
concerns. Include them in controllers of your own, so that authentication, the base controller and
|
|
9
|
+
the policies stay yours:
|
|
10
10
|
|
|
11
11
|
```ruby
|
|
12
12
|
# config/routes.rb, inside your admin namespace
|
|
13
13
|
namespace :admin do
|
|
14
|
-
resources :administrate_mcp_api_keys
|
|
15
|
-
|
|
14
|
+
resources :administrate_mcp_api_keys, only: %i[index show new create destroy]
|
|
15
|
+
# Only when config.persist_feedback is true; the table exists only then.
|
|
16
|
+
resources :administrate_mcp_feedbacks, only: %i[index show edit update]
|
|
16
17
|
end
|
|
17
18
|
|
|
19
|
+
# app/dashboards/administrate_mcp_api_key_dashboard.rb
|
|
20
|
+
class AdministrateMcpApiKeyDashboard < Administrate::MCP::ApiKeyDashboard
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# app/dashboards/administrate_mcp_feedback_dashboard.rb
|
|
24
|
+
class AdministrateMcpFeedbackDashboard < Administrate::MCP::FeedbackDashboard
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# app/controllers/admin/administrate_mcp_api_keys_controller.rb
|
|
28
|
+
module Admin
|
|
29
|
+
class AdministrateMcpApiKeysController < Admin::ApplicationController
|
|
30
|
+
include Administrate::MCP::ApiKeysAdmin
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# app/controllers/admin/administrate_mcp_feedbacks_controller.rb
|
|
35
|
+
module Admin
|
|
36
|
+
class AdministrateMcpFeedbacksController < Admin::ApplicationController
|
|
37
|
+
include Administrate::MCP::FeedbacksAdmin
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
`ApiKeysAdmin` generates the key, stores its digest, and puts the plaintext in the flash of the
|
|
43
|
+
redirect that created it, because that response is the only place it can ever be read. `destroy`
|
|
44
|
+
revokes rather than deletes, so the row still says what the token was. Both concerns point
|
|
45
|
+
Administrate at the engine's model, which the controller's own name cannot spell.
|
|
46
|
+
|
|
47
|
+
The two empty subclasses are the part the engine cannot write for you. Administrate finds a
|
|
48
|
+
dashboard by camelizing the controller's name, and an application that registers MCP as an
|
|
49
|
+
inflection acronym expects `AdministrateMCPApiKeyDashboard` where one that does not expects
|
|
50
|
+
`AdministrateMcpApiKeyDashboard`. Name the file `administrate_mcp_api_key_dashboard.rb` and the
|
|
51
|
+
class whatever your own inflections produce for it; the columns come from the engine's class.
|
|
52
|
+
|
|
53
|
+
The key owner comes from `config.current_admin`, so no host code decides it twice. If your base
|
|
54
|
+
controller offers Pundit's `authorize`, both actions call it.
|
|
55
|
+
|
|
56
|
+
### What a host overrides
|
|
57
|
+
|
|
58
|
+
| Method | Default | Override to |
|
|
59
|
+
| --- | --- | --- |
|
|
60
|
+
| `mcp_write_access_allowed?` | `false` | let some admins mint write-enabled keys |
|
|
61
|
+
| `mcp_api_key_owner` | `config.current_admin` | attribute the key to someone else |
|
|
62
|
+
| `scoped_resource` | every key, newest first | show an admin only their own keys |
|
|
63
|
+
|
|
64
|
+
```ruby
|
|
65
|
+
module Admin
|
|
66
|
+
class AdministrateMcpApiKeysController < Admin::ApplicationController
|
|
67
|
+
include Administrate::MCP::ApiKeysAdmin
|
|
68
|
+
|
|
69
|
+
private
|
|
70
|
+
|
|
71
|
+
def mcp_write_access_allowed?
|
|
72
|
+
policy(Administrate::MCP::ApiKey).grant_write_access?
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def scoped_resource
|
|
76
|
+
current_administrator.full_access? ? super : super.where(admin: current_administrator)
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
### Different columns
|
|
83
|
+
|
|
84
|
+
Redefine the constants you want to change in your subclass:
|
|
85
|
+
|
|
86
|
+
```ruby
|
|
87
|
+
class AdministrateMcpApiKeyDashboard < Administrate::MCP::ApiKeyDashboard
|
|
88
|
+
COLLECTION_ATTRIBUTES = %i[name last_used_at].freeze
|
|
89
|
+
end
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
For a console mounted at a path Administrate cannot derive the dashboard from, name it explicitly:
|
|
93
|
+
|
|
94
|
+
```ruby
|
|
95
|
+
module Admin
|
|
96
|
+
class AdministrateMcpApiKeysController < Admin::ApplicationController
|
|
97
|
+
include Administrate::MCP::ApiKeysAdmin
|
|
98
|
+
administrate_mcp_resource Administrate::MCP::ApiKey, dashboard: ApiKeyConsoleDashboard
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
## Listing the engine's tables over the protocol
|
|
104
|
+
|
|
105
|
+
The consoles above are not MCP resources. The registry only reads your own dashboard directory, so
|
|
106
|
+
the engine's dashboards are invisible to it whatever they are called. To expose a key list over the
|
|
107
|
+
protocol, declare a dashboard in `app/dashboards` with an `MCP_DESCRIPTION` and a `self.model`:
|
|
108
|
+
|
|
109
|
+
```ruby
|
|
18
110
|
# app/dashboards/administrate_mcp/api_key_dashboard.rb
|
|
19
111
|
module AdministrateMcp
|
|
20
112
|
class ApiKeyDashboard < Administrate::BaseDashboard
|
|
113
|
+
MCP_DESCRIPTION = 'API keys admins authenticate to the MCP server with.'
|
|
114
|
+
|
|
21
115
|
ATTRIBUTE_TYPES = { id: Field::String, name: Field::String, admin: Field::BelongsTo }.freeze
|
|
22
116
|
COLLECTION_ATTRIBUTES = %i[id name].freeze
|
|
23
117
|
SHOW_PAGE_ATTRIBUTES = %i[id name admin].freeze
|
|
@@ -28,18 +122,11 @@ module AdministrateMcp
|
|
|
28
122
|
end
|
|
29
123
|
end
|
|
30
124
|
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
125
|
```
|
|
40
126
|
|
|
41
|
-
The
|
|
42
|
-
|
|
127
|
+
The registry finds it through `self.model`, registers it as `administrate/mcp/api_key`, and builds
|
|
128
|
+
record URLs from the namespaced route key, so it never shadows a resource of your own called
|
|
129
|
+
`api_keys`.
|
|
43
130
|
|
|
44
131
|
## Customising the consent screen
|
|
45
132
|
|
|
@@ -48,6 +135,11 @@ has `@application`, `@redirect_uri`, `@redirect_host` and `@scopes`.
|
|
|
48
135
|
|
|
49
136
|
## Feedback housekeeping
|
|
50
137
|
|
|
138
|
+
The `administrate_mcp_feedbacks` table, its dashboard and this housekeeping service are only needed
|
|
139
|
+
when `config.persist_feedback` is `true`. With it off, `report_mcp_improvement` still works and
|
|
140
|
+
`on_feedback` still fires, just without a row behind it, and the table can be left out entirely; see
|
|
141
|
+
[docs/configuration.md](configuration.md) for the setting.
|
|
142
|
+
|
|
51
143
|
`Administrate::MCP::CleanOldFeedbacks.call(till: 2.months.ago)` deletes one batch of 10,000 and
|
|
52
144
|
reports `more?`; schedule it however your app schedules work.
|
|
53
145
|
|
data/docs/configuration.md
CHANGED
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
[Back to README](../README.md)
|
|
4
4
|
|
|
5
5
|
Configure the engine in an initializer, `config/initializers/administrate_mcp.rb`. It must run
|
|
6
|
-
before the engine's models load, because the `admin` association reads `admin_class_name
|
|
6
|
+
before the engine's models load, because the `admin` association reads `admin_class_name` and
|
|
7
|
+
`admin_foreign_key`.
|
|
7
8
|
|
|
8
9
|
```ruby
|
|
9
10
|
Administrate::MCP.configure do |c|
|
|
@@ -11,6 +12,7 @@ Administrate::MCP.configure do |c|
|
|
|
11
12
|
c.server_version = '1.0.0'
|
|
12
13
|
|
|
13
14
|
c.admin_class_name = 'Administrator'
|
|
15
|
+
c.admin_foreign_key = :administrator_id
|
|
14
16
|
c.current_admin = ->(controller) { controller.send(:warden)&.authenticate(scope: :administrator) }
|
|
15
17
|
c.admin_active = ->(admin) { admin.admin? && admin.anonymized_at.nil? }
|
|
16
18
|
c.sign_in = lambda do |controller|
|
|
@@ -36,10 +38,28 @@ Administrate::MCP.configure do |c|
|
|
|
36
38
|
Admin::AuditOperation.call!(administrator_id: admin.id, request_method: 'POST',
|
|
37
39
|
request_url: "mcp://tools/#{tool_name}", params: arguments)
|
|
38
40
|
end
|
|
39
|
-
c.
|
|
41
|
+
c.persist_feedback = true
|
|
42
|
+
c.on_feedback = ->(report) { SlackNotifier.notify(notification_type: :mcp_feedback, blocks: blocks_for(report)) }
|
|
40
43
|
end
|
|
41
44
|
```
|
|
42
45
|
|
|
46
|
+
Set `admin_foreign_key` when the host's own admin table already has a foreign key column under a
|
|
47
|
+
different name, for example a live credentials table called `administrator_id`, and renaming that
|
|
48
|
+
column is not something you want to do. The association is still called `admin` everywhere the
|
|
49
|
+
engine's API uses it; only the column it reads and writes changes.
|
|
50
|
+
|
|
51
|
+
`report_mcp_improvement` gives users of the MCP server a way to flag what the server got wrong; the
|
|
52
|
+
gem's job stops at reporting that signal to `on_feedback`. Storing what it reports, showing it in a
|
|
53
|
+
dashboard and cleaning up old rows is an opinion about how a host wants to handle the signal, not
|
|
54
|
+
something every host needs, so it is batteries-included rather than mandatory: set
|
|
55
|
+
`persist_feedback` to `true` to have the gem create an `Administrate::MCP::Feedback` row before
|
|
56
|
+
calling `on_feedback`, expose it in your admin as described in
|
|
57
|
+
[docs/admin-integration.md](admin-integration.md), and clean old rows up with
|
|
58
|
+
`Administrate::MCP::CleanOldFeedbacks`. Leave it `false`, the default, and `on_feedback` still fires
|
|
59
|
+
with the same report, just without a persisted record behind it, and the `administrate_mcp_feedbacks`
|
|
60
|
+
table is never touched. Set `feedback_tool` to `false` to stop publishing `report_mcp_improvement`
|
|
61
|
+
at all.
|
|
62
|
+
|
|
43
63
|
## The full configuration object
|
|
44
64
|
|
|
45
65
|
```ruby
|
|
@@ -77,6 +97,7 @@ class Configuration
|
|
|
77
97
|
:oauth,
|
|
78
98
|
:sign_in,
|
|
79
99
|
:admin_class_name,
|
|
100
|
+
:admin_foreign_key,
|
|
80
101
|
:authorization,
|
|
81
102
|
:default_required_roles,
|
|
82
103
|
:tool_paths,
|
|
@@ -87,6 +108,8 @@ class Configuration
|
|
|
87
108
|
:on_error,
|
|
88
109
|
:allow_localhost_redirects,
|
|
89
110
|
:default_client_name,
|
|
111
|
+
:persist_feedback,
|
|
112
|
+
:feedback_tool,
|
|
90
113
|
:sidekiq_stats_provider,
|
|
91
114
|
:admin_route_namespace,
|
|
92
115
|
:admin_url_options,
|
|
@@ -107,6 +130,7 @@ class Configuration
|
|
|
107
130
|
@oauth = true
|
|
108
131
|
@sign_in = nil
|
|
109
132
|
@admin_class_name = 'Administrator'
|
|
133
|
+
@admin_foreign_key = :admin_id
|
|
110
134
|
@authorization = default_authorization
|
|
111
135
|
@default_required_roles = []
|
|
112
136
|
@tool_paths = []
|
|
@@ -116,11 +140,13 @@ class Configuration
|
|
|
116
140
|
def assign_hooks
|
|
117
141
|
@instrument = ->(tool_name:, admin:, &block) { block.call }
|
|
118
142
|
@on_tool_call = ->(tool_name:, admin:, arguments:, scopes:) {}
|
|
119
|
-
@on_feedback = ->(
|
|
143
|
+
@on_feedback = ->(report) {}
|
|
120
144
|
@on_error = ->(exception) {}
|
|
121
145
|
@allow_localhost_redirects = true
|
|
122
146
|
@api_key_token_prefix = 'amcp_'
|
|
123
147
|
@default_client_name = 'MCP Client'
|
|
148
|
+
@persist_feedback = false
|
|
149
|
+
@feedback_tool = true
|
|
124
150
|
@sidekiq_stats_provider = nil
|
|
125
151
|
end
|
|
126
152
|
|
|
@@ -155,18 +181,21 @@ end
|
|
|
155
181
|
| `identity_fallback` | returns `nil` | Proc taking the request, returning an `Authentication::Identity` or nil, consulted when no credential the engine issued matched |
|
|
156
182
|
| `oauth` | `true` | Whether the engine serves its own OAuth 2.1 server. False draws no OAuth routes and never looks an access token up |
|
|
157
183
|
| `sign_in` | `nil` (renders 401) | Proc taking the OAuth controller, sending an anonymous visitor to sign in |
|
|
158
|
-
| `admin_class_name` | `'Administrator'` | Class the
|
|
184
|
+
| `admin_class_name` | `'Administrator'` | Class the admin foreign key column points at |
|
|
185
|
+
| `admin_foreign_key` | `:admin_id` | Column on the engine's own tables that stores the owning admin's id. The association is still called `admin`; only the column name changes |
|
|
159
186
|
| `authorization` | Pundit if defined, else Permissive | Adapter: `authorize!`, `authorized?`, `authorize_roles!` |
|
|
160
187
|
| `default_required_roles` | `[]` | Roles a tool requires unless it declares its own |
|
|
161
188
|
| `tool_paths` | `[]` | Directories scanned for extra `BaseTool` subclasses |
|
|
162
189
|
| `dashboard_paths` | `app/dashboards` | Where dashboards are found |
|
|
163
190
|
| `instrument` | yields | Around hook `(tool_name:, admin:, &block)` |
|
|
164
191
|
| `on_tool_call` | no-op | Audit hook `(tool_name:, admin:, arguments:, scopes:)`, after the permission checks |
|
|
165
|
-
| `on_feedback` | no-op | Called with each
|
|
192
|
+
| `on_feedback` | no-op | Called with each `FeedbackReport`, whether or not it was persisted |
|
|
166
193
|
| `on_error` | no-op | Called with an exception the engine swallowed, so you can report it |
|
|
167
194
|
| `allow_localhost_redirects` | `true` | Whether loopback OAuth redirect URIs are accepted. Inert when `oauth` is false |
|
|
168
195
|
| `api_key_token_prefix` | `'amcp_'` | Prefix that marks a bearer token as an API key. Cannot be blank |
|
|
169
196
|
| `default_client_name` | `'MCP Client'` | Name given to a dynamically registered client that sends no `client_name`. Inert when `oauth` is false |
|
|
197
|
+
| `persist_feedback` | `false` | Whether `report_mcp_improvement` creates an `Administrate::MCP::Feedback` row before calling `on_feedback`. False needs no `administrate_mcp_feedbacks` table at all |
|
|
198
|
+
| `feedback_tool` | `true` | Whether `report_mcp_improvement` is published |
|
|
170
199
|
| `sidekiq_stats_provider` | `nil` | Object answering `counts`, `total_counts`, `queues`, `stats_cleared_at`; a class name String or a Proc is resolved lazily so autoloaded providers can be named in an initializer |
|
|
171
200
|
| `admin_route_namespace` | `:admin` | Namespace record URLs are built from |
|
|
172
201
|
| `admin_url_options` | `{}` | Options passed to `polymorphic_url`; when no `:host` is given, host, protocol and port are taken from `admin_origin` |
|
|
@@ -8,10 +8,16 @@ module Administrate
|
|
|
8
8
|
# Shared role gating. Roles are a host concept: an admin class that does not answer
|
|
9
9
|
# `can_access?` cannot be gated on them, and saying so beats letting the call through.
|
|
10
10
|
class Base
|
|
11
|
+
# The message the built-in adapters raise. A caller that reformats a denial compares against
|
|
12
|
+
# it to tell the generic message from one the host wrote.
|
|
13
|
+
def self.denial_message(action, model_name)
|
|
14
|
+
"Not authorized to #{action} #{model_name}"
|
|
15
|
+
end
|
|
16
|
+
|
|
11
17
|
def authorize!(admin, record_or_class, action)
|
|
12
18
|
return if authorized?(admin, record_or_class, action)
|
|
13
19
|
|
|
14
|
-
raise UnauthorizedError,
|
|
20
|
+
raise UnauthorizedError, Base.denial_message(action, model_name(record_or_class))
|
|
15
21
|
end
|
|
16
22
|
|
|
17
23
|
def authorized?(_admin, _record_or_class, _action)
|
|
@@ -11,7 +11,7 @@ module Administrate
|
|
|
11
11
|
policy = policy_for(admin, record_or_class)
|
|
12
12
|
return if policy.public_send(action)
|
|
13
13
|
|
|
14
|
-
raise UnauthorizedError,
|
|
14
|
+
raise UnauthorizedError, Base.denial_message(action, model_name(record_or_class))
|
|
15
15
|
end
|
|
16
16
|
|
|
17
17
|
def authorized?(admin, record_or_class, action)
|
|
@@ -44,6 +44,7 @@ module Administrate
|
|
|
44
44
|
:oauth,
|
|
45
45
|
:sign_in,
|
|
46
46
|
:admin_class_name,
|
|
47
|
+
:admin_foreign_key,
|
|
47
48
|
:authorization,
|
|
48
49
|
:default_required_roles,
|
|
49
50
|
:tool_paths,
|
|
@@ -54,6 +55,8 @@ module Administrate
|
|
|
54
55
|
:on_error,
|
|
55
56
|
:allow_localhost_redirects,
|
|
56
57
|
:default_client_name,
|
|
58
|
+
:persist_feedback,
|
|
59
|
+
:feedback_tool,
|
|
57
60
|
:sidekiq_stats_provider,
|
|
58
61
|
:admin_route_namespace,
|
|
59
62
|
:admin_url_options,
|
|
@@ -80,6 +83,7 @@ module Administrate
|
|
|
80
83
|
@oauth = true
|
|
81
84
|
@sign_in = nil
|
|
82
85
|
@admin_class_name = 'Administrator'
|
|
86
|
+
@admin_foreign_key = :admin_id
|
|
83
87
|
@authorization = default_authorization
|
|
84
88
|
@default_required_roles = []
|
|
85
89
|
@tool_paths = []
|
|
@@ -89,11 +93,13 @@ module Administrate
|
|
|
89
93
|
def assign_hooks
|
|
90
94
|
@instrument = ->(tool_name:, admin:, &block) { block.call } # rubocop:disable Lint/UnusedBlockArgument
|
|
91
95
|
@on_tool_call = ->(tool_name:, admin:, arguments:, scopes:) {}
|
|
92
|
-
@on_feedback = ->(
|
|
96
|
+
@on_feedback = ->(report) {}
|
|
93
97
|
@on_error = ->(exception) {}
|
|
94
98
|
@allow_localhost_redirects = true
|
|
95
99
|
@api_key_token_prefix = 'amcp_'
|
|
96
100
|
@default_client_name = 'MCP Client'
|
|
101
|
+
@persist_feedback = false
|
|
102
|
+
@feedback_tool = true
|
|
97
103
|
@sidekiq_stats_provider = nil
|
|
98
104
|
end
|
|
99
105
|
|
|
@@ -9,6 +9,12 @@ module Administrate
|
|
|
9
9
|
class UnauthorizedError < Error
|
|
10
10
|
end
|
|
11
11
|
|
|
12
|
+
# Raised when the caller's role may not read one resource. It is still an authorization failure,
|
|
13
|
+
# but it is returned as a tool error rather than a 403: the credentials work for the tool, only
|
|
14
|
+
# this resource is out of reach, and the caller needs the message to tell the two apart.
|
|
15
|
+
class ResourceForbiddenError < UnauthorizedError
|
|
16
|
+
end
|
|
17
|
+
|
|
12
18
|
# Raised when the host asked for something the configuration cannot deliver.
|
|
13
19
|
class ConfigurationError < Error
|
|
14
20
|
end
|