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,284 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Administrate
4
+ module MCP
5
+ # Converts a record + dashboard definition into a plain Hash suitable for JSON.
6
+ #
7
+ # Field classes are matched by name against the configured registry, walking the field's
8
+ # ancestors, so a host can register its own field classes without the engine ever referencing
9
+ # a constant it does not own.
10
+ class FieldSerializer
11
+ MAX_EXPAND_ITEMS = 25
12
+
13
+ # Where one attribute's value comes from. Administrate resolves a `getter:` option through the
14
+ # field instance, never off the record, so an attribute declared only as a lambda has no
15
+ # matching model method and `public_send` raises. Building the field is the only way to read
16
+ # those.
17
+ #
18
+ # A getter is arbitrary code — several run their own queries — so `resolve_getters` is opt-in
19
+ # and only single-record callers set it. Resolving them per row would turn one list call into
20
+ # a query per row.
21
+ AttributeSource =
22
+ Struct.new(:record, :attr_name, :field_spec, :resolve_getters) do
23
+ def value
24
+ return field_spec.new(attr_name, nil, :show, resource: record).data if getter?
25
+
26
+ record.public_send(attr_name)
27
+ end
28
+
29
+ def getter?
30
+ resolve_getters && field_spec.is_a?(Administrate::Field::Deferred) && field_spec.getter.present?
31
+ end
32
+ end
33
+
34
+ class << self
35
+ def serialize(record, dashboard, attributes: nil, expand: nil, resolve_getters: false)
36
+ attrs = exposed_attributes(dashboard, attributes)
37
+ attrs.each_with_object({ url: admin_url_for(record) }) do |attr_name, hash|
38
+ field_spec = dashboard.attribute_types[attr_name]
39
+ next unless field_spec
40
+
41
+ next if skip_field?(resolve_field_class(field_spec))
42
+
43
+ hash[attr_name] = serialize_single(
44
+ AttributeSource.new(record, attr_name, field_spec, resolve_getters),
45
+ expand
46
+ )
47
+ end
48
+ end
49
+
50
+ def admin_url_for(record)
51
+ return nil unless record.is_a?(ActiveRecord::Base)
52
+
53
+ klass = record.class
54
+ while klass < ActiveRecord::Base && !klass.abstract_class?
55
+ begin
56
+ return polymorphic_admin_url(record.becomes(klass))
57
+ rescue ActionController::UrlGenerationError, NoMethodError
58
+ klass = klass.superclass
59
+ end
60
+ end
61
+ nil
62
+ end
63
+
64
+ def polymorphic_admin_url(record)
65
+ config = Administrate::MCP.config
66
+ Rails.application.routes.url_helpers.polymorphic_url(
67
+ [config.admin_route_namespace, record],
68
+ **admin_url_options(config)
69
+ )
70
+ end
71
+
72
+ # A host may declare `admin_origin` as a proc that reads the request, and there is no request
73
+ # here. Falling back to the configured options keeps the URL buildable from
74
+ # default_url_options instead of losing every `url` to a nil.
75
+ def admin_url_options(config)
76
+ options = config.admin_url_options
77
+ return options if options[:host].present?
78
+
79
+ origin = URI.parse(config.admin_origin_for.to_s)
80
+ return options if origin.host.blank?
81
+
82
+ { host: origin.host, protocol: origin.scheme, port: origin.port }.compact.merge(options)
83
+ rescue StandardError
84
+ options
85
+ end
86
+
87
+ def exposed_attributes(dashboard, attributes = nil)
88
+ (attributes || dashboard.show_page_attributes) - DashboardRegistry.skipped_attributes(dashboard)
89
+ end
90
+
91
+ def resolve_columns(dashboard, attributes)
92
+ exposed_attributes(dashboard, attributes).select do |attr_name|
93
+ field_spec = dashboard.attribute_types[attr_name]
94
+ next false unless field_spec
95
+
96
+ !skip_field?(resolve_field_class(field_spec))
97
+ end
98
+ end
99
+
100
+ def serialize_row(record, dashboard, columns:, expand: nil, resolve_getters: false)
101
+ columns.map do |attr_name|
102
+ source = AttributeSource.new(record, attr_name, dashboard.attribute_types[attr_name], resolve_getters)
103
+ serialize_single(source, expand)
104
+ end
105
+ end
106
+
107
+ def serialize_single(source, expand)
108
+ field_class = resolve_field_class(source.field_spec)
109
+ if expand&.include?(source.attr_name) && expandable_field?(field_class)
110
+ serialize_has_many_expanded(source)
111
+ else
112
+ serialize_field(source, field_class)
113
+ end
114
+ end
115
+
116
+ def expandable_field?(field_class)
117
+ return false unless field_class.is_a?(Class)
118
+
119
+ matches?(field_class, Administrate::MCP.config.has_many_field_classes)
120
+ end
121
+
122
+ def resolve_field_class(field_spec)
123
+ case field_spec
124
+ when Administrate::Field::Deferred
125
+ field_spec.deferred_class
126
+ when Class
127
+ field_spec
128
+ end
129
+ end
130
+
131
+ def skip_field?(field_class)
132
+ return true unless field_class
133
+
134
+ matches?(field_class, Administrate::MCP.config.skipped_field_classes)
135
+ end
136
+
137
+ def serialize_field(source, field_class)
138
+ serializer = lookup_serializer(field_class)
139
+ return serializer.call(source) if serializer.respond_to?(:call)
140
+ return send(:"serialize_#{serializer}", source) if serializer
141
+ return field_class.mcp_value(source.record, source.attr_name) if field_class.respond_to?(:mcp_value)
142
+
143
+ serialize_unknown(source)
144
+ rescue StandardError
145
+ "[error: could not serialize #{source.attr_name}]"
146
+ end
147
+
148
+ def serialize_has_many(source)
149
+ relation = has_many_relation(source)
150
+ return nil unless relation
151
+
152
+ { count: relation_count(relation) }
153
+ rescue StandardError
154
+ nil
155
+ end
156
+
157
+ def serialize_has_many_expanded(source)
158
+ relation = has_many_relation(source)
159
+ return nil unless relation
160
+
161
+ { count: relation_count(relation), items: expanded_items(relation) }
162
+ rescue StandardError
163
+ nil
164
+ end
165
+
166
+ def has_many_relation(source) # rubocop:disable Naming/PredicatePrefix
167
+ value = source.value
168
+ value.is_a?(ActiveRecord::Relation) ? value : nil
169
+ end
170
+
171
+ def expanded_items(relation)
172
+ related_records = relation.limit(MAX_EXPAND_ITEMS)
173
+ dashboard_class = "#{relation.klass.name}Dashboard".safe_constantize
174
+ return related_records.map(&:to_s) unless dashboard_class
175
+
176
+ dash = dashboard_class.new
177
+ related_records.map { |r| serialize(r, dash, attributes: dash.collection_attributes) }
178
+ end
179
+
180
+ def relation_count(relation)
181
+ return relation.count if relation.select_values.blank?
182
+
183
+ relation.klass.from(relation.unscope(:order).arel.as('mcp_count_sub')).count
184
+ end
185
+
186
+ def serialize_belongs_to(source)
187
+ related = source.value
188
+ related ? build_related_hash(related) : nil
189
+ rescue StandardError
190
+ nil
191
+ end
192
+
193
+ # A polymorphic association is useless to a caller without its type: the id alone names no
194
+ # table. Both halves ship in the hash.
195
+ def serialize_polymorphic(source)
196
+ related = source.value
197
+ return nil unless related
198
+
199
+ build_related_hash(related).merge(type: related.class.name)
200
+ rescue StandardError
201
+ nil
202
+ end
203
+
204
+ def serialize_datetime(source)
205
+ source.value&.iso8601
206
+ end
207
+
208
+ def serialize_enum(source)
209
+ source.value.to_s
210
+ end
211
+
212
+ def serialize_attachment(source)
213
+ value = source.value
214
+ return nil unless value
215
+
216
+ value.try(:url) || value.to_s
217
+ rescue StandardError
218
+ nil
219
+ end
220
+
221
+ def serialize_scalar(source)
222
+ source.value
223
+ end
224
+
225
+ def serialize_unknown(source)
226
+ value = source.value
227
+ return value if value.nil? || value.is_a?(Numeric) || [true, false].include?(value)
228
+ return value if value.is_a?(String) || value.is_a?(Hash) || value.is_a?(Array)
229
+
230
+ value.to_s
231
+ end
232
+
233
+ def build_related_hash(related)
234
+ display = display_resource_for(related)
235
+ result = { id: related.id }
236
+ result[:display] = display if display != "#{related.class.name.demodulize} ##{related.id}"
237
+ result[:slug] = related.slug if related.respond_to?(:slug)
238
+ result
239
+ end
240
+
241
+ def display_resource_for(related)
242
+ dashboard_class = "#{related.class.name}Dashboard".safe_constantize
243
+ return related.to_s unless dashboard_class
244
+
245
+ dashboard_class.new.display_resource(related)
246
+ rescue StandardError
247
+ related.to_s
248
+ end
249
+
250
+ private
251
+
252
+ # A field class registered under its own name wins outright. Otherwise a class that speaks
253
+ # `mcp_value` answers for itself, ahead of anything inherited from an ancestor: that is how
254
+ # a host subclasses a registered field to publish a richer value.
255
+ def lookup_serializer(field_class)
256
+ return nil unless field_class.is_a?(Class)
257
+
258
+ registry = Administrate::MCP.config.field_serializers
259
+ own = field_class.name && registry[field_class.name]
260
+ return own if own
261
+ return nil if field_class.respond_to?(:mcp_value)
262
+
263
+ inherited_serializer(field_class, registry)
264
+ end
265
+
266
+ def inherited_serializer(field_class, registry)
267
+ field_class.ancestors.each do |ancestor|
268
+ name = ancestor.name
269
+ next unless name
270
+
271
+ entry = registry[name]
272
+ return entry if entry
273
+ end
274
+ nil
275
+ end
276
+
277
+ def matches?(field_class, class_names)
278
+ names = field_class.ancestors.filter_map(&:name)
279
+ class_names.any? { |candidate| names.include?(candidate.to_s) }
280
+ end
281
+ end
282
+ end
283
+ end
284
+ end
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Administrate
4
+ module MCP
5
+ # OAuth 2.1 service for MCP authentication: parameter validation and token exchange.
6
+ # Implements RFC 9126 (PAR), RFC 7636 (PKCE), and RFC 6749 (OAuth 2.0) token exchange.
7
+ # See https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-1-12
8
+ class OAuthService
9
+ BASE64URL_CHALLENGE_PATTERN = /\A[A-Za-z0-9_-]{43,128}\z/
10
+
11
+ Result = Struct.new(:success?, :data, :error, :error_description, keyword_init: true)
12
+
13
+ def validate_authorize_params(application:, redirect_uri:, code_challenge_method:, code_challenge:)
14
+ return 'Unknown client_id' unless application
15
+ return 'Invalid redirect_uri' unless valid_redirect_uri?(application, redirect_uri)
16
+ unless supported_challenge_method?(code_challenge_method)
17
+ return 'Unsupported code_challenge_method, must be S256'
18
+ end
19
+ return 'Invalid code_challenge format' unless valid_code_challenge?(code_challenge)
20
+
21
+ nil
22
+ end
23
+
24
+ def valid_redirect_uri?(application, uri)
25
+ return false if uri.blank?
26
+ return true if application.redirect_uris.include?(uri)
27
+
28
+ loopback_redirect_uri?(uri)
29
+ end
30
+
31
+ def exchange_authorization_code(code:, code_verifier:, redirect_uri:)
32
+ grant = find_valid_grant(code)
33
+ return error_result('invalid_grant') unless grant
34
+ return error_result('invalid_grant', 'redirect_uri mismatch') if redirect_uri_mismatch?(grant, redirect_uri)
35
+ unless grant.verify_code_challenge(code_verifier.to_s)
36
+ return error_result('invalid_grant', 'PKCE verification failed')
37
+ end
38
+
39
+ grant.revoke!
40
+ success_result(issue_access_token(grant.admin, grant.application, grant.scopes))
41
+ end
42
+
43
+ def exchange_refresh_token(refresh_token:)
44
+ old_token = OAuthAccessToken.active.find_by_refresh_token(refresh_token)
45
+ return error_result('invalid_grant') unless old_token && !old_token.expired?
46
+
47
+ old_token.revoke!
48
+ success_result(issue_access_token(old_token.admin, old_token.application, old_token.scopes))
49
+ end
50
+
51
+ private
52
+
53
+ # Loopback redirects (RFC 8252) are allowed on any port, but the host must be a real loopback
54
+ # host — matched on the parsed hostname, so `http://localhost.attacker.com` cannot intercept an
55
+ # authorization code.
56
+ def loopback_redirect_uri?(uri)
57
+ return false unless Administrate::MCP.config.allow_localhost_redirects
58
+
59
+ parsed = URI.parse(uri)
60
+ parsed.scheme == 'http' && LoopbackUri.localhost?(parsed.hostname)
61
+ rescue URI::InvalidURIError
62
+ false
63
+ end
64
+
65
+ def valid_code_challenge?(challenge)
66
+ challenge.blank? || BASE64URL_CHALLENGE_PATTERN.match?(challenge)
67
+ end
68
+
69
+ def supported_challenge_method?(method)
70
+ method.blank? || method == 'S256'
71
+ end
72
+
73
+ def find_valid_grant(code)
74
+ grant = OAuthAccessGrant.find_by_token(code)
75
+ grant if grant && !grant.revoked? && !grant.expired?
76
+ end
77
+
78
+ def redirect_uri_mismatch?(grant, redirect_uri)
79
+ redirect_uri.present? && grant.redirect_uri != redirect_uri
80
+ end
81
+
82
+ def issue_access_token(admin, application, scopes)
83
+ OAuthAccessToken.issue(admin:, application:, scopes:, expires_in: OAuthAccessToken::DEFAULT_EXPIRES_IN)
84
+ end
85
+
86
+ def success_result(access_token)
87
+ Result.new(
88
+ success?: true,
89
+ data: {
90
+ access_token: access_token.plaintext_token,
91
+ token_type: 'bearer',
92
+ expires_in: access_token.expires_in,
93
+ refresh_token: access_token.plaintext_refresh_token
94
+ }
95
+ )
96
+ end
97
+
98
+ def error_result(error, description = nil)
99
+ Result.new(success?: false, error:, error_description: description)
100
+ end
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Administrate
4
+ module MCP
5
+ # Persists an MCP improvement suggestion and hands it to the host's `on_feedback` hook.
6
+ class ReportImprovement
7
+ Result = Struct.new(:success?, :feedback, :errors, keyword_init: true)
8
+
9
+ def self.call(...)
10
+ new(...).call
11
+ end
12
+
13
+ def initialize(admin:, category:, suggestion:, resource_name: nil)
14
+ @admin = admin
15
+ @category = category
16
+ @suggestion = suggestion
17
+ @resource_name = resource_name
18
+ end
19
+
20
+ def call
21
+ errors = validation_errors
22
+ return Result.new(success?: false, feedback: nil, errors:) if errors.any?
23
+
24
+ feedback = create_feedback
25
+ notify(feedback)
26
+ Result.new(success?: true, feedback:, errors: [])
27
+ end
28
+
29
+ private
30
+
31
+ attr_reader :admin, :category, :suggestion, :resource_name
32
+
33
+ def validation_errors
34
+ errors = []
35
+ unless Feedback.categories.key?(category.to_s)
36
+ errors << "category must be one of: #{Feedback.categories.keys.join(', ')}"
37
+ end
38
+ errors << "suggestion can't be blank" if suggestion.blank?
39
+ errors
40
+ end
41
+
42
+ def create_feedback
43
+ Feedback.create!(admin:, api_key: latest_api_key, category:, resource_name:, suggestion:)
44
+ end
45
+
46
+ def latest_api_key
47
+ ApiKey.active.where(admin:).order(last_used_at: :desc).first
48
+ end
49
+
50
+ # The feedback is already persisted; a broken notifier must not take the report down with it.
51
+ def notify(feedback)
52
+ Administrate::MCP.config.on_feedback.call(feedback)
53
+ rescue StandardError => e
54
+ Administrate::MCP.config.on_error.call(e)
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Administrate
4
+ module MCP
5
+ # Builds a configured MCP::Server instance with auto-discovered tools.
6
+ class ServerBuilder
7
+ BUILT_IN_TOOLS = %w[
8
+ Administrate::MCP::Tools::AdminResourceList
9
+ Administrate::MCP::Tools::AdminResourceShow
10
+ Administrate::MCP::Tools::AdminResourceListResources
11
+ Administrate::MCP::Tools::ReportImprovement
12
+ ].freeze
13
+
14
+ SIDEKIQ_RETRIES_TOOL = 'Administrate::MCP::Tools::SidekiqRetries'
15
+
16
+ SIDEKIQ_STATS_TOOL = 'Administrate::MCP::Tools::SidekiqStats'
17
+
18
+ class << self
19
+ def build(server_context:)
20
+ server_context[:authorization_errors] ||= []
21
+ config = Administrate::MCP.config
22
+ server =
23
+ ::MCP::Server.new(
24
+ name: config.server_name,
25
+ version: config.server_version,
26
+ tools: discover_tools(server_context),
27
+ server_context:
28
+ )
29
+ server.transport =
30
+ ::MCP::Server::Transports::StreamableHTTPTransport.new(
31
+ server,
32
+ stateless: true,
33
+ # The gem's Host/Origin check guards against DNS rebinding on locally-bound servers.
34
+ # The host's own route constraints already restrict which Host values reach the
35
+ # controller, and this is not loopback-bound. Passing the incoming request's own host
36
+ # back as `allowed_hosts:` would just validate the Host header against itself.
37
+ dns_rebinding_protection: false
38
+ )
39
+ server
40
+ end
41
+
42
+ def discover_tools(server_context)
43
+ load_host_tools
44
+ (built_in_tools + host_tools).uniq + Actions.tools_for(server_context)
45
+ end
46
+
47
+ def built_in_tools
48
+ names = BUILT_IN_TOOLS.dup
49
+ names << SIDEKIQ_RETRIES_TOOL if defined?(::Sidekiq)
50
+ names << SIDEKIQ_STATS_TOOL if defined?(::Sidekiq) && Administrate::MCP.config.sidekiq_stats_provider
51
+ names.filter_map(&:safe_constantize)
52
+ end
53
+
54
+ def host_tools
55
+ BaseTool.descendants.select do |klass|
56
+ klass.name && !klass.name.start_with?('Administrate::MCP::') && klass.instance_variable_get(:@name_value)
57
+ end
58
+ end
59
+
60
+ private
61
+
62
+ def load_host_tools
63
+ Administrate::MCP.config.tool_paths.each do |path|
64
+ Dir[File.join(path.to_s, '*.rb')].each { |file| require_dependency file }
65
+ end
66
+ end
67
+ end
68
+ end
69
+ end
70
+ end