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,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
|