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
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 4da1e224450ced4f0f0e209279bf8f07e3e863b8cc54d53deb20071e09c162f5
4
- data.tar.gz: 7299ecaabb67efa26432087c689d6291d7b839692a97855997f0a5aa94ea8e5c
3
+ metadata.gz: c22f6f3b6febbe8e86b8cb721e30f63e4e9232edbd668a3a311e44ff180b9117
4
+ data.tar.gz: cd60a0dad70e15f8235e1ed6c3c2385b5d8cc56efe2a66e1b84a18b3b5fe4087
5
5
  SHA512:
6
- metadata.gz: 52016172bf5d81384da02d845c2a7e491ad88859b414ce20b1732804deeff46f930a4df8e4d73b254721c31c39fe8bf2907cda7e56c32dd7b1728641993025ae
7
- data.tar.gz: 78e0e4ac8f02ce53a3c4603d5bcb8d95ace6976192fed36519a0df092fa2a0b7bec2ac1fc071e3fcc2862e60f81195377943bd2c7693060c488d5ea4b4548a5a
6
+ metadata.gz: 65ea611de7b893d7b4aaec605cc91a3bd742187ca3ff2ef340229009daa130a7bc7abb79e1f4bda2d4995670ff0dadfce95252f659366ada2713458a4d70a60c
7
+ data.tar.gz: 23a0677acecdb975efeb760a6f797f9319e0ae95b607be57db1dd5a5be5c1db21141aaa3cfcfbf90625472059b65a3263ae8b10ec0ab00d002414e19d5c67215
data/CHANGELOG.md CHANGED
@@ -8,6 +8,66 @@ minor release may change behaviour a host depends on; the entry says so when it
8
8
 
9
9
  ## [Unreleased]
10
10
 
11
+ ## [0.3.0] - 2026-10-02
12
+
13
+ ### Changed
14
+
15
+ - **Behaviour change for clients:** two failures of `admin_resource_list_resources`,
16
+ `admin_resource_list` and `admin_resource_show` now return HTTP 200 with a tool error
17
+ (`result.isError: true`) instead of HTTP 403 with `X-Auth-Error: forbidden` and JSON-RPC code
18
+ -32003. Clients such as Claude showed that 403 as an authentication failure and dropped the message:
19
+ - An unknown resource name. The message says the name is not registered and lists up to five
20
+ close registered names, taken only from the resources the caller can read.
21
+ - A registered resource the caller may not read. The message is "Resource `<name>` exists but you
22
+ are not authorized to list it" (or "show it"), followed by the authorization adapter's own
23
+ message when the host wrote one. The old 403 body already told the two cases apart
24
+ (`Unknown resource: …` against `Not authorized to show? <Model>`); the difference is that
25
+ clients now display it.
26
+
27
+ A tool-level refusal, a missing OAuth scope or a required role, is still a 403 for now
28
+ ([#11](https://github.com/sorare/administrate-mcp/issues/11)). The per-resource denial is raised
29
+ as `Administrate::MCP::ResourceForbiddenError`, a subclass of `UnauthorizedError`, so host code
30
+ that rescues `UnauthorizedError` still catches it.
31
+
32
+ ### Fixed
33
+
34
+ - A `subscriptions/listen` request no longer raises `NoMethodError` in the host's Rack stack. The
35
+ `mcp` gem answers it with a streaming SSE body meant to stay open, which the engine's controller
36
+ cannot serve; the transport is now built with `serve_subscriptions_listen: false`, so the method
37
+ is answered with HTTP 404 and JSON-RPC error `-32601` like any unimplemented method, and
38
+ `server/discover` stops advertising the `listChanged` and `subscribe` capability flags.
39
+
40
+ ## [0.2.0] - 2026-09-22
41
+
42
+ ### Added
43
+
44
+ - The admin console for the engine's own tables: the `Administrate::MCP::ApiKeyDashboard` and
45
+ `Administrate::MCP::FeedbackDashboard` base dashboards, and the `Administrate::MCP::ApiKeysAdmin`
46
+ and `Administrate::MCP::FeedbacksAdmin` controller concerns. A host subclasses each dashboard
47
+ under the name its own inflections give `administrate_mcp_api_key_dashboard.rb`, which differs
48
+ between an application that registers MCP as an acronym and one that does not. The concern goes in
49
+ a controller of the host's own, which keeps its base controller, authentication and policies; the
50
+ engine supplies the resource wiring, key generation, the one-time plaintext in the flash, and
51
+ revocation. Write-enabled keys are refused until the host overrides `mcp_write_access_allowed?`.
52
+ - `config.admin_foreign_key`, defaulting to `:admin_id`, so a host whose admin table already has a
53
+ differently named foreign key can keep that column name instead of renaming it. The association
54
+ stays called `admin` everywhere in the engine's API; only the column it reads and writes changes.
55
+ - `config.persist_feedback`, defaulting to `false`, so a host can use `report_mcp_improvement` and
56
+ `on_feedback` as a plain signal without carrying the `administrate_mcp_feedbacks` table, its model
57
+ or its migration. Set it to `true` for the previous behaviour: the report is persisted before
58
+ `on_feedback` runs, and the dashboard and `CleanOldFeedbacks` housekeeping become meaningful.
59
+ - `config.feedback_tool`, defaulting to `true`, to stop publishing `report_mcp_improvement`
60
+ altogether.
61
+
62
+ ### Changed
63
+
64
+ - `on_feedback` is now called with a `FeedbackReport`, a plain value object, instead of a persisted
65
+ `Feedback` record. A host reading attributes off the argument (`category`, `suggestion`,
66
+ `resource_name`, `admin`, `api_key`) sees no difference; a host that relied on it being an
67
+ `ActiveRecord` instance, for example calling `.save`, `.update` or `.reload` on it, or expecting
68
+ `.persisted?` to be true, must update its `on_feedback` hook. When `config.persist_feedback` is
69
+ `true` the persisted record is still reachable, as `report.record`.
70
+
11
71
  ## [0.1.0] - 2026-09-22
12
72
 
13
73
  ### Added
@@ -57,5 +117,7 @@ minor release may change behaviour a host depends on; the entry says so when it
57
117
  took the gem from git before 0.1.0 must reinstall its migrations rather than migrate
58
118
  incrementally.
59
119
 
60
- [Unreleased]: https://github.com/sorare/administrate-mcp/compare/v0.1.0...HEAD
120
+ [Unreleased]: https://github.com/sorare/administrate-mcp/compare/v0.3.0...HEAD
121
+ [0.3.0]: https://github.com/sorare/administrate-mcp/compare/v0.2.0...v0.3.0
122
+ [0.2.0]: https://github.com/sorare/administrate-mcp/compare/v0.1.0...v0.2.0
61
123
  [0.1.0]: https://github.com/sorare/administrate-mcp/releases/tag/v0.1.0
data/README.md CHANGED
@@ -23,6 +23,7 @@ permissions the admin UI enforces.
23
23
  - [Dashboard declarations](#dashboard-declarations)
24
24
  - [Authentication](#authentication)
25
25
  - [OAuth](#oauth)
26
+ - [Improving the server from its own use](#improving-the-server-from-its-own-use)
26
27
  - [Rate limiting](#rate-limiting)
27
28
  - [Admin integration](#admin-integration)
28
29
  - [Development](#development)
@@ -53,8 +54,14 @@ about your application; everything host-specific goes through `Administrate::MCP
53
54
  as the admin UI, so a resource an admin cannot see in the browser is not exposed over MCP either.
54
55
  - Search, filters and field selection built from the dashboard's own declarations, plus foreign key
55
56
  filters that need no declaration at all.
56
- - A feedback tool, `report_mcp_improvement`, so users can report what the server got wrong, and a
57
- housekeeping method to clean up old feedback records.
57
+ - A feedback tool, `report_mcp_improvement`, so the client can tell you which of your descriptions,
58
+ filters and fields let it down, and you can fix them. See
59
+ [Improving the server from its own use](#improving-the-server-from-its-own-use). The signal is
60
+ always available through `config.on_feedback`; storing it, the dashboard and the clean-up service
61
+ are a batteries-included option a host turns on with `config.persist_feedback`.
62
+ - The admin console for its own tables, as two dashboards and two controller concerns you include
63
+ in controllers of your own: issuing an API key, revoking one, and reading the feedback, without
64
+ giving up your base controller, your authentication or your policies.
58
65
  - Optional Sidekiq introspection tools, `sidekiq_stats` and `sidekiq_retries`, wired to a stats
59
66
  provider object you supply.
60
67
  - No reference to a constant it does not own: field serializers are keyed on class names, dashboards
@@ -151,13 +158,20 @@ bin/rails db:migrate
151
158
 
152
159
  The tables are `administrate_mcp_api_keys`, `administrate_mcp_feedbacks`,
153
160
  `administrate_mcp_oauth_applications`, `administrate_mcp_oauth_access_grants` and
154
- `administrate_mcp_oauth_access_tokens`. They use uuid primary keys and a uuid `admin_id` column
155
- that is indexed but carries no foreign key constraint, so the engine works with any admin table.
161
+ `administrate_mcp_oauth_access_tokens`. They use uuid primary keys and a uuid column, `admin_id` by
162
+ default, that is indexed but carries no foreign key constraint, so the engine works with any admin
163
+ table. Set `config.admin_foreign_key` before running the migrations if the host's own admin table
164
+ already uses a different column name and renaming it is not an option, for example a live
165
+ credentials table.
166
+
167
+ `administrate_mcp_feedbacks` is only needed when `config.persist_feedback` is `true`; leave it
168
+ `false`, the default, and the migration ships but the table is never read from or written to.
156
169
 
157
170
  ## Quick start
158
171
 
159
172
  The smallest configuration that works. Save it as `config/initializers/administrate_mcp.rb`; it
160
- must run before the engine's models load, because the `admin` association reads `admin_class_name`:
173
+ must run before the engine's models load, because the `admin` association reads `admin_class_name`
174
+ and `admin_foreign_key`:
161
175
 
162
176
  ```ruby
163
177
  Administrate::MCP.configure do |c|
@@ -218,6 +232,35 @@ with the gem for hosts that run edge-managed OAuth in front of the application:
218
232
  The built-in OAuth 2.1 server, what turning it off with `c.oauth = false` changes, and when a host
219
233
  should: [docs/oauth.md](docs/oauth.md).
220
234
 
235
+ ## Improving the server from its own use
236
+
237
+ Every tool here is built from your dashboards: the resource names, the field lists, the filters and
238
+ the `MCP_DESCRIPTION` you wrote. The client calling those tools is the one that gets misled when any
239
+ of it is wrong, and it is the only party that knows which call it was trying to make. The engine has
240
+ no way to detect this on its own: a vague description is not an error, it is a successful call that
241
+ returned the wrong thing or a query the caller gave up on.
242
+
243
+ `report_mcp_improvement` is how the client tells you. Its categories are deliberately not free text.
244
+ Each one names a change you make in a dashboard:
245
+
246
+ | Category | What it points at |
247
+ | --- | --- |
248
+ | `description` | `MCP_DESCRIPTION` is missing, vague or actively misleading |
249
+ | `missing_filter` | the query needed a filter the dashboard does not declare |
250
+ | `missing_field` | a field the caller needed is not on the show page |
251
+ | `missing_resource` | a dashboard is not exposed, or does not exist |
252
+ | `serialization` | a field came back unreadable and needs a serializer registered |
253
+ | `other` | anything the categories above do not cover |
254
+
255
+ A report arrives with the category, the resource it concerns and the client's own account of what it
256
+ wanted, which is most of a change request already. Wire `config.on_feedback` to somewhere your team
257
+ will actually read, work through what arrives, and the next caller gets a server that describes
258
+ itself better. Turning on `config.persist_feedback` keeps the reports in a table so you can triage a
259
+ batch at a time rather than react to each one.
260
+
261
+ This is the loop the tool exists for. It is worth running deliberately rather than waiting for
262
+ complaints: point a client at the server, give it real tasks, and collect what it could not do.
263
+
221
264
  ## Rate limiting
222
265
 
223
266
  The rack-attack throttles recommended for the OAuth endpoints, and the helper that registers them
@@ -225,8 +268,9 @@ for you: [docs/oauth.md#rate-limiting](docs/oauth.md#rate-limiting).
225
268
 
226
269
  ## Admin integration
227
270
 
228
- Exposing the engine's own tables (API keys, feedback) in your host admin, customising the consent
229
- screen, and cleaning up old feedback: [docs/admin-integration.md](docs/admin-integration.md).
271
+ Including the API key and feedback consoles the engine ships, what a host overrides in them,
272
+ listing the engine's own tables over the protocol, customising the consent screen, and cleaning up
273
+ old feedback: [docs/admin-integration.md](docs/admin-integration.md).
230
274
 
231
275
  ## Development
232
276
 
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Administrate
4
+ module MCP
5
+ # Administrate console for the API keys admins authenticate to the MCP server with. The host
6
+ # keeps its own base controller, authentication and policies; this supplies the resource wiring
7
+ # and the two actions a write-once credential needs, because the plaintext key exists only in
8
+ # the response that creates it.
9
+ #
10
+ # A host that lets some admins mint write-enabled keys overrides `mcp_write_access_allowed?`.
11
+ module ApiKeysAdmin
12
+ extend ActiveSupport::Concern
13
+ include ResourceController
14
+
15
+ TOKEN_PREFIX_LENGTH = 13
16
+ WRITE_ACCESS_REFUSAL = 'Only administrators allowed to grant write access can create write-enabled API keys.'
17
+
18
+ included do
19
+ administrate_mcp_resource Administrate::MCP::ApiKey
20
+ end
21
+
22
+ def create
23
+ token = Administrate::MCP::ApiKey.generate_token
24
+ api_key = build_mcp_api_key(token)
25
+ authorize_mcp_resource(api_key)
26
+
27
+ if api_key.write_access? && !mcp_write_access_allowed?
28
+ return render_mcp_form_error(api_key, WRITE_ACCESS_REFUSAL)
29
+ end
30
+
31
+ if api_key.save
32
+ redirect_to_mcp_index("MCP API key created. Copy it now, it is not shown again: #{token}")
33
+ else
34
+ render_mcp_form_error(api_key, api_key.errors.full_messages.join(', '))
35
+ end
36
+ end
37
+
38
+ def destroy
39
+ api_key = requested_resource
40
+ authorize_mcp_resource(api_key)
41
+ api_key.revoke!
42
+
43
+ redirect_to_mcp_index("API key '#{api_key.name}' has been revoked.")
44
+ end
45
+
46
+ private
47
+
48
+ def build_mcp_api_key(token)
49
+ Administrate::MCP::ApiKey.new(
50
+ admin: mcp_api_key_owner,
51
+ token_digest: Administrate::MCP::ApiKey.digest_token(token),
52
+ token_prefix: token[0, TOKEN_PREFIX_LENGTH],
53
+ name: mcp_api_key_name,
54
+ write_access: mcp_write_access_requested?
55
+ )
56
+ end
57
+
58
+ def mcp_api_key_owner
59
+ Administrate::MCP.config.current_admin.call(self)
60
+ end
61
+
62
+ def mcp_api_key_name
63
+ params.dig(resource_name, :name).presence || 'Unnamed key'
64
+ end
65
+
66
+ def mcp_write_access_requested?
67
+ ActiveModel::Type::Boolean.new.cast(params.dig(resource_name, :write_access)) || false
68
+ end
69
+
70
+ # A write-enabled key carries every write tool, so granting one is a decision the host makes.
71
+ def mcp_write_access_allowed?
72
+ false
73
+ end
74
+
75
+ def authorize_mcp_resource(api_key)
76
+ authorize(api_key) if respond_to?(:authorize, true)
77
+ end
78
+
79
+ def redirect_to_mcp_index(notice)
80
+ redirect_to({ action: :index }, notice:, status: :see_other)
81
+ end
82
+
83
+ def render_mcp_form_error(api_key, message)
84
+ flash.now[:error] = message
85
+ render :new, locals: { page: Administrate::Page::Form.new(dashboard, api_key) }, status: :unprocessable_content
86
+ end
87
+
88
+ def scoped_resource
89
+ super.order(created_at: :desc)
90
+ end
91
+ end
92
+ end
93
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Administrate
4
+ module MCP
5
+ # Administrate console for the improvement reports `report_mcp_improvement` stores. Reading them
6
+ # is the whole point, so no action is overridden beyond the resource wiring and the ordering:
7
+ # the newest report is the one a maintainer has not acted on yet.
8
+ #
9
+ # Only meaningful with `config.persist_feedback` on; without it nothing writes to the table.
10
+ module FeedbacksAdmin
11
+ extend ActiveSupport::Concern
12
+ include ResourceController
13
+
14
+ included do
15
+ administrate_mcp_resource Administrate::MCP::Feedback
16
+ end
17
+
18
+ private
19
+
20
+ def scoped_resource
21
+ super.order(created_at: :desc)
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Administrate
4
+ module MCP
5
+ # Points an Administrate controller at a model the engine owns, rather than at the one
6
+ # Administrate would infer from the controller's own name.
7
+ #
8
+ # The dashboard is left alone on purpose. Administrate finds it by camelizing the controller's
9
+ # name, and a host that registers MCP as an inflection acronym spells that constant differently
10
+ # from one that does not. Naming it here would be right in one host and an autoloading error in
11
+ # the other, so each host declares its own dashboard, usually a subclass of the engine's.
12
+ module ResourceController
13
+ extend ActiveSupport::Concern
14
+
15
+ included do
16
+ class_attribute :mcp_resource_class, :mcp_dashboard_class
17
+ end
18
+
19
+ class_methods do
20
+ # `dashboard` is only needed for a dashboard Administrate cannot find from the controller's
21
+ # own name.
22
+ def administrate_mcp_resource(resource_class, dashboard: nil)
23
+ self.mcp_resource_class = resource_class
24
+ self.mcp_dashboard_class = dashboard
25
+ end
26
+ end
27
+
28
+ def resource_class
29
+ mcp_resource_class
30
+ end
31
+
32
+ private
33
+
34
+ def resource_resolver
35
+ @resource_resolver ||=
36
+ Administrate::MCP::ResourceResolver.new(
37
+ controller_path,
38
+ resource_class: mcp_resource_class,
39
+ dashboard_class: mcp_dashboard_class
40
+ )
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'administrate/base_dashboard'
4
+
5
+ module Administrate
6
+ module MCP
7
+ # Default Administrate dashboard for the engine's API keys. A host that wants other columns
8
+ # subclasses it and redefines the constants it cares about.
9
+ #
10
+ # Nothing here is registered as an MCP resource: the registry only reads the host's own
11
+ # dashboard directory, so a host that wants the keys listed over the protocol declares a
12
+ # subclass there with an `MCP_DESCRIPTION`.
13
+ class ApiKeyDashboard < Administrate::BaseDashboard
14
+ ATTRIBUTE_TYPES = {
15
+ id: Field::String.with_options(searchable: false),
16
+ admin: Field::BelongsTo.with_options(class_name: Administrate::MCP.config.admin_class_name),
17
+ name: Field::String,
18
+ token_prefix: Field::String,
19
+ write_access: Field::Boolean,
20
+ last_used_at: Field::DateTime,
21
+ revoked_at: Field::DateTime,
22
+ created_at: Field::DateTime,
23
+ updated_at: Field::DateTime
24
+ }.freeze
25
+
26
+ COLLECTION_ATTRIBUTES = %i[name admin token_prefix write_access last_used_at revoked_at].freeze
27
+
28
+ SHOW_PAGE_ATTRIBUTES = %i[
29
+ id
30
+ admin
31
+ name
32
+ token_prefix
33
+ write_access
34
+ last_used_at
35
+ revoked_at
36
+ created_at
37
+ updated_at
38
+ ].freeze
39
+
40
+ FORM_ATTRIBUTES = %i[name write_access].freeze
41
+
42
+ COLLECTION_FILTERS = {}.freeze
43
+
44
+ def self.model
45
+ Administrate::MCP::ApiKey
46
+ end
47
+
48
+ def display_resource(api_key)
49
+ "#{api_key.name} (#{api_key.token_prefix}...)"
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'administrate/base_dashboard'
4
+
5
+ module Administrate
6
+ module MCP
7
+ # Default Administrate dashboard for the improvement reports the feedback tool stores. A host
8
+ # that wants other columns subclasses it and redefines the constants it cares about.
9
+ class FeedbackDashboard < Administrate::BaseDashboard
10
+ ATTRIBUTE_TYPES = {
11
+ id: Field::String.with_options(searchable: false),
12
+ admin: Field::BelongsTo.with_options(class_name: Administrate::MCP.config.admin_class_name),
13
+ api_key: Field::BelongsTo.with_options(class_name: 'Administrate::MCP::ApiKey'),
14
+ category: Field::Select.with_options(collection: -> { Administrate::MCP::Feedback.categories.keys }),
15
+ resource_name: Field::String,
16
+ suggestion: Field::Text,
17
+ status: Field::Select.with_options(collection: -> { Administrate::MCP::Feedback.statuses.keys }),
18
+ created_at: Field::DateTime,
19
+ updated_at: Field::DateTime
20
+ }.freeze
21
+
22
+ COLLECTION_ATTRIBUTES = %i[category resource_name status admin created_at].freeze
23
+
24
+ SHOW_PAGE_ATTRIBUTES = %i[id admin api_key category resource_name suggestion status created_at updated_at].freeze
25
+
26
+ # Triaging a report is the one edit worth making from the console.
27
+ FORM_ATTRIBUTES = %i[status].freeze
28
+
29
+ COLLECTION_FILTERS = {
30
+ status: ->(resources, status) { resources.where(status:) },
31
+ category: ->(resources, category) { resources.where(category:) }
32
+ }.freeze
33
+
34
+ def self.model
35
+ Administrate::MCP::Feedback
36
+ end
37
+
38
+ def display_resource(feedback)
39
+ "#{feedback.category} on #{feedback.resource_name.presence || 'the server'}"
40
+ end
41
+ end
42
+ end
43
+ end
@@ -1,10 +1,14 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'did_you_mean'
4
+
3
5
  module Administrate
4
6
  module MCP
5
7
  # Base class for MCP tools that operate on Administrate dashboard resources.
6
8
  class AdminDashboardTool < BaseTool
7
9
  LISTED_VALID_VALUES = 40
10
+ MAX_SUGGESTIONS = 5
11
+ ACTION_VERBS = { index?: 'list', show?: 'show' }.freeze
8
12
 
9
13
  class << self
10
14
  def policy_action
@@ -14,7 +18,7 @@ module Administrate
14
18
  def check_roles!(admin, resource: nil, **)
15
19
  return unless resource && policy_action
16
20
 
17
- entry = find_dashboard_entry!(resource)
21
+ entry = find_dashboard_entry!(admin, resource)
18
22
  authorize_resource!(admin, entry.model_class, policy_action)
19
23
  end
20
24
 
@@ -22,15 +26,57 @@ module Administrate
22
26
  Administrate::MCP.config.authorization.authorized?(admin, model_class, action)
23
27
  end
24
28
 
29
+ # The adapter is the host's and raises a plain UnauthorizedError. Re-raised here so a denial on
30
+ # one resource reaches the caller as a tool error instead of a 403 on the whole response. A
31
+ # message the host wrote is kept, since it may say why; the adapters' generic one adds nothing.
25
32
  def authorize_resource!(admin, model_class, action)
26
33
  Administrate::MCP.config.authorization.authorize!(admin, model_class, action)
34
+ rescue UnauthorizedError => e
35
+ verb = ACTION_VERBS.fetch(action.to_sym) { action.to_s.delete_suffix('?') }
36
+ message = "Resource `#{model_class.name.underscore}` exists but you are not authorized to #{verb} it."
37
+ reason = e.message unless e.message == Authorization::Base.denial_message(action, model_class.name)
38
+ raise ResourceForbiddenError, [message, reason].compact.join(' ')
27
39
  end
28
40
 
29
- def find_dashboard_entry!(resource_name)
41
+ def find_dashboard_entry!(admin, resource_name)
30
42
  entry = DashboardRegistry.find(resource_name)
31
43
  return entry if entry
32
44
 
33
- raise UnauthorizedError, "Unknown resource: #{resource_name}"
45
+ raise InvalidArgumentError, unknown_resource_message(admin, resource_name)
46
+ end
47
+
48
+ def unknown_resource_message(admin, resource_name)
49
+ message = "Unknown resource: #{resource_name}. This name is not registered."
50
+ suggestions = resource_suggestions(admin, resource_name)
51
+ return "#{message} Call admin_resource_list_resources for the catalog." if suggestions.empty?
52
+
53
+ "#{message} Closest registered names: #{suggestions.join(', ')}"
54
+ end
55
+
56
+ # Only names the caller can read are suggested, as in the catalog, so a typo does not reveal
57
+ # hidden resources.
58
+ def resource_suggestions(admin, resource_name)
59
+ suggestion_candidates(resource_name).lazy.select { |name| readable?(admin, name) }.first(MAX_SUGGESTIONS)
60
+ end
61
+
62
+ # Matches on the last path segment too, so a bare "order" finds "shop/order".
63
+ def suggestion_candidates(resource_name)
64
+ key = resource_name.to_s.underscore.singularize
65
+ names = DashboardRegistry.resource_names
66
+ by_segment = names.group_by { |name| name.split('/').last }
67
+ segment = key.split('/').last.to_s
68
+ close_segments = DidYouMean::SpellChecker.new(dictionary: by_segment.keys).correct(segment)
69
+
70
+ (
71
+ by_segment.fetch(segment, []) +
72
+ DidYouMean::SpellChecker.new(dictionary: names).correct(key) +
73
+ close_segments.flat_map { |name| by_segment[name] }
74
+ ).uniq
75
+ end
76
+
77
+ def readable?(admin, resource_name)
78
+ entry = DashboardRegistry.find(resource_name)
79
+ entry && authorized?(admin, entry.model_class, :index?)
34
80
  end
35
81
 
36
82
  def reject_unknown!(kind, given, allowed)
@@ -5,6 +5,7 @@ module Administrate
5
5
  # Base class for all MCP tools — provides role gating, auditing, and response helpers.
6
6
  class BaseTool < ::MCP::Tool
7
7
  UnauthorizedError = Administrate::MCP::UnauthorizedError
8
+ ResourceForbiddenError = Administrate::MCP::ResourceForbiddenError
8
9
  InvalidArgumentError = Administrate::MCP::InvalidArgumentError
9
10
 
10
11
  class << self
@@ -26,11 +27,11 @@ module Administrate
26
27
 
27
28
  def call(server_context:, **args)
28
29
  instrument(server_context) { call_with_context(server_context, **args) }
30
+ rescue ResourceForbiddenError, InvalidArgumentError => e
31
+ error_response(e.message)
29
32
  rescue UnauthorizedError => e
30
33
  (server_context[:authorization_errors] ||= []).push(e.message)
31
34
  error_response(e.message)
32
- rescue InvalidArgumentError => e
33
- error_response(e.message)
34
35
  rescue StandardError => e
35
36
  error_response("Internal error: #{e.message}")
36
37
  end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Administrate
4
+ module MCP
5
+ # The category list behind `Feedback#category` and the `report_mcp_improvement` schema, kept
6
+ # independent of the model so validation and the tool's input schema work whether or not the
7
+ # `administrate_mcp_feedbacks` table exists.
8
+ module FeedbackCategories
9
+ CATEGORIES = {
10
+ 'description' => 0,
11
+ 'missing_filter' => 1,
12
+ 'missing_field' => 2,
13
+ 'missing_resource' => 3,
14
+ 'serialization' => 4,
15
+ 'other' => 5
16
+ }.freeze
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Administrate
4
+ module MCP
5
+ # Plain report of an MCP improvement suggestion, handed to `config.on_feedback` whether or not
6
+ # `config.persist_feedback` is on. `record` and `api_key` are only set when it is, so a host that
7
+ # wants the persisted row can reach it; the report itself never needs the `Feedback` table to
8
+ # exist.
9
+ FeedbackReport = Struct.new(:admin, :category, :suggestion, :resource_name, :api_key, :record, keyword_init: true)
10
+ end
11
+ end
@@ -2,9 +2,12 @@
2
2
 
3
3
  module Administrate
4
4
  module MCP
5
- # Persists an MCP improvement suggestion and hands it to the host's `on_feedback` hook.
5
+ # Validates an MCP improvement suggestion and hands a plain report to the host's `on_feedback`
6
+ # hook. Persisting the report to the `Feedback` table is opt-in through `config.persist_feedback`;
7
+ # when that is off, nothing here touches `Feedback` or `ApiKey`, so a host that never turns it on
8
+ # need not carry the table at all.
6
9
  class ReportImprovement
7
- Result = Struct.new(:success?, :feedback, :errors, keyword_init: true)
10
+ Result = Struct.new(:success?, :report, :errors, keyword_init: true)
8
11
 
9
12
  def self.call(...)
10
13
  new(...).call
@@ -19,11 +22,11 @@ module Administrate
19
22
 
20
23
  def call
21
24
  errors = validation_errors
22
- return Result.new(success?: false, feedback: nil, errors:) if errors.any?
25
+ return Result.new(success?: false, report: nil, errors:) if errors.any?
23
26
 
24
- feedback = create_feedback
25
- notify(feedback)
26
- Result.new(success?: true, feedback:, errors: [])
27
+ report = build_report
28
+ notify(report)
29
+ Result.new(success?: true, report:, errors: [])
27
30
  end
28
31
 
29
32
  private
@@ -32,13 +35,22 @@ module Administrate
32
35
 
33
36
  def validation_errors
34
37
  errors = []
35
- unless Feedback.categories.key?(category.to_s)
36
- errors << "category must be one of: #{Feedback.categories.keys.join(', ')}"
38
+ unless FeedbackCategories::CATEGORIES.key?(category.to_s)
39
+ errors << "category must be one of: #{FeedbackCategories::CATEGORIES.keys.join(', ')}"
37
40
  end
38
41
  errors << "suggestion can't be blank" if suggestion.blank?
39
42
  errors
40
43
  end
41
44
 
45
+ def build_report
46
+ record = create_feedback if persist_feedback?
47
+ FeedbackReport.new(admin:, category:, suggestion:, resource_name:, record:, api_key: record&.api_key)
48
+ end
49
+
50
+ def persist_feedback?
51
+ Administrate::MCP.config.persist_feedback
52
+ end
53
+
42
54
  def create_feedback
43
55
  Feedback.create!(admin:, api_key: latest_api_key, category:, resource_name:, suggestion:)
44
56
  end
@@ -47,9 +59,8 @@ module Administrate
47
59
  ApiKey.active.where(admin:).order(last_used_at: :desc).first
48
60
  end
49
61
 
50
- # The feedback is already persisted; a broken notifier must not take the report down with it.
51
- def notify(feedback)
52
- Administrate::MCP.config.on_feedback.call(feedback)
62
+ def notify(report)
63
+ Administrate::MCP.config.on_feedback.call(report)
53
64
  rescue StandardError => e
54
65
  Administrate::MCP.config.on_error.call(e)
55
66
  end