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,170 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Administrate
4
+ module MCP
5
+ # Generates self-describing MCP tools from the custom write actions declared on dashboards.
6
+ #
7
+ # A dashboard declares an action with `mcp_action`, alongside its MCP_DESCRIPTION — so one file
8
+ # defines a resource's full MCP contract (readable fields + writable actions). From each
9
+ # declaration we build a tool whose name, input schema and description let non-developer users
10
+ # discover and call it through the protocol.
11
+ #
12
+ # Enforcement is layered: the generated tool requires an OAuth `write` scope (the rollout gate)
13
+ # AND runs the resource's authorization predicate against the loaded record (the per-admin
14
+ # truth, identical to the admin UI). Auditing is inherited from BaseTool.
15
+ module Actions
16
+ Definition =
17
+ Struct.new(
18
+ :model_class,
19
+ :name,
20
+ :predicate,
21
+ :scope,
22
+ :destructive,
23
+ :description,
24
+ :params,
25
+ :required,
26
+ :invoke,
27
+ keyword_init: true
28
+ )
29
+
30
+ class << self
31
+ # Normalizes the arguments of a `mcp_action` declaration into a spec (everything but the
32
+ # model, which is inferred from the owning dashboard when definitions are built).
33
+ def build_spec(name, description:, params: {}, predicate: nil, pundit: nil, scope: :write,
34
+ destructive: true, &invoke)
35
+ raise ArgumentError, "#{name}: a block is required to invoke the action" unless invoke
36
+
37
+ normalized = normalize_params(params)
38
+
39
+ {
40
+ name: name.to_sym,
41
+ predicate: (predicate || pundit || :"#{name}?").to_sym,
42
+ scope: scope.to_sym,
43
+ destructive:,
44
+ description:,
45
+ params: normalized.transform_values { |spec| spec.except(:required) },
46
+ required: normalized.reject { |_, spec| spec[:required] == false }.keys,
47
+ invoke:
48
+ }
49
+ end
50
+
51
+ def normalize_params(params)
52
+ params.transform_values { |spec| spec.is_a?(String) ? { type: 'string', description: spec } : spec }
53
+ end
54
+
55
+ def definitions
56
+ @definitions ||=
57
+ DashboardRegistry.registry.values.flat_map do |entry|
58
+ next [] unless entry.dashboard_class.respond_to?(:mcp_action_specs)
59
+
60
+ entry.dashboard_class.mcp_action_specs.map do |spec|
61
+ Definition.new(model_class: entry.model_class, **spec)
62
+ end
63
+ end
64
+ end
65
+
66
+ def all_tools
67
+ @all_tools ||= definitions.map { |defn| ToolFactory.build(defn) }
68
+ end
69
+
70
+ # Tool classes the caller may both see and run: write scope granted AND the authorization
71
+ # predicate satisfied at the role level. The actual call re-checks it against the record.
72
+ def tools_for(server_context)
73
+ granted = (server_context[:scopes] || []).map(&:to_sym)
74
+ admin = server_context[:admin]
75
+
76
+ all_tools.select do |tool|
77
+ defn = tool.definition
78
+ granted.include?(defn.scope) && authorized_for_discovery?(admin, defn)
79
+ end
80
+ end
81
+
82
+ def reset!
83
+ @definitions = nil
84
+ @all_tools = nil
85
+ end
86
+
87
+ def find_record!(model_class, id)
88
+ record = model_class.find_by(id:) || friendly_find(model_class, id)
89
+ raise UnauthorizedError, "#{model_class.name} not found: #{id}" unless record
90
+
91
+ record
92
+ end
93
+
94
+ def authorize!(admin, record, predicate)
95
+ Administrate::MCP.config.authorization.authorize!(admin, record, predicate)
96
+ end
97
+
98
+ private
99
+
100
+ def authorized_for_discovery?(admin, definition)
101
+ Administrate::MCP.config.authorization.authorized?(admin, definition.model_class, definition.predicate)
102
+ rescue StandardError
103
+ false
104
+ end
105
+
106
+ def friendly_find(model_class, id)
107
+ return nil unless model_class.respond_to?(:friendly)
108
+
109
+ model_class.friendly.find(id)
110
+ rescue ActiveRecord::RecordNotFound
111
+ nil
112
+ end
113
+ end
114
+
115
+ # Turns one Definition into an anonymous BaseTool subclass.
116
+ module ToolFactory
117
+ class << self
118
+ def build(defn)
119
+ resource = defn.model_class.name.underscore
120
+ klass = Class.new(BaseTool)
121
+ klass.tool_name "#{resource.tr('/', '_')}_#{defn.name}"
122
+ klass.description defn.description
123
+ klass.annotations(read_only_hint: false, destructive_hint: defn.destructive, open_world_hint: true)
124
+ klass.requires_scope defn.scope
125
+ klass.input_schema(**schema_for(defn, resource))
126
+ define_behaviors(klass, defn)
127
+ klass
128
+ end
129
+
130
+ def schema_for(defn, resource)
131
+ properties = { id: { type: 'string', description: "ID or slug of the #{resource}" } }
132
+ defn.params.each { |name, spec| properties[name] = spec }
133
+ { properties:, required: %w[id] + defn.required.map(&:to_s) }
134
+ end
135
+
136
+ # Replaces the role-based default with instance-level authorization on the loaded record.
137
+ def authorize!(defn, admin, id)
138
+ record = Actions.find_record!(defn.model_class, id)
139
+ Actions.authorize!(admin, record, defn.predicate)
140
+ end
141
+
142
+ def invoke(defn, admin, id, params)
143
+ record = Actions.find_record!(defn.model_class, id)
144
+ result = defn.invoke.call(record:, admin:, params:)
145
+ return { error: result.try(:error) || "#{defn.name} failed" } if failed?(result)
146
+
147
+ { data: { status: 'ok', model: defn.model_class.name.underscore, id:, action: defn.name.to_s } }
148
+ end
149
+
150
+ private
151
+
152
+ def define_behaviors(klass, defn)
153
+ klass.define_singleton_method(:definition) { defn }
154
+ klass.define_singleton_method(:check_roles!) do |admin, id: nil, **|
155
+ ToolFactory.authorize!(defn, admin, id)
156
+ end
157
+ klass.define_singleton_method(:execute) do |admin:, id:, **params|
158
+ outcome = ToolFactory.invoke(defn, admin, id, params)
159
+ outcome.key?(:error) ? error_response(outcome[:error]) : json_response(outcome[:data])
160
+ end
161
+ end
162
+
163
+ def failed?(result)
164
+ result.respond_to?(:success?) && !result.success?
165
+ end
166
+ end
167
+ end
168
+ end
169
+ end
170
+ end
@@ -0,0 +1,114 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Administrate
4
+ module MCP
5
+ # Base class for MCP tools that operate on Administrate dashboard resources.
6
+ class AdminDashboardTool < BaseTool
7
+ LISTED_VALID_VALUES = 40
8
+
9
+ class << self
10
+ def policy_action
11
+ nil
12
+ end
13
+
14
+ def check_roles!(admin, resource: nil, **)
15
+ return unless resource && policy_action
16
+
17
+ entry = find_dashboard_entry!(resource)
18
+ authorize_resource!(admin, entry.model_class, policy_action)
19
+ end
20
+
21
+ def authorized?(admin, model_class, action)
22
+ Administrate::MCP.config.authorization.authorized?(admin, model_class, action)
23
+ end
24
+
25
+ def authorize_resource!(admin, model_class, action)
26
+ Administrate::MCP.config.authorization.authorize!(admin, model_class, action)
27
+ end
28
+
29
+ def find_dashboard_entry!(resource_name)
30
+ entry = DashboardRegistry.find(resource_name)
31
+ return entry if entry
32
+
33
+ raise UnauthorizedError, "Unknown resource: #{resource_name}"
34
+ end
35
+
36
+ def reject_unknown!(kind, given, allowed)
37
+ allowed = allowed.to_a.map(&:to_s).sort
38
+ unknown = Array.wrap(given).map(&:to_s) - allowed
39
+ return if unknown.empty?
40
+
41
+ raise InvalidArgumentError,
42
+ "Unknown #{kind}: #{unknown.sort.join(', ')}. " \
43
+ "Valid #{kind} for this resource: #{listed(allowed)}"
44
+ end
45
+
46
+ def reject_over_limit!(kind, size, max)
47
+ return if size <= max
48
+
49
+ raise InvalidArgumentError, "Too many #{kind}: #{size} requested, at most #{max} are allowed per call."
50
+ end
51
+
52
+ def listed(allowed)
53
+ return 'none' if allowed.empty?
54
+ return allowed.join(', ') if allowed.size <= LISTED_VALID_VALUES
55
+
56
+ "#{allowed.first(LISTED_VALID_VALUES).join(', ')} " \
57
+ "(+#{allowed.size - LISTED_VALID_VALUES} more, see admin_resource_list_resources)"
58
+ end
59
+
60
+ def resolve_attributes(dashboard, fields, default_method)
61
+ return dashboard.public_send(default_method) if fields.blank?
62
+
63
+ reject_unknown!('fields', fields, FieldSerializer.exposed_attributes(dashboard))
64
+
65
+ fields.map(&:to_sym)
66
+ end
67
+
68
+ # An expansion only applies to an attribute the response carries, so an expanded association
69
+ # is added to the returned attributes rather than quietly doing nothing.
70
+ def resolve_attributes_and_expansions(dashboard, fields, expand, default_method, max_expand)
71
+ attrs = resolve_attributes(dashboard, fields, default_method)
72
+ expand_set = validated_expand_set(dashboard, expand, attrs, max_expand)
73
+ return attrs, nil unless expand_set
74
+
75
+ [attrs | expand_set.to_a, expand_set]
76
+ end
77
+
78
+ # Expandable names stay bounded by show_page_attributes so an expansion cannot surface an
79
+ # attribute the dashboard withholds from its show page.
80
+ def validated_expand_set(dashboard, expand, attributes, max_expand)
81
+ return nil if expand.blank?
82
+
83
+ exposed = FieldSerializer.exposed_attributes(dashboard) | attributes.to_a
84
+ expandable = expandable_attributes(dashboard) & exposed
85
+ reject_unknown!('expandable associations', expand, expandable)
86
+ reject_over_limit!('expansions', expand.size, max_expand)
87
+
88
+ expand.to_set(&:to_sym)
89
+ end
90
+
91
+ def expandable_attributes(dashboard)
92
+ dashboard
93
+ .attribute_types
94
+ .select { |_, spec| FieldSerializer.expandable_field?(FieldSerializer.resolve_field_class(spec)) }
95
+ .keys
96
+ end
97
+
98
+ def association_columns(model_class)
99
+ column_names = model_class.column_names.to_set
100
+ model_class
101
+ .reflect_on_all_associations(:belongs_to)
102
+ .each_with_object(Set.new) do |reflection, columns|
103
+ fk = reflection.foreign_key.to_s
104
+ columns << fk if column_names.include?(fk)
105
+ next unless reflection.options[:polymorphic]
106
+
107
+ ft = reflection.foreign_type.to_s
108
+ columns << ft if column_names.include?(ft)
109
+ end
110
+ end
111
+ end
112
+ end
113
+ end
114
+ end
@@ -0,0 +1,146 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Administrate
4
+ module MCP
5
+ # Extracts and verifies the credentials on an MCP request. Session cookies are never consulted:
6
+ # hosts commonly share a session across sibling subdomains, and the protocol endpoint must not
7
+ # inherit that trust.
8
+ #
9
+ # A bearer token this server issued takes precedence: API keys and the engine's own OAuth tokens
10
+ # are resolved, and a failure on either raises rather than falling through. Anything else — an
11
+ # absent header, or a token no row matches — is offered to `config.identity_fallback`, which is
12
+ # how a host authenticates callers whose token was already resolved in front of the application.
13
+ module Authentication
14
+ OAUTH_ERROR_CODE = -32_001
15
+ API_KEY_ERROR_CODE = -32_001
16
+ MISSING_TOKEN_ERROR_CODE = -32_001
17
+ INACTIVE_ADMIN_ERROR_CODE = -32_001
18
+ EXTERNAL_IDENTITY_ERROR_CODE = -32_001
19
+
20
+ # Authenticated caller: the admin plus the scopes their credential carries. API keys are
21
+ # read-only unless granted write access; OAuth tokens carry the scopes they were granted.
22
+ Identity = Struct.new(:admin, :scopes, keyword_init: true)
23
+
24
+ # Base authentication error for MCP requests.
25
+ class Error < StandardError
26
+ def auth_error_type
27
+ 'unknown'
28
+ end
29
+
30
+ def jsonrpc_error_code
31
+ MISSING_TOKEN_ERROR_CODE
32
+ end
33
+ end
34
+
35
+ # Raised when an OAuth access token is invalid, expired, or revoked.
36
+ class OAuthTokenError < Error
37
+ def auth_error_type
38
+ 'oauth'
39
+ end
40
+
41
+ def jsonrpc_error_code
42
+ OAUTH_ERROR_CODE
43
+ end
44
+ end
45
+
46
+ # Raised when an API key is invalid or revoked.
47
+ class InvalidApiKeyError < Error
48
+ def auth_error_type
49
+ 'api_key'
50
+ end
51
+
52
+ def jsonrpc_error_code
53
+ API_KEY_ERROR_CODE
54
+ end
55
+ end
56
+
57
+ # Raised when the credential is still valid but its owner no longer is. A credential outlives
58
+ # the admin who holds it, so the host is asked on every call.
59
+ class InactiveAdminError < Error
60
+ def auth_error_type
61
+ 'inactive_admin'
62
+ end
63
+
64
+ def jsonrpc_error_code
65
+ INACTIVE_ADMIN_ERROR_CODE
66
+ end
67
+ end
68
+
69
+ # Raised by an identity fallback that recognised the caller but found no admin account for
70
+ # them. The host picks the `auth_error_type` the client sees.
71
+ class ExternalIdentityError < Error
72
+ def initialize(message = nil, auth_error_type: 'external')
73
+ super(message)
74
+ @auth_error_type = auth_error_type
75
+ end
76
+
77
+ attr_reader :auth_error_type
78
+
79
+ def jsonrpc_error_code
80
+ EXTERNAL_IDENTITY_ERROR_CODE
81
+ end
82
+ end
83
+
84
+ class << self
85
+ def authenticate!(request)
86
+ token = extract_bearer_token(request)
87
+
88
+ if token.present?
89
+ return active!(authenticate_api_key!(token)) if token.start_with?(ApiKey.token_prefix_value)
90
+
91
+ oauth_token = find_oauth_token(token)
92
+ return active!(authenticate_oauth_token!(oauth_token)) if oauth_token
93
+ end
94
+
95
+ fallback = Administrate::MCP.config.identity_fallback.call(request)
96
+ raise missing_credential_error(token) unless fallback
97
+
98
+ active!(fallback)
99
+ end
100
+
101
+ private
102
+
103
+ # With the OAuth server off the table may not even exist, so it is never queried and an
104
+ # OAuth token is just another bearer the engine does not recognise.
105
+ def find_oauth_token(token)
106
+ return nil unless Administrate::MCP.config.oauth
107
+
108
+ OAuthAccessToken.find_by_token(token)
109
+ end
110
+
111
+ def authenticate_api_key!(token)
112
+ api_key = ApiKey.authenticate(token)
113
+ raise InvalidApiKeyError, 'Invalid or revoked API key' if api_key.nil?
114
+
115
+ Identity.new(admin: api_key.admin, scopes: api_key.scopes)
116
+ end
117
+
118
+ def missing_credential_error(token)
119
+ return Error.new('Missing Authorization header') if token.blank?
120
+
121
+ OAuthTokenError.new('Invalid token')
122
+ end
123
+
124
+ def active!(identity)
125
+ return identity if Administrate::MCP.config.admin_active.call(identity.admin)
126
+
127
+ raise InactiveAdminError, 'Admin is no longer active'
128
+ end
129
+
130
+ def authenticate_oauth_token!(oauth_token)
131
+ raise OAuthTokenError, 'Token has been revoked' if oauth_token.revoked?
132
+ raise OAuthTokenError, 'Token has expired' if oauth_token.expired?
133
+
134
+ Identity.new(admin: oauth_token.admin, scopes: oauth_token.scopes.to_s.split)
135
+ end
136
+
137
+ def extract_bearer_token(request)
138
+ header = request.headers['Authorization']
139
+ return nil if header.blank?
140
+
141
+ header[/\ABearer (.+)\z/, 1]
142
+ end
143
+ end
144
+ end
145
+ end
146
+ end
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Administrate
4
+ module MCP
5
+ # Base class for all MCP tools — provides role gating, auditing, and response helpers.
6
+ class BaseTool < ::MCP::Tool
7
+ UnauthorizedError = Administrate::MCP::UnauthorizedError
8
+ InvalidArgumentError = Administrate::MCP::InvalidArgumentError
9
+
10
+ class << self
11
+ def requires_roles(*roles)
12
+ @required_roles = roles.flatten
13
+ end
14
+
15
+ def required_roles
16
+ @required_roles || Administrate::MCP.config.default_required_roles
17
+ end
18
+
19
+ def requires_scope(*scopes)
20
+ @required_scopes = scopes.map(&:to_sym)
21
+ end
22
+
23
+ def required_scopes
24
+ @required_scopes || []
25
+ end
26
+
27
+ def call(server_context:, **args)
28
+ instrument(server_context) { call_with_context(server_context, **args) }
29
+ rescue UnauthorizedError => e
30
+ (server_context[:authorization_errors] ||= []).push(e.message)
31
+ error_response(e.message)
32
+ rescue InvalidArgumentError => e
33
+ error_response(e.message)
34
+ rescue StandardError => e
35
+ error_response("Internal error: #{e.message}")
36
+ end
37
+
38
+ private
39
+
40
+ def instrument(server_context, &)
41
+ Administrate::MCP.config.instrument.call(tool_name: name_value, admin: server_context[:admin], &)
42
+ end
43
+
44
+ def call_with_context(server_context, **args)
45
+ admin = server_context[:admin]
46
+ check_scopes!(server_context)
47
+ check_roles!(admin, **args)
48
+ audit!(admin, args, server_context)
49
+ execute(admin:, **args)
50
+ end
51
+
52
+ def check_scopes!(server_context)
53
+ return if required_scopes.empty?
54
+
55
+ granted = (server_context[:scopes] || []).map(&:to_sym)
56
+ missing = required_scopes - granted
57
+ return if missing.empty?
58
+
59
+ raise UnauthorizedError, "Missing required scope(s): #{missing.join(', ')}"
60
+ end
61
+
62
+ def check_roles!(admin, **)
63
+ Administrate::MCP.config.authorization.authorize_roles!(admin, required_roles)
64
+ end
65
+
66
+ def audit!(admin, args, server_context)
67
+ Administrate::MCP.config.on_tool_call.call(
68
+ tool_name: name_value,
69
+ admin:,
70
+ arguments: args,
71
+ scopes: server_context[:scopes] || []
72
+ )
73
+ end
74
+
75
+ def execute(admin:, **args)
76
+ raise NotImplementedError, "#{name} must implement .execute"
77
+ end
78
+
79
+ def text_response(text)
80
+ ::MCP::Tool::Response.new([{ type: 'text', text: }])
81
+ end
82
+
83
+ def json_response(data)
84
+ ::MCP::Tool::Response.new([{ type: 'text', text: data.to_json }])
85
+ end
86
+
87
+ def error_response(message)
88
+ ::MCP::Tool::Response.new([{ type: 'text', text: message }], error: true)
89
+ end
90
+ end
91
+ end
92
+ end
93
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Administrate
4
+ module MCP
5
+ # Removes MCP feedbacks older than the given cutoff, one batch at a time. The caller decides
6
+ # whether to run the next batch: `call` reports whether the batch was full.
7
+ class CleanOldFeedbacks
8
+ BATCH_SIZE = 10_000
9
+
10
+ Result = Struct.new(:deleted_count, :more?, keyword_init: true)
11
+
12
+ def self.call(...)
13
+ new(...).call
14
+ end
15
+
16
+ def initialize(till: 2.months.ago)
17
+ @till = till
18
+ end
19
+
20
+ def call
21
+ deleted_count = Feedback.where(created_at: ...@till).limit(BATCH_SIZE).delete_all
22
+ Result.new(deleted_count:, more?: deleted_count == BATCH_SIZE)
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Administrate
4
+ module MCP
5
+ # Maps resource names (e.g. "card", "card_factory/card_sample") to their dashboard and model
6
+ # classes. Dashboards can declare MCP_DESCRIPTION to provide a human-readable description for
7
+ # LLM tool discovery, MCP_BASE_SCOPE (a proc returning a relation) to override the model's
8
+ # default scope, MCP_SKIPPED_ATTRIBUTES to keep attributes out of MCP reads, and
9
+ # MCP_EXPOSED = false to leave the resource out of the registry altogether.
10
+ # default scope for MCP reads — mirroring an admin controller's custom scoped_resource.
11
+ class DashboardRegistry
12
+ Entry =
13
+ Struct.new(:dashboard_class, :model_class, :description, :base_scope, keyword_init: true) do
14
+ def scope
15
+ base_scope ? base_scope.call : model_class.all
16
+ end
17
+ end
18
+
19
+ class << self
20
+ def find(resource_name)
21
+ key = resource_name.to_s.underscore.singularize
22
+ registry[key]
23
+ end
24
+
25
+ def resource_names
26
+ registry.keys.sort
27
+ end
28
+
29
+ def reset!
30
+ @registry = nil
31
+ end
32
+
33
+ def registry
34
+ @registry ||= build_registry
35
+ end
36
+
37
+ def build_registry
38
+ dashboard_files.each_with_object({}) do |file, result|
39
+ entry = build_entry(file)
40
+ next unless entry
41
+
42
+ result[entry.model_class.name.underscore] = entry
43
+ rescue NameError
44
+ next
45
+ end
46
+ end
47
+
48
+ def build_entry(file)
49
+ require_dependency file
50
+ class_name = dashboard_class_name(file)
51
+ dashboard_class = class_name.safe_constantize
52
+ return unless dashboard_class
53
+ return unless dashboard_class < Administrate::BaseDashboard
54
+ return if dashboard_constant(dashboard_class, :MCP_EXPOSED) == false
55
+
56
+ model_class = infer_model_class(dashboard_class, class_name)
57
+ return unless model_class
58
+
59
+ Entry.new(
60
+ dashboard_class:,
61
+ model_class:,
62
+ description: dashboard_constant(dashboard_class, :MCP_DESCRIPTION),
63
+ base_scope: dashboard_constant(dashboard_class, :MCP_BASE_SCOPE)
64
+ )
65
+ end
66
+
67
+ def dashboard_files
68
+ dashboard_roots.flat_map { |root| Dir[File.join(root, '**', '*_dashboard.rb')] }
69
+ end
70
+
71
+ def dashboard_class_name(file)
72
+ root = dashboard_roots.find { |candidate| file.start_with?("#{candidate}/") }
73
+ file.delete_prefix("#{root}/").delete_suffix('.rb').camelize
74
+ end
75
+
76
+ def infer_model_class(dashboard_class, class_name)
77
+ dashboard_class.model
78
+ rescue StandardError
79
+ class_name.delete_suffix('Dashboard').safe_constantize
80
+ end
81
+
82
+ def skipped_attributes(dashboard)
83
+ dashboard_class = dashboard.is_a?(Class) ? dashboard : dashboard.class
84
+ Array(dashboard_constant(dashboard_class, :MCP_SKIPPED_ATTRIBUTES)).map(&:to_sym)
85
+ end
86
+
87
+ private
88
+
89
+ def dashboard_roots
90
+ Administrate::MCP.config.dashboard_directories
91
+ end
92
+
93
+ # `inherit: false`: a constant of the same name defined at the top level of the host would
94
+ # otherwise be found on every dashboard, and a stray MCP_EXPOSED = false would empty the
95
+ # registry. A dashboard that wants one has to declare it itself.
96
+ def dashboard_constant(dashboard_class, name)
97
+ dashboard_class.const_defined?(name, false) ? dashboard_class.const_get(name, false) : nil
98
+ end
99
+ end
100
+ end
101
+ end
102
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Administrate
4
+ module MCP
5
+ # Administrate's Search with exact matching by default: `*` is the only wildcard, so an id or a
6
+ # slug does not accidentally match every row that contains it.
7
+ class FastSearch < Administrate::Search
8
+ PRIMARY_COLUMN_NAMES = %i[id slug].freeze
9
+
10
+ def query_template
11
+ return '' if term.blank?
12
+
13
+ search_attributes.map { |attr| attribute_template(attr) }.join(' OR ')
14
+ end
15
+
16
+ def operator
17
+ exact_term.include?('%') ? 'LIKE' : '='
18
+ end
19
+
20
+ def query_values
21
+ [exact_term] * search_attributes.sum { |attr| searchable_fields(attr).count }
22
+ end
23
+
24
+ def exact_term
25
+ term.downcase.tr('*', '%').presence || '%'
26
+ end
27
+
28
+ private
29
+
30
+ def attribute_template(attr)
31
+ table_name = query_table_name(attr)
32
+ searchable_fields(attr).map { |field| field_template(table_name, field) }.join(' OR ')
33
+ end
34
+
35
+ def field_template(table_name, field)
36
+ column_name = column_to_query(field)
37
+ return "LOWER(CAST(#{table_name}.#{column_name} AS TEXT)) #{operator} ?" unless primary?(field)
38
+
39
+ "CAST(#{table_name}.#{column_name} AS TEXT) #{operator} ?"
40
+ end
41
+
42
+ def primary?(field)
43
+ PRIMARY_COLUMN_NAMES.include?(field)
44
+ end
45
+ end
46
+ end
47
+ end