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.
Files changed (32) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +63 -1
  3. data/README.md +51 -7
  4. data/app/controllers/concerns/administrate/mcp/api_keys_admin.rb +93 -0
  5. data/app/controllers/concerns/administrate/mcp/feedbacks_admin.rb +25 -0
  6. data/app/controllers/concerns/administrate/mcp/resource_controller.rb +44 -0
  7. data/app/dashboards/administrate/mcp/api_key_dashboard.rb +53 -0
  8. data/app/dashboards/administrate/mcp/feedback_dashboard.rb +43 -0
  9. data/app/lib/administrate/mcp/admin_dashboard_tool.rb +49 -3
  10. data/app/lib/administrate/mcp/base_tool.rb +3 -2
  11. data/app/lib/administrate/mcp/feedback_categories.rb +19 -0
  12. data/app/lib/administrate/mcp/feedback_report.rb +11 -0
  13. data/app/lib/administrate/mcp/report_improvement.rb +22 -11
  14. data/app/lib/administrate/mcp/resource_resolver.rb +29 -0
  15. data/app/lib/administrate/mcp/server_builder.rb +8 -2
  16. data/app/lib/administrate/mcp/tools/admin_resource_list.rb +2 -6
  17. data/app/lib/administrate/mcp/tools/admin_resource_list_resources.rb +3 -6
  18. data/app/lib/administrate/mcp/tools/admin_resource_show.rb +3 -6
  19. data/app/lib/administrate/mcp/tools/report_improvement.rb +5 -2
  20. data/app/models/administrate/mcp/application_record.rb +4 -1
  21. data/app/models/administrate/mcp/feedback.rb +1 -2
  22. data/db/migrate/20260101000001_create_administrate_model_context_protocol_api_keys.rb +4 -2
  23. data/db/migrate/20260101000002_create_administrate_model_context_protocol_feedbacks.rb +4 -2
  24. data/db/migrate/20260101000003_create_administrate_model_context_protocol_authorization_tables.rb +6 -4
  25. data/docs/admin-integration.md +108 -16
  26. data/docs/configuration.md +34 -5
  27. data/lib/administrate/mcp/authorization/base.rb +7 -1
  28. data/lib/administrate/mcp/authorization/pundit.rb +1 -1
  29. data/lib/administrate/mcp/configuration.rb +7 -1
  30. data/lib/administrate/mcp/errors.rb +6 -0
  31. data/lib/administrate/mcp/version.rb +1 -1
  32. 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 all resource names (lightweight catalog). ' \
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. An unrecognised name ' \
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) # rubocop:disable Lint/UnusedMethodArgument
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: Feedback.categories.keys,
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
- json_response({ status: 'created', feedback_id: result.feedback.id })
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, optional: false
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 :admin_id, null: false
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, :admin_id, if_not_exists: true
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 :admin_id
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, :admin_id, if_not_exists: true
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
@@ -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 :admin_id, null: false
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, :admin_id, if_not_exists: true
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 :admin_id, null: false
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, :admin_id, if_not_exists: true
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
@@ -2,22 +2,116 @@
2
2
 
3
3
  [Back to README](../README.md)
4
4
 
5
- ## Exposing the engine's own tables in your admin
5
+ ## The API key and feedback consoles
6
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:
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
- resources :administrate_mcp_feedbacks, only: %i[index show destroy]
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 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.
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
 
@@ -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.on_feedback = ->(feedback) { SlackNotifier.notify(notification_type: :mcp_feedback, blocks: blocks_for(feedback)) }
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 = ->(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 `admin_id` column points at |
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 new `Feedback` record |
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, "Not authorized to #{action} #{model_name(record_or_class)}"
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, "Not authorized to #{action} #{model_name(record_or_class)}"
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 = ->(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
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Administrate
4
4
  module MCP
5
- VERSION = '0.1.0'
5
+ VERSION = '0.3.0'
6
6
  end
7
7
  end