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