administrate-mcp 0.1.0 → 0.2.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 +33 -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/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 +3 -1
- 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/configuration.rb +7 -1
- data/lib/administrate/mcp/version.rb +1 -1
- metadata +9 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 68fb06b806eb2dea5dbb0e0a01a865b4a9984b515a358bfd86fab8d0b949b65c
|
|
4
|
+
data.tar.gz: ac0a3ef739bf776dff2325bc3ae641e22b694047ffd03d078c944810b2071768
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 1d6204806b8c490362a1e4a56939a3ae588897f069f7c1e21c4ff653eaed486d06cd238e3243d3c3a29edcc2399a7aef25deed80022e598f8b52fc36a7fc7895
|
|
7
|
+
data.tar.gz: 2064c7b7ad08e21f32eba4db06ebda81d594e1abf0c731ddf369a901282ce419c923eea77715faa1c25ae44601809e4f3195d55fc539c91e90ae5b2d4107eca1
|
data/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,37 @@ minor release may change behaviour a host depends on; the entry says so when it
|
|
|
8
8
|
|
|
9
9
|
## [Unreleased]
|
|
10
10
|
|
|
11
|
+
## [0.2.0] - 2026-09-22
|
|
12
|
+
|
|
13
|
+
### Added
|
|
14
|
+
|
|
15
|
+
- The admin console for the engine's own tables: the `Administrate::MCP::ApiKeyDashboard` and
|
|
16
|
+
`Administrate::MCP::FeedbackDashboard` base dashboards, and the `Administrate::MCP::ApiKeysAdmin`
|
|
17
|
+
and `Administrate::MCP::FeedbacksAdmin` controller concerns. A host subclasses each dashboard
|
|
18
|
+
under the name its own inflections give `administrate_mcp_api_key_dashboard.rb`, which differs
|
|
19
|
+
between an application that registers MCP as an acronym and one that does not. The concern goes in
|
|
20
|
+
a controller of the host's own, which keeps its base controller, authentication and policies; the
|
|
21
|
+
engine supplies the resource wiring, key generation, the one-time plaintext in the flash, and
|
|
22
|
+
revocation. Write-enabled keys are refused until the host overrides `mcp_write_access_allowed?`.
|
|
23
|
+
- `config.admin_foreign_key`, defaulting to `:admin_id`, so a host whose admin table already has a
|
|
24
|
+
differently named foreign key can keep that column name instead of renaming it. The association
|
|
25
|
+
stays called `admin` everywhere in the engine's API; only the column it reads and writes changes.
|
|
26
|
+
- `config.persist_feedback`, defaulting to `false`, so a host can use `report_mcp_improvement` and
|
|
27
|
+
`on_feedback` as a plain signal without carrying the `administrate_mcp_feedbacks` table, its model
|
|
28
|
+
or its migration. Set it to `true` for the previous behaviour: the report is persisted before
|
|
29
|
+
`on_feedback` runs, and the dashboard and `CleanOldFeedbacks` housekeeping become meaningful.
|
|
30
|
+
- `config.feedback_tool`, defaulting to `true`, to stop publishing `report_mcp_improvement`
|
|
31
|
+
altogether.
|
|
32
|
+
|
|
33
|
+
### Changed
|
|
34
|
+
|
|
35
|
+
- `on_feedback` is now called with a `FeedbackReport`, a plain value object, instead of a persisted
|
|
36
|
+
`Feedback` record. A host reading attributes off the argument (`category`, `suggestion`,
|
|
37
|
+
`resource_name`, `admin`, `api_key`) sees no difference; a host that relied on it being an
|
|
38
|
+
`ActiveRecord` instance, for example calling `.save`, `.update` or `.reload` on it, or expecting
|
|
39
|
+
`.persisted?` to be true, must update its `on_feedback` hook. When `config.persist_feedback` is
|
|
40
|
+
`true` the persisted record is still reachable, as `report.record`.
|
|
41
|
+
|
|
11
42
|
## [0.1.0] - 2026-09-22
|
|
12
43
|
|
|
13
44
|
### Added
|
|
@@ -57,5 +88,6 @@ minor release may change behaviour a host depends on; the entry says so when it
|
|
|
57
88
|
took the gem from git before 0.1.0 must reinstall its migrations rather than migrate
|
|
58
89
|
incrementally.
|
|
59
90
|
|
|
60
|
-
[Unreleased]: https://github.com/sorare/administrate-mcp/compare/v0.
|
|
91
|
+
[Unreleased]: https://github.com/sorare/administrate-mcp/compare/v0.2.0...HEAD
|
|
92
|
+
[0.2.0]: https://github.com/sorare/administrate-mcp/compare/v0.1.0...v0.2.0
|
|
61
93
|
[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
|
|
57
|
-
|
|
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`
|
|
155
|
-
that is indexed but carries no foreign key constraint, so the engine works with any admin
|
|
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
|
-
|
|
229
|
-
screen, and cleaning up
|
|
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
|
|
@@ -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
|
-
#
|
|
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?, :
|
|
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,
|
|
25
|
+
return Result.new(success?: false, report: nil, errors:) if errors.any?
|
|
23
26
|
|
|
24
|
-
|
|
25
|
-
notify(
|
|
26
|
-
Result.new(success?: true,
|
|
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
|
|
36
|
-
errors << "category must be one of: #{
|
|
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
|
-
|
|
51
|
-
|
|
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
|
|
@@ -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'
|
|
@@ -46,6 +47,7 @@ module Administrate
|
|
|
46
47
|
|
|
47
48
|
def built_in_tools
|
|
48
49
|
names = BUILT_IN_TOOLS.dup
|
|
50
|
+
names << REPORT_IMPROVEMENT_TOOL if Administrate::MCP.config.feedback_tool
|
|
49
51
|
names << SIDEKIQ_RETRIES_TOOL if defined?(::Sidekiq)
|
|
50
52
|
names << SIDEKIQ_STATS_TOOL if defined?(::Sidekiq) && Administrate::MCP.config.sidekiq_stats_provider
|
|
51
53
|
names.filter_map(&:safe_constantize)
|
|
@@ -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` |
|
|
@@ -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
|
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: administrate-mcp
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.2.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Sorare
|
|
@@ -84,6 +84,11 @@ files:
|
|
|
84
84
|
- README.md
|
|
85
85
|
- app/controllers/administrate/mcp/json_rpc_controller.rb
|
|
86
86
|
- app/controllers/administrate/mcp/o_auth_controller.rb
|
|
87
|
+
- app/controllers/concerns/administrate/mcp/api_keys_admin.rb
|
|
88
|
+
- app/controllers/concerns/administrate/mcp/feedbacks_admin.rb
|
|
89
|
+
- app/controllers/concerns/administrate/mcp/resource_controller.rb
|
|
90
|
+
- app/dashboards/administrate/mcp/api_key_dashboard.rb
|
|
91
|
+
- app/dashboards/administrate/mcp/feedback_dashboard.rb
|
|
87
92
|
- app/lib/administrate/mcp/actions.rb
|
|
88
93
|
- app/lib/administrate/mcp/admin_dashboard_tool.rb
|
|
89
94
|
- app/lib/administrate/mcp/authentication.rb
|
|
@@ -91,9 +96,12 @@ files:
|
|
|
91
96
|
- app/lib/administrate/mcp/clean_old_feedbacks.rb
|
|
92
97
|
- app/lib/administrate/mcp/dashboard_registry.rb
|
|
93
98
|
- app/lib/administrate/mcp/fast_search.rb
|
|
99
|
+
- app/lib/administrate/mcp/feedback_categories.rb
|
|
100
|
+
- app/lib/administrate/mcp/feedback_report.rb
|
|
94
101
|
- app/lib/administrate/mcp/field_serializer.rb
|
|
95
102
|
- app/lib/administrate/mcp/o_auth_service.rb
|
|
96
103
|
- app/lib/administrate/mcp/report_improvement.rb
|
|
104
|
+
- app/lib/administrate/mcp/resource_resolver.rb
|
|
97
105
|
- app/lib/administrate/mcp/server_builder.rb
|
|
98
106
|
- app/lib/administrate/mcp/tools/admin_resource_list.rb
|
|
99
107
|
- app/lib/administrate/mcp/tools/admin_resource_list_resources.rb
|