administrate-mcp 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +61 -0
- data/LICENSE.txt +21 -0
- data/README.md +267 -0
- data/app/controllers/administrate/mcp/json_rpc_controller.rb +83 -0
- data/app/controllers/administrate/mcp/o_auth_controller.rb +199 -0
- data/app/lib/administrate/mcp/actions.rb +170 -0
- data/app/lib/administrate/mcp/admin_dashboard_tool.rb +114 -0
- data/app/lib/administrate/mcp/authentication.rb +146 -0
- data/app/lib/administrate/mcp/base_tool.rb +93 -0
- data/app/lib/administrate/mcp/clean_old_feedbacks.rb +26 -0
- data/app/lib/administrate/mcp/dashboard_registry.rb +102 -0
- data/app/lib/administrate/mcp/fast_search.rb +47 -0
- data/app/lib/administrate/mcp/field_serializer.rb +284 -0
- data/app/lib/administrate/mcp/o_auth_service.rb +103 -0
- data/app/lib/administrate/mcp/report_improvement.rb +58 -0
- data/app/lib/administrate/mcp/server_builder.rb +70 -0
- data/app/lib/administrate/mcp/tools/admin_resource_list.rb +194 -0
- data/app/lib/administrate/mcp/tools/admin_resource_list_resources.rb +107 -0
- data/app/lib/administrate/mcp/tools/admin_resource_show.rb +130 -0
- data/app/lib/administrate/mcp/tools/report_improvement.rb +43 -0
- data/app/lib/administrate/mcp/tools/sidekiq_retries.rb +50 -0
- data/app/lib/administrate/mcp/tools/sidekiq_stats.rb +75 -0
- data/app/models/administrate/mcp/api_key.rb +59 -0
- data/app/models/administrate/mcp/application_record.rb +23 -0
- data/app/models/administrate/mcp/feedback.rb +20 -0
- data/app/models/administrate/mcp/o_auth_access_grant.rb +76 -0
- data/app/models/administrate/mcp/o_auth_access_token.rb +84 -0
- data/app/models/administrate/mcp/o_auth_application.rb +64 -0
- data/app/views/administrate/mcp/o_auth/authorize.html.erb +63 -0
- data/config/routes.rb +6 -0
- data/db/migrate/20260101000001_create_administrate_model_context_protocol_api_keys.rb +20 -0
- data/db/migrate/20260101000002_create_administrate_model_context_protocol_feedbacks.rb +19 -0
- data/db/migrate/20260101000003_create_administrate_model_context_protocol_authorization_tables.rb +52 -0
- data/docs/admin-integration.md +56 -0
- data/docs/authentication.md +116 -0
- data/docs/configuration.md +220 -0
- data/docs/dashboards.md +50 -0
- data/docs/development.md +19 -0
- data/docs/oauth.md +54 -0
- data/docs/routes.md +30 -0
- data/lib/administrate/mcp/authorization/base.rb +45 -0
- data/lib/administrate/mcp/authorization/permissive.rb +13 -0
- data/lib/administrate/mcp/authorization/pundit.rb +32 -0
- data/lib/administrate/mcp/cloudflare_access.rb +140 -0
- data/lib/administrate/mcp/configuration.rb +156 -0
- data/lib/administrate/mcp/dashboard_extension.rb +25 -0
- data/lib/administrate/mcp/engine.rb +21 -0
- data/lib/administrate/mcp/errors.rb +21 -0
- data/lib/administrate/mcp/loopback_uri.rb +18 -0
- data/lib/administrate/mcp/rack_attack.rb +39 -0
- data/lib/administrate/mcp/routes.rb +48 -0
- data/lib/administrate/mcp/version.rb +7 -0
- data/lib/administrate/mcp.rb +35 -0
- data/lib/administrate-mcp.rb +3 -0
- metadata +160 -0
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Administrate
|
|
4
|
+
module MCP
|
|
5
|
+
module Tools
|
|
6
|
+
# Lists/searches admin resources using Administrate dashboard definitions and search.
|
|
7
|
+
class AdminResourceList < AdminDashboardTool
|
|
8
|
+
MAX_PER_PAGE = 25
|
|
9
|
+
MAX_EXPAND = 2
|
|
10
|
+
|
|
11
|
+
def self.policy_action
|
|
12
|
+
:index?
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
tool_name 'admin_resource_list'
|
|
16
|
+
description 'Search and list admin resources. ' \
|
|
17
|
+
'Supports text search, filters, sorting, field selection, and HasMany expansion.'
|
|
18
|
+
annotations read_only_hint: true, destructive_hint: false, open_world_hint: true
|
|
19
|
+
|
|
20
|
+
input_schema(
|
|
21
|
+
properties: {
|
|
22
|
+
resource: {
|
|
23
|
+
type: 'string',
|
|
24
|
+
description:
|
|
25
|
+
'Resource type name (e.g., "card", "user", "player"). ' \
|
|
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.'
|
|
31
|
+
},
|
|
32
|
+
query: {
|
|
33
|
+
type: 'string',
|
|
34
|
+
description:
|
|
35
|
+
'Search query (optional). Searches across searchable string fields ' \
|
|
36
|
+
'(e.g., slug, display_name). Uses exact matching by default — ' \
|
|
37
|
+
'use * as wildcard for partial matches (e.g., "*stellar*ligue*").'
|
|
38
|
+
},
|
|
39
|
+
filters: {
|
|
40
|
+
type: 'object',
|
|
41
|
+
description:
|
|
42
|
+
'Filters to apply (optional). Keys are filter names, values are filter arguments. ' \
|
|
43
|
+
'Use admin_resource_list_resources to discover available filters per resource.'
|
|
44
|
+
},
|
|
45
|
+
sort: {
|
|
46
|
+
type: 'string',
|
|
47
|
+
description: 'Field to sort by (e.g., "created_at", "ranking"). Must be a valid dashboard attribute.'
|
|
48
|
+
},
|
|
49
|
+
sort_direction: {
|
|
50
|
+
type: 'string',
|
|
51
|
+
enum: %w[asc desc],
|
|
52
|
+
description: 'Sort direction (default: desc)'
|
|
53
|
+
},
|
|
54
|
+
fields: {
|
|
55
|
+
type: 'array',
|
|
56
|
+
items: {
|
|
57
|
+
type: 'string'
|
|
58
|
+
},
|
|
59
|
+
description: 'Specific fields to return (optional). Returns all collection fields if omitted.'
|
|
60
|
+
},
|
|
61
|
+
expand: {
|
|
62
|
+
type: 'array',
|
|
63
|
+
items: {
|
|
64
|
+
type: 'string'
|
|
65
|
+
},
|
|
66
|
+
description:
|
|
67
|
+
'HasMany associations to expand inline instead of counts. Max 2 associations. An expanded ' \
|
|
68
|
+
'association returns {count, items}: count is the association total, and items is capped at ' \
|
|
69
|
+
'25 rows. Both ship in the same response, so always compare them — count > items.length ' \
|
|
70
|
+
'means the list is clipped and items is NOT the full set. items also come back in no ' \
|
|
71
|
+
'guaranteed order: the sort_by / direction declared on the dashboard attribute is not ' \
|
|
72
|
+
'applied on this path, so a clipped expand is an arbitrary slice and not the newest rows — ' \
|
|
73
|
+
'reading recency off it can report a year-old row as the latest one. There is no way to page ' \
|
|
74
|
+
'past the cap or to sort here: when you need the remaining rows, or the most recent ones, ' \
|
|
75
|
+
'list the related resource directly with a filter on this record and an explicit sort.'
|
|
76
|
+
},
|
|
77
|
+
page: {
|
|
78
|
+
type: 'number',
|
|
79
|
+
description: 'Page number (default: 1)'
|
|
80
|
+
},
|
|
81
|
+
per_page: {
|
|
82
|
+
type: 'number',
|
|
83
|
+
description: "Results per page (default: 10, max: #{MAX_PER_PAGE})"
|
|
84
|
+
}
|
|
85
|
+
},
|
|
86
|
+
required: %w[resource]
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
# rubocop:disable-next Lint/UnusedMethodArgument
|
|
90
|
+
def self.execute(
|
|
91
|
+
admin:,
|
|
92
|
+
resource:,
|
|
93
|
+
query: nil,
|
|
94
|
+
filters: nil,
|
|
95
|
+
sort: nil,
|
|
96
|
+
sort_direction: 'desc',
|
|
97
|
+
fields: nil,
|
|
98
|
+
expand: nil,
|
|
99
|
+
page: 1,
|
|
100
|
+
per_page: 10
|
|
101
|
+
)
|
|
102
|
+
entry = find_dashboard_entry!(resource)
|
|
103
|
+
dashboard = entry.dashboard_class.new
|
|
104
|
+
per_page = per_page.to_i.clamp(1, MAX_PER_PAGE)
|
|
105
|
+
page = [page.to_i, 1].max
|
|
106
|
+
scope = build_scope(entry, dashboard, query, filters)
|
|
107
|
+
scope = apply_sort(scope, dashboard, sort, sort_direction)
|
|
108
|
+
records = scope.page(page).per(per_page)
|
|
109
|
+
|
|
110
|
+
json_response(build_response(records, dashboard, page, per_page, fields:, expand:))
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def self.build_response(records, dashboard, page, per_page, fields: nil, expand: nil)
|
|
114
|
+
attrs, expand_set =
|
|
115
|
+
resolve_attributes_and_expansions(dashboard, fields, expand, :collection_attributes, MAX_EXPAND)
|
|
116
|
+
columns = FieldSerializer.resolve_columns(dashboard, attrs)
|
|
117
|
+
|
|
118
|
+
{
|
|
119
|
+
columns: %w[url] + columns.map(&:to_s),
|
|
120
|
+
rows:
|
|
121
|
+
records.map do |r|
|
|
122
|
+
[FieldSerializer.admin_url_for(r)] +
|
|
123
|
+
FieldSerializer.serialize_row(r, dashboard, columns:, expand: expand_set)
|
|
124
|
+
end,
|
|
125
|
+
meta: {
|
|
126
|
+
page:,
|
|
127
|
+
per_page:,
|
|
128
|
+
total_count: records.total_count,
|
|
129
|
+
total_pages: records.total_pages
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def self.build_scope(entry, dashboard, query, filters)
|
|
135
|
+
scope = entry.scope
|
|
136
|
+
scope = apply_includes(scope, dashboard)
|
|
137
|
+
scope = apply_search(scope, dashboard, query) if query.present?
|
|
138
|
+
scope = apply_filters(scope, entry, filters) if filters.present?
|
|
139
|
+
scope
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def self.apply_includes(scope, dashboard)
|
|
143
|
+
includes = dashboard.collection_includes
|
|
144
|
+
includes.any? ? scope.preload(*includes) : scope
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def self.apply_filters(scope, entry, filters)
|
|
148
|
+
dashboard_filters = dashboard_collection_filters(entry.dashboard_class)
|
|
149
|
+
valid_columns = association_columns(entry.model_class)
|
|
150
|
+
|
|
151
|
+
reject_unknown!('filters', filters.keys, dashboard_filters.keys.map(&:to_s) + valid_columns.to_a)
|
|
152
|
+
|
|
153
|
+
filters.each do |name, value|
|
|
154
|
+
scope = apply_single_filter(scope, name, value, dashboard_filters, valid_columns)
|
|
155
|
+
end
|
|
156
|
+
scope
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def self.apply_single_filter(scope, name, value, dashboard_filters, valid_columns)
|
|
160
|
+
dashboard_filter = dashboard_filters[name.to_sym]
|
|
161
|
+
if dashboard_filter
|
|
162
|
+
dashboard_filter.arity == 1 ? dashboard_filter.call(scope) : dashboard_filter.call(scope, value)
|
|
163
|
+
elsif valid_columns.include?(name.to_s)
|
|
164
|
+
scope.where(name.to_s => value)
|
|
165
|
+
else
|
|
166
|
+
scope
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def self.dashboard_collection_filters(dashboard_class)
|
|
171
|
+
return {} unless dashboard_class.const_defined?(:COLLECTION_FILTERS, false)
|
|
172
|
+
|
|
173
|
+
dashboard_class.const_get(:COLLECTION_FILTERS, false)
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def self.apply_search(scope, dashboard, query)
|
|
177
|
+
results = FastSearch.new(scope, dashboard, query).run
|
|
178
|
+
return results if results.exists?
|
|
179
|
+
|
|
180
|
+
Administrate::Search.new(scope, dashboard, query).run
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def self.apply_sort(scope, dashboard, sort, direction)
|
|
184
|
+
return scope if sort.blank?
|
|
185
|
+
|
|
186
|
+
reject_unknown!('sort fields', sort, FieldSerializer.exposed_attributes(dashboard))
|
|
187
|
+
|
|
188
|
+
safe_direction = direction.to_s == 'asc' ? :asc : :desc
|
|
189
|
+
scope.order(sort.to_sym => safe_direction)
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
end
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Administrate
|
|
4
|
+
module MCP
|
|
5
|
+
module Tools
|
|
6
|
+
# Lists available admin resources. Without a resource param, returns a lightweight catalog
|
|
7
|
+
# (name + description). With a resource param, returns full detail (fields + filters).
|
|
8
|
+
class AdminResourceListResources < AdminDashboardTool
|
|
9
|
+
tool_name 'admin_resource_list_resources'
|
|
10
|
+
description 'Discover available admin resources. ' \
|
|
11
|
+
'Without a resource param: returns all resource names (lightweight catalog). ' \
|
|
12
|
+
'With a resource param: returns fields and filters for that resource.'
|
|
13
|
+
annotations read_only_hint: true, destructive_hint: false, open_world_hint: true
|
|
14
|
+
|
|
15
|
+
input_schema(
|
|
16
|
+
properties: {
|
|
17
|
+
resource: {
|
|
18
|
+
type: 'string',
|
|
19
|
+
description:
|
|
20
|
+
'Resource name to inspect (optional). When provided, returns fields and filters for that ' \
|
|
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.'
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
def self.execute(admin:, resource: nil)
|
|
31
|
+
return detail_response(admin, resource) if resource.present?
|
|
32
|
+
|
|
33
|
+
catalog_response(admin)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def self.catalog_response(admin)
|
|
37
|
+
resources =
|
|
38
|
+
DashboardRegistry.resource_names.filter_map do |name|
|
|
39
|
+
entry = DashboardRegistry.find(name)
|
|
40
|
+
next unless entry
|
|
41
|
+
next unless authorized?(admin, entry.model_class, :index?)
|
|
42
|
+
|
|
43
|
+
result = { name: }
|
|
44
|
+
result[:description] = entry.description if entry.description.present?
|
|
45
|
+
result
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
json_response(resources)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def self.detail_response(admin, resource_name)
|
|
52
|
+
entry = find_dashboard_entry!(resource_name)
|
|
53
|
+
authorize_resource!(admin, entry.model_class, :index?)
|
|
54
|
+
|
|
55
|
+
json_response(build_detail(resource_name, entry))
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def self.build_detail(name, entry)
|
|
59
|
+
dashboard = entry.dashboard_class.new
|
|
60
|
+
result = { name:, fields: FieldSerializer.exposed_attributes(dashboard).map(&:to_s) }
|
|
61
|
+
result[:description] = entry.description if entry.description.present?
|
|
62
|
+
enrich_detail!(result, entry, dashboard)
|
|
63
|
+
result
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def self.enrich_detail!(result, entry, dashboard)
|
|
67
|
+
filters = collection_filters(entry.dashboard_class, entry.model_class)
|
|
68
|
+
result[:filters] = filters if filters.present?
|
|
69
|
+
expandable = expandable_associations(dashboard)
|
|
70
|
+
result[:expandable] = expandable if expandable.present?
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def self.expandable_associations(dashboard)
|
|
74
|
+
dashboard.attribute_types.filter_map do |attr_name, spec|
|
|
75
|
+
next unless FieldSerializer.expandable_field?(FieldSerializer.resolve_field_class(spec))
|
|
76
|
+
|
|
77
|
+
attr_name.to_s
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def self.collection_filters(dashboard_class, model_class)
|
|
82
|
+
dashboard_filters = dashboard_defined_filters(dashboard_class)
|
|
83
|
+
dashboard_filter_names = dashboard_filters.to_set { |f| f[:name] }
|
|
84
|
+
|
|
85
|
+
assoc_filters =
|
|
86
|
+
association_columns(model_class)
|
|
87
|
+
.reject { |col| dashboard_filter_names.include?(col) }
|
|
88
|
+
.sort
|
|
89
|
+
.map { |col| { name: col, type: 'value' } }
|
|
90
|
+
|
|
91
|
+
dashboard_filters + assoc_filters
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def self.dashboard_defined_filters(dashboard_class)
|
|
95
|
+
return [] unless dashboard_class.const_defined?(:COLLECTION_FILTERS, false)
|
|
96
|
+
|
|
97
|
+
dashboard_class
|
|
98
|
+
.const_get(:COLLECTION_FILTERS, false)
|
|
99
|
+
.map do |name, filter|
|
|
100
|
+
type = filter.arity == 1 ? 'boolean' : 'value'
|
|
101
|
+
{ name: name.to_s, type: }
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Administrate
|
|
4
|
+
module MCP
|
|
5
|
+
module Tools
|
|
6
|
+
# Shows a single admin resource by ID or slug using its Administrate dashboard definition.
|
|
7
|
+
class AdminResourceShow < AdminDashboardTool
|
|
8
|
+
MAX_BATCH = 10
|
|
9
|
+
MAX_EXPAND = 2
|
|
10
|
+
|
|
11
|
+
def self.policy_action
|
|
12
|
+
:show?
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
tool_name 'admin_resource_show'
|
|
16
|
+
description 'Show admin resource(s) by ID or slug. ' \
|
|
17
|
+
'Accepts a single ID or an array of up to 10 IDs for batch lookup.'
|
|
18
|
+
annotations read_only_hint: true, destructive_hint: false, open_world_hint: true
|
|
19
|
+
|
|
20
|
+
input_schema(
|
|
21
|
+
properties: {
|
|
22
|
+
resource: {
|
|
23
|
+
type: 'string',
|
|
24
|
+
description:
|
|
25
|
+
'Resource type name (e.g., "card", "user", "player"). ' \
|
|
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.'
|
|
31
|
+
},
|
|
32
|
+
id: {
|
|
33
|
+
oneOf: [
|
|
34
|
+
{ type: 'string', description: 'Single resource ID or slug — one id, never a list encoded as text' },
|
|
35
|
+
{
|
|
36
|
+
type: 'array',
|
|
37
|
+
items: {
|
|
38
|
+
type: 'string'
|
|
39
|
+
},
|
|
40
|
+
maxItems: MAX_BATCH,
|
|
41
|
+
description:
|
|
42
|
+
"Multiple IDs (max #{MAX_BATCH}), as a real JSON array. A stringified array is read as one " \
|
|
43
|
+
'id, matches nothing, and fails with "<Resource> not found for: [...]" echoing the whole ' \
|
|
44
|
+
'list — that error means the ids never reached the batch path, not that they are wrong, ' \
|
|
45
|
+
'so re-send them as an array rather than falling back to single calls. ' \
|
|
46
|
+
'Attributes a dashboard computes per record are not resolved in batch mode and come back ' \
|
|
47
|
+
'as "[error: could not serialize <field>]" — pass a single ID when you need one of those.'
|
|
48
|
+
}
|
|
49
|
+
]
|
|
50
|
+
},
|
|
51
|
+
fields: {
|
|
52
|
+
type: 'array',
|
|
53
|
+
items: {
|
|
54
|
+
type: 'string'
|
|
55
|
+
},
|
|
56
|
+
description: 'Specific fields to return (optional). Returns all fields if omitted.'
|
|
57
|
+
},
|
|
58
|
+
expand: {
|
|
59
|
+
type: 'array',
|
|
60
|
+
items: {
|
|
61
|
+
type: 'string'
|
|
62
|
+
},
|
|
63
|
+
description:
|
|
64
|
+
'HasMany associations to expand inline instead of counts. Max 2 associations. An expanded ' \
|
|
65
|
+
'association returns {count, items}: count is the association total, and items is capped at ' \
|
|
66
|
+
'25 rows. Both ship in the same response, so always compare them — count > items.length ' \
|
|
67
|
+
'means the list is clipped and items is NOT the full set. items also come back in no ' \
|
|
68
|
+
'guaranteed order: the sort_by / direction declared on the dashboard attribute is not ' \
|
|
69
|
+
'applied on this path, so a clipped expand is an arbitrary slice and not the newest rows — ' \
|
|
70
|
+
'reading recency off it can report a year-old row as the latest one. There is no way to page ' \
|
|
71
|
+
'past the cap or to sort here: when you need the remaining rows, or the most recent ones, ' \
|
|
72
|
+
'list the related resource directly with a filter on this record and an explicit sort.'
|
|
73
|
+
}
|
|
74
|
+
},
|
|
75
|
+
required: %w[resource id]
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
def self.execute(admin:, resource:, id:, fields: nil, expand: nil) # rubocop:disable Lint/UnusedMethodArgument
|
|
79
|
+
entry = find_dashboard_entry!(resource)
|
|
80
|
+
dashboard = entry.dashboard_class.new
|
|
81
|
+
attrs, expand_set =
|
|
82
|
+
resolve_attributes_and_expansions(dashboard, fields, expand, :show_page_attributes, MAX_EXPAND)
|
|
83
|
+
|
|
84
|
+
return batch_show(entry, dashboard, id, resource, attributes: attrs, expand: expand_set) if id.is_a?(Array)
|
|
85
|
+
|
|
86
|
+
record = find_record(entry, id)
|
|
87
|
+
return error_response("#{resource.capitalize} not found for: #{id}") unless record
|
|
88
|
+
|
|
89
|
+
json_response(
|
|
90
|
+
FieldSerializer.serialize(record, dashboard, attributes: attrs, expand: expand_set, resolve_getters: true)
|
|
91
|
+
)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Getters stay unresolved here: they are per-record work, and a batch multiplies them by up
|
|
95
|
+
# to MAX_BATCH. Read one id at a time when a getter-backed attribute matters.
|
|
96
|
+
def self.batch_show(entry, dashboard, ids, resource, attributes:, expand:)
|
|
97
|
+
reject_over_limit!('ids', ids.size, MAX_BATCH)
|
|
98
|
+
columns = FieldSerializer.resolve_columns(dashboard, attributes)
|
|
99
|
+
|
|
100
|
+
rows =
|
|
101
|
+
ids.map do |single_id|
|
|
102
|
+
record = find_record(entry, single_id)
|
|
103
|
+
if record
|
|
104
|
+
[FieldSerializer.admin_url_for(record)] +
|
|
105
|
+
FieldSerializer.serialize_row(record, dashboard, columns:, expand:)
|
|
106
|
+
else
|
|
107
|
+
{ id: single_id, error: "#{resource.capitalize} not found" }
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
json_response({ columns: %w[url] + columns.map(&:to_s), rows: })
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def self.find_record(entry, id)
|
|
115
|
+
entry.scope.find_by(id:) || friendly_find(entry, id)
|
|
116
|
+
rescue ActiveRecord::RecordNotFound
|
|
117
|
+
nil
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def self.friendly_find(entry, id)
|
|
121
|
+
return nil unless entry.model_class.respond_to?(:friendly)
|
|
122
|
+
|
|
123
|
+
entry.scope.friendly.find(id)
|
|
124
|
+
rescue ActiveRecord::RecordNotFound
|
|
125
|
+
nil
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Administrate
|
|
4
|
+
module MCP
|
|
5
|
+
module Tools
|
|
6
|
+
# Lets MCP users report improvements (misleading descriptions, missing filters, etc.).
|
|
7
|
+
# Open to every authenticated admin: reporting a bad description is not a privileged action.
|
|
8
|
+
class ReportImprovement < BaseTool
|
|
9
|
+
tool_name 'report_mcp_improvement'
|
|
10
|
+
requires_roles
|
|
11
|
+
description 'Report an improvement for the MCP server (misleading description, missing filter, ' \
|
|
12
|
+
'bad serialization, missing resource, etc.).'
|
|
13
|
+
|
|
14
|
+
input_schema(
|
|
15
|
+
properties: {
|
|
16
|
+
category: {
|
|
17
|
+
type: 'string',
|
|
18
|
+
enum: Feedback.categories.keys,
|
|
19
|
+
description: 'The type of improvement being reported.'
|
|
20
|
+
},
|
|
21
|
+
resource_name: {
|
|
22
|
+
type: 'string',
|
|
23
|
+
description: 'The resource this feedback relates to (optional).'
|
|
24
|
+
},
|
|
25
|
+
suggestion: {
|
|
26
|
+
type: 'string',
|
|
27
|
+
description: 'A detailed description of the improvement.'
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
required: %w[category suggestion]
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
def self.execute(admin:, category:, suggestion:, resource_name: nil)
|
|
34
|
+
result = Administrate::MCP::ReportImprovement.call(admin:, category:, suggestion:, resource_name:)
|
|
35
|
+
|
|
36
|
+
return error_response(result.errors.join(', ')) unless result.success?
|
|
37
|
+
|
|
38
|
+
json_response({ status: 'created', feedback_id: result.feedback.id })
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Administrate
|
|
4
|
+
module MCP
|
|
5
|
+
module Tools
|
|
6
|
+
# Exposes Sidekiq retry queue metrics: total size and first page of retries.
|
|
7
|
+
class SidekiqRetries < BaseTool
|
|
8
|
+
PAGE_SIZE = 25
|
|
9
|
+
|
|
10
|
+
tool_name 'sidekiq_retries'
|
|
11
|
+
description 'Get the Sidekiq retry queue size and optionally the first page of retry entries ' \
|
|
12
|
+
'(up to 25 jobs with class, error, retry count, and timestamps).'
|
|
13
|
+
annotations read_only_hint: true, destructive_hint: false, open_world_hint: false
|
|
14
|
+
|
|
15
|
+
input_schema(
|
|
16
|
+
properties: {
|
|
17
|
+
include_entries: {
|
|
18
|
+
type: 'boolean',
|
|
19
|
+
description: 'When true, returns the first page of retry entries alongside the total count.'
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
def self.execute(admin:, include_entries: false) # rubocop:disable Lint/UnusedMethodArgument
|
|
25
|
+
retry_set = ::Sidekiq::RetrySet.new
|
|
26
|
+
data = { total_size: retry_set.size }
|
|
27
|
+
|
|
28
|
+
data[:entries] = retry_set.first(PAGE_SIZE)&.map { |entry| serialize_entry(entry) } || [] if include_entries
|
|
29
|
+
|
|
30
|
+
json_response(data)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def self.serialize_entry(entry)
|
|
34
|
+
{
|
|
35
|
+
jid: entry.jid,
|
|
36
|
+
queue: entry.queue,
|
|
37
|
+
class: entry['class'],
|
|
38
|
+
error_class: entry['error_class'],
|
|
39
|
+
error_message: entry['error_message']&.truncate(200),
|
|
40
|
+
retry_count: entry['retry_count'],
|
|
41
|
+
failed_at: entry['failed_at'] && Time.at(entry['failed_at']).utc.iso8601,
|
|
42
|
+
retried_at: entry['retried_at'] && Time.at(entry['retried_at']).utc.iso8601,
|
|
43
|
+
next_retry_at: Time.at(entry.score).utc.iso8601
|
|
44
|
+
}
|
|
45
|
+
end
|
|
46
|
+
private_class_method :serialize_entry
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Administrate
|
|
4
|
+
module MCP
|
|
5
|
+
module Tools
|
|
6
|
+
# Exposes the host's per-job-class Sidekiq queue stats, through `config.sidekiq_stats_provider`.
|
|
7
|
+
# The provider answers `counts(queue)`, `total_counts(counts)`, `queues` and `stats_cleared_at`.
|
|
8
|
+
class SidekiqStats < BaseTool
|
|
9
|
+
MAX_QUEUES = 20
|
|
10
|
+
RETRY_QUEUE = 'retries'
|
|
11
|
+
|
|
12
|
+
tool_name 'sidekiq_stats'
|
|
13
|
+
description 'Get per-job-class counts from the custom Sidekiq stats. ' \
|
|
14
|
+
'Returns retry breakdown and optionally per-queue breakdowns. ' \
|
|
15
|
+
'Each breakdown maps job class names to their pending count.'
|
|
16
|
+
annotations read_only_hint: true, destructive_hint: false, open_world_hint: false
|
|
17
|
+
|
|
18
|
+
input_schema(
|
|
19
|
+
properties: {
|
|
20
|
+
include_queues: {
|
|
21
|
+
type: 'boolean',
|
|
22
|
+
description: 'When true, also returns per-queue job class breakdowns (capped at 20 queues).'
|
|
23
|
+
},
|
|
24
|
+
queue_name: {
|
|
25
|
+
type: 'string',
|
|
26
|
+
description: 'Return stats for a specific queue only. Ignored when include_queues is false.'
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
def self.provider
|
|
32
|
+
configured = Administrate::MCP.config.sidekiq_stats_provider
|
|
33
|
+
case configured
|
|
34
|
+
when String then configured.constantize
|
|
35
|
+
when Proc then configured.call
|
|
36
|
+
else configured
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def self.retry_queue
|
|
41
|
+
provider.const_defined?(:RETRY_QUEUE, false) ? provider.const_get(:RETRY_QUEUE, false) : RETRY_QUEUE
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def self.execute(admin:, include_queues: false, queue_name: nil) # rubocop:disable Lint/UnusedMethodArgument
|
|
45
|
+
retries = provider.counts(retry_queue)
|
|
46
|
+
|
|
47
|
+
data = {
|
|
48
|
+
stats_cleared_at: provider.stats_cleared_at.iso8601,
|
|
49
|
+
retries: sort_desc(retries),
|
|
50
|
+
total_retries: provider.total_counts(retries)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
data[:queues] = queue_breakdowns(queue_name) if include_queues
|
|
54
|
+
|
|
55
|
+
json_response(data)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def self.queue_breakdowns(queue_name)
|
|
59
|
+
queues = queue_name.present? ? [queue_name] : provider.queues.first(MAX_QUEUES)
|
|
60
|
+
|
|
61
|
+
queues.to_h do |q|
|
|
62
|
+
counts = provider.counts(q)
|
|
63
|
+
[q, { counts: sort_desc(counts), total: provider.total_counts(counts) }]
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
private_class_method :queue_breakdowns
|
|
67
|
+
|
|
68
|
+
def self.sort_desc(counts)
|
|
69
|
+
counts.sort_by { |_, v| -v }.to_h
|
|
70
|
+
end
|
|
71
|
+
private_class_method :sort_desc
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Administrate
|
|
4
|
+
module MCP
|
|
5
|
+
# Bearer token for authenticating an admin to the MCP server.
|
|
6
|
+
class ApiKey < ApplicationRecord
|
|
7
|
+
self.table_name = 'administrate_mcp_api_keys'
|
|
8
|
+
|
|
9
|
+
TOKEN_LENGTH = 40
|
|
10
|
+
WRITE_SCOPE = 'write'
|
|
11
|
+
|
|
12
|
+
belongs_to_admin
|
|
13
|
+
|
|
14
|
+
has_many :feedbacks, class_name: 'Administrate::MCP::Feedback', dependent: :nullify, inverse_of: :api_key
|
|
15
|
+
|
|
16
|
+
validates :token_digest, presence: true, uniqueness: true
|
|
17
|
+
validates :token_prefix, presence: true
|
|
18
|
+
validates :name, presence: true
|
|
19
|
+
|
|
20
|
+
scope :active, -> { where(revoked_at: nil) }
|
|
21
|
+
|
|
22
|
+
class << self
|
|
23
|
+
def token_prefix_value
|
|
24
|
+
Administrate::MCP.config.api_key_token_prefix
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def generate_token
|
|
28
|
+
"#{token_prefix_value}#{SecureRandom.hex(TOKEN_LENGTH / 2)}"
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def digest_token(plaintext)
|
|
32
|
+
Digest::SHA256.hexdigest(plaintext)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def authenticate(plaintext)
|
|
36
|
+
return nil unless plaintext&.start_with?(token_prefix_value)
|
|
37
|
+
|
|
38
|
+
key = active.find_by(token_digest: digest_token(plaintext))
|
|
39
|
+
return nil unless key
|
|
40
|
+
|
|
41
|
+
key.touch(:last_used_at) # rubocop:disable Rails/SkipsModelValidations
|
|
42
|
+
key
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def scopes
|
|
47
|
+
write_access? ? [WRITE_SCOPE] : []
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def revoke!
|
|
51
|
+
update!(revoked_at: Time.current)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def revoked?
|
|
55
|
+
revoked_at.present?
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Administrate
|
|
4
|
+
module MCP
|
|
5
|
+
# Base class for the engine's own tables.
|
|
6
|
+
class ApplicationRecord < ActiveRecord::Base
|
|
7
|
+
self.abstract_class = true
|
|
8
|
+
|
|
9
|
+
class << self
|
|
10
|
+
# `isolate_namespace` strips the namespace from model_name, so ApiKey would answer
|
|
11
|
+
# `api_keys` and collide with the host's own resource of that name in route and form
|
|
12
|
+
# helpers. These records name themselves the way any other namespaced model does.
|
|
13
|
+
def model_name
|
|
14
|
+
@model_name ||= ActiveModel::Name.new(self, nil, name)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def belongs_to_admin
|
|
18
|
+
belongs_to :admin, class_name: Administrate::MCP.config.admin_class_name, optional: false
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|