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.
Files changed (56) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +61 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +267 -0
  5. data/app/controllers/administrate/mcp/json_rpc_controller.rb +83 -0
  6. data/app/controllers/administrate/mcp/o_auth_controller.rb +199 -0
  7. data/app/lib/administrate/mcp/actions.rb +170 -0
  8. data/app/lib/administrate/mcp/admin_dashboard_tool.rb +114 -0
  9. data/app/lib/administrate/mcp/authentication.rb +146 -0
  10. data/app/lib/administrate/mcp/base_tool.rb +93 -0
  11. data/app/lib/administrate/mcp/clean_old_feedbacks.rb +26 -0
  12. data/app/lib/administrate/mcp/dashboard_registry.rb +102 -0
  13. data/app/lib/administrate/mcp/fast_search.rb +47 -0
  14. data/app/lib/administrate/mcp/field_serializer.rb +284 -0
  15. data/app/lib/administrate/mcp/o_auth_service.rb +103 -0
  16. data/app/lib/administrate/mcp/report_improvement.rb +58 -0
  17. data/app/lib/administrate/mcp/server_builder.rb +70 -0
  18. data/app/lib/administrate/mcp/tools/admin_resource_list.rb +194 -0
  19. data/app/lib/administrate/mcp/tools/admin_resource_list_resources.rb +107 -0
  20. data/app/lib/administrate/mcp/tools/admin_resource_show.rb +130 -0
  21. data/app/lib/administrate/mcp/tools/report_improvement.rb +43 -0
  22. data/app/lib/administrate/mcp/tools/sidekiq_retries.rb +50 -0
  23. data/app/lib/administrate/mcp/tools/sidekiq_stats.rb +75 -0
  24. data/app/models/administrate/mcp/api_key.rb +59 -0
  25. data/app/models/administrate/mcp/application_record.rb +23 -0
  26. data/app/models/administrate/mcp/feedback.rb +20 -0
  27. data/app/models/administrate/mcp/o_auth_access_grant.rb +76 -0
  28. data/app/models/administrate/mcp/o_auth_access_token.rb +84 -0
  29. data/app/models/administrate/mcp/o_auth_application.rb +64 -0
  30. data/app/views/administrate/mcp/o_auth/authorize.html.erb +63 -0
  31. data/config/routes.rb +6 -0
  32. data/db/migrate/20260101000001_create_administrate_model_context_protocol_api_keys.rb +20 -0
  33. data/db/migrate/20260101000002_create_administrate_model_context_protocol_feedbacks.rb +19 -0
  34. data/db/migrate/20260101000003_create_administrate_model_context_protocol_authorization_tables.rb +52 -0
  35. data/docs/admin-integration.md +56 -0
  36. data/docs/authentication.md +116 -0
  37. data/docs/configuration.md +220 -0
  38. data/docs/dashboards.md +50 -0
  39. data/docs/development.md +19 -0
  40. data/docs/oauth.md +54 -0
  41. data/docs/routes.md +30 -0
  42. data/lib/administrate/mcp/authorization/base.rb +45 -0
  43. data/lib/administrate/mcp/authorization/permissive.rb +13 -0
  44. data/lib/administrate/mcp/authorization/pundit.rb +32 -0
  45. data/lib/administrate/mcp/cloudflare_access.rb +140 -0
  46. data/lib/administrate/mcp/configuration.rb +156 -0
  47. data/lib/administrate/mcp/dashboard_extension.rb +25 -0
  48. data/lib/administrate/mcp/engine.rb +21 -0
  49. data/lib/administrate/mcp/errors.rb +21 -0
  50. data/lib/administrate/mcp/loopback_uri.rb +18 -0
  51. data/lib/administrate/mcp/rack_attack.rb +39 -0
  52. data/lib/administrate/mcp/routes.rb +48 -0
  53. data/lib/administrate/mcp/version.rb +7 -0
  54. data/lib/administrate/mcp.rb +35 -0
  55. data/lib/administrate-mcp.rb +3 -0
  56. 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