activeadmin_mcp 0.0.4 → 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 +4 -4
- data/README.md +280 -32
- data/app/controllers/activeadmin_mcp/mcp_controller.rb +0 -2
- data/app/models/activeadmin_mcp/api_token.rb +0 -2
- data/config/routes.rb +0 -2
- data/lib/activeadmin_mcp/action_catalog.rb +64 -0
- data/lib/activeadmin_mcp/action_definition.rb +183 -0
- data/lib/activeadmin_mcp/action_params.rb +78 -0
- data/lib/activeadmin_mcp/action_runner.rb +163 -0
- data/lib/activeadmin_mcp/action_schema.rb +66 -0
- data/lib/activeadmin_mcp/active_admin_ext.rb +39 -0
- data/lib/activeadmin_mcp/authorization.rb +0 -2
- data/lib/activeadmin_mcp/configuration.rb +0 -2
- data/lib/activeadmin_mcp/controller_dispatcher.rb +138 -0
- data/lib/activeadmin_mcp/engine.rb +6 -2
- data/lib/activeadmin_mcp/form_description.rb +149 -0
- data/lib/activeadmin_mcp/form_field_collector.rb +68 -15
- data/lib/activeadmin_mcp/record_writer.rb +161 -0
- data/lib/activeadmin_mcp/request_handler.rb +149 -38
- data/lib/activeadmin_mcp/resource_registry.rb +0 -2
- data/lib/activeadmin_mcp/version.rb +1 -3
- data/lib/activeadmin_mcp.rb +9 -3
- data/lib/generators/activeadmin_mcp/install/install_generator.rb +0 -2
- data/lib/generators/activeadmin_mcp/install/templates/initializer.rb +5 -2
- data/lib/generators/activeadmin_mcp/install/templates/mcp_api_tokens.rb +0 -2
- data/lib/generators/activeadmin_mcp/install/templates/migration.rb.erb +0 -2
- metadata +18 -10
- data/lib/activeadmin_mcp/record_updater.rb +0 -108
|
@@ -1,29 +1,34 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
1
|
module ActiveadminMcp
|
|
4
|
-
#
|
|
2
|
+
# Reads an ActiveAdmin `form do ... end` block and reports what each input
|
|
3
|
+
# tells a client about how to fill the field in.
|
|
5
4
|
#
|
|
6
5
|
# ActiveAdmin form blocks are arbitrary Formtastic DSL — `input`, `inputs`,
|
|
7
|
-
# `actions`,
|
|
8
|
-
# stand-in form builder. Every `input :field`
|
|
9
|
-
# message (including
|
|
10
|
-
#
|
|
11
|
-
#
|
|
12
|
-
#
|
|
6
|
+
# `actions`, application helpers, conditionals — so the block is run against
|
|
7
|
+
# this stand-in form builder rather than parsed. Every `input :field` is
|
|
8
|
+
# recorded; every other message (including helpers that would need a view
|
|
9
|
+
# context we do not have) is swallowed and returns self, so the block runs to
|
|
10
|
+
# completion outside a request.
|
|
11
|
+
#
|
|
12
|
+
# Only the options that describe the field to whoever is filling it in are
|
|
13
|
+
# kept. Presentation options (`input_html:`, `wrapper_html:` and the rest)
|
|
14
|
+
# say nothing an MCP client can act on and are dropped.
|
|
13
15
|
class FormFieldCollector
|
|
14
|
-
|
|
16
|
+
DESCRIBED_TEXT_OPTIONS = %i[label hint].freeze
|
|
15
17
|
|
|
16
18
|
def initialize
|
|
17
|
-
@
|
|
19
|
+
@inputs = []
|
|
18
20
|
end
|
|
19
21
|
|
|
20
22
|
def collect(&block)
|
|
21
23
|
instance_exec(self, &block)
|
|
22
|
-
@
|
|
24
|
+
@inputs
|
|
23
25
|
end
|
|
24
26
|
|
|
25
|
-
def input(name, *_args, **
|
|
26
|
-
|
|
27
|
+
def input(name, *_args, **options, &_block)
|
|
28
|
+
return self unless name.respond_to?(:to_sym)
|
|
29
|
+
return self if declared?(name.to_sym)
|
|
30
|
+
|
|
31
|
+
@inputs << describe(name.to_sym, options)
|
|
27
32
|
self
|
|
28
33
|
end
|
|
29
34
|
|
|
@@ -32,7 +37,14 @@ module ActiveadminMcp
|
|
|
32
37
|
self
|
|
33
38
|
end
|
|
34
39
|
|
|
35
|
-
|
|
40
|
+
# Recorded as a group of its own rather than descended into, so a client
|
|
41
|
+
# can tell an association's fields from the record's own. Flattening them
|
|
42
|
+
# would advertise `body` as an attribute of the parent record.
|
|
43
|
+
def has_many(name, *_args, **_opts, &block)
|
|
44
|
+
return self unless name.respond_to?(:to_sym)
|
|
45
|
+
return self if declared?(name.to_sym)
|
|
46
|
+
|
|
47
|
+
@inputs << { name: name.to_sym, nested: block ? self.class.new.collect(&block) : [] }
|
|
36
48
|
self
|
|
37
49
|
end
|
|
38
50
|
|
|
@@ -44,5 +56,46 @@ module ActiveadminMcp
|
|
|
44
56
|
def respond_to_missing?(_name, _include_private = false)
|
|
45
57
|
true
|
|
46
58
|
end
|
|
59
|
+
|
|
60
|
+
private
|
|
61
|
+
|
|
62
|
+
def declared?(name)
|
|
63
|
+
@inputs.any? { |input| input[:name] == name }
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def describe(name, options)
|
|
67
|
+
described = { name: name }
|
|
68
|
+
|
|
69
|
+
described[:as] = options[:as].to_sym if scalar_name?(options[:as])
|
|
70
|
+
described[:required] = options[:required] if [true, false].include?(options[:required])
|
|
71
|
+
|
|
72
|
+
DESCRIBED_TEXT_OPTIONS.each do |key|
|
|
73
|
+
described[key] = options[key].to_s if scalar_name?(options[key])
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
values = allowed_values(options[:collection])
|
|
77
|
+
described[:collection] = values if values
|
|
78
|
+
|
|
79
|
+
described
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# A helper the collector swallowed comes back as the collector itself, so
|
|
83
|
+
# anything that is not plain text is not something to report as a label.
|
|
84
|
+
def scalar_name?(value)
|
|
85
|
+
value.is_a?(String) || value.is_a?(Symbol)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Only a literal array is resolved. A relation would mean firing a query
|
|
89
|
+
# from what is meant to be a description, and can be arbitrarily large; a
|
|
90
|
+
# proc usually needs the view context this collector does not have. Either
|
|
91
|
+
# is omitted rather than evaluated.
|
|
92
|
+
def allowed_values(collection)
|
|
93
|
+
return nil unless collection.is_a?(Array)
|
|
94
|
+
|
|
95
|
+
values = collection.map { |entry| entry.is_a?(Array) ? entry.last : entry }
|
|
96
|
+
return nil unless values.all? { |value| scalar_name?(value) || value.is_a?(Numeric) }
|
|
97
|
+
|
|
98
|
+
values
|
|
99
|
+
end
|
|
47
100
|
end
|
|
48
101
|
end
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
module ActiveadminMcp
|
|
2
|
+
# Creates and updates ActiveAdmin-managed records by dispatching the
|
|
3
|
+
# resource's own create and update actions, so a write from an MCP client
|
|
4
|
+
# goes through the same permitted params, callbacks, before_action chain and
|
|
5
|
+
# authorization as the same write made by clicking Save in the admin UI.
|
|
6
|
+
#
|
|
7
|
+
# Three gates run before anything is dispatched: the resource must expose the
|
|
8
|
+
# action, the authorization adapter must allow it, and the resource must
|
|
9
|
+
# declare what may be written. The controller then applies all three again,
|
|
10
|
+
# against the record it builds or loads.
|
|
11
|
+
class RecordWriter
|
|
12
|
+
CREATE = :create
|
|
13
|
+
UPDATE = :update
|
|
14
|
+
|
|
15
|
+
def initialize(resource:, current_user:)
|
|
16
|
+
@resource = resource
|
|
17
|
+
@current_user = current_user
|
|
18
|
+
@config = resource[:config]
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def create(attributes:)
|
|
22
|
+
return error("Resource is not creatable: #{resource_name}") unless exposes?(CREATE)
|
|
23
|
+
|
|
24
|
+
permitted = resolve_permitted(attributes)
|
|
25
|
+
refusal = permit_refusal(permitted)
|
|
26
|
+
return refusal if refusal
|
|
27
|
+
return error("Not authorized to create #{resource_name}") unless authorized?(CREATE, @config.resource_class)
|
|
28
|
+
|
|
29
|
+
write(
|
|
30
|
+
action: CREATE,
|
|
31
|
+
verb: :post,
|
|
32
|
+
path: @config.route_collection_path,
|
|
33
|
+
attributes: attributes,
|
|
34
|
+
written: permitted.keys,
|
|
35
|
+
written_key: :created,
|
|
36
|
+
description: "Create #{resource_name}"
|
|
37
|
+
)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def update(id:, attributes:)
|
|
41
|
+
return error("Resource is not editable: #{resource_name}") unless exposes?(UPDATE)
|
|
42
|
+
|
|
43
|
+
record = @resource[:model].find_by(id: id)
|
|
44
|
+
return error("Record not found: #{resource_name}##{id}") unless record
|
|
45
|
+
|
|
46
|
+
permitted = resolve_permitted(attributes)
|
|
47
|
+
refusal = permit_refusal(permitted)
|
|
48
|
+
return refusal if refusal
|
|
49
|
+
return error("Not authorized to update #{resource_name}##{id}") unless authorized?(UPDATE, record)
|
|
50
|
+
|
|
51
|
+
write(
|
|
52
|
+
action: UPDATE,
|
|
53
|
+
verb: :patch,
|
|
54
|
+
path: @config.route_instance_path(record),
|
|
55
|
+
attributes: attributes,
|
|
56
|
+
written: permitted.keys,
|
|
57
|
+
written_key: :updated,
|
|
58
|
+
path_params: { id: record.to_param },
|
|
59
|
+
description: "Update #{resource_name}##{id}"
|
|
60
|
+
)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
private
|
|
64
|
+
|
|
65
|
+
def write(action:, verb:, path:, attributes:, written:, written_key:, description:, path_params: {})
|
|
66
|
+
record = nil
|
|
67
|
+
|
|
68
|
+
outcome = ControllerDispatcher.new(config: @config, current_user: @current_user).call(
|
|
69
|
+
action: action,
|
|
70
|
+
verb: verb,
|
|
71
|
+
path: path,
|
|
72
|
+
params: { @config.param_key => attributes },
|
|
73
|
+
path_params: path_params
|
|
74
|
+
) { |controller| record = controller.send(:get_resource_ivar) }
|
|
75
|
+
|
|
76
|
+
return rejection(record, description, outcome) unless saved?(record)
|
|
77
|
+
|
|
78
|
+
{
|
|
79
|
+
resource: resource_name,
|
|
80
|
+
id: record.id,
|
|
81
|
+
written_key => written,
|
|
82
|
+
record: without_sensitive_attributes(record),
|
|
83
|
+
}
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def saved?(record)
|
|
87
|
+
!record.nil? && record.persisted? && record.errors.empty?
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# The controller declined to save. Validation messages are the usual
|
|
91
|
+
# reason, and they are read off the record rather than the response
|
|
92
|
+
# because a rejected write re-renders the admin form — a render the
|
|
93
|
+
# synthesized request may well not survive, leaving the dispatch itself
|
|
94
|
+
# reported as a failure even though the record knows exactly what was
|
|
95
|
+
# wrong with it.
|
|
96
|
+
#
|
|
97
|
+
# With no record and no validation messages, anything the controller put
|
|
98
|
+
# in the flash is the best account left of what the admin UI would have
|
|
99
|
+
# told the user.
|
|
100
|
+
def rejection(record, description, outcome)
|
|
101
|
+
messages = record ? record.errors.full_messages : []
|
|
102
|
+
return error("Validation failed", details: messages) if messages.any?
|
|
103
|
+
return outcome if outcome[:error]
|
|
104
|
+
|
|
105
|
+
error("#{description} failed", details: Array(outcome[:flash]&.values).presence)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def exposes?(action)
|
|
109
|
+
@config.defined_actions.include?(action)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def authorized?(action, subject)
|
|
113
|
+
Authorization.for(@config, @current_user).authorized?(action, subject)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Turns what the controller says it would permit into a refusal, or nil to
|
|
117
|
+
# go ahead. The answer is not used to filter the write — dispatch means
|
|
118
|
+
# ActiveAdmin applies permit_params itself — but to refuse early, and with
|
|
119
|
+
# a reason, in the two cases where dispatching would otherwise fail
|
|
120
|
+
# obscurely: a resource that never declared permit_params, and a call whose
|
|
121
|
+
# every attribute would be dropped.
|
|
122
|
+
#
|
|
123
|
+
# A resource with no permit_params cannot be written through ActiveAdmin's
|
|
124
|
+
# own forms either — Rails raises ForbiddenAttributesError on the
|
|
125
|
+
# unpermitted params — so refusing it says what the admin UI would.
|
|
126
|
+
def permit_refusal(permitted)
|
|
127
|
+
if permitted.nil?
|
|
128
|
+
return error("Resource declares no permit_params, so nothing may be written: #{resource_name}")
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
error("No permitted attributes to write") if permitted.empty?
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# Asks the resource's own controller what it would permit, or nil when it
|
|
135
|
+
# has nothing to say because permit_params was never declared.
|
|
136
|
+
def resolve_permitted(attributes)
|
|
137
|
+
param_key = @config.param_key.to_sym
|
|
138
|
+
controller = @config.controller.new
|
|
139
|
+
controller.params = ActionController::Parameters.new(param_key => attributes)
|
|
140
|
+
permitted = controller.send(:permitted_params)
|
|
141
|
+
scoped = permitted && permitted[param_key]
|
|
142
|
+
scoped&.to_h&.symbolize_keys
|
|
143
|
+
rescue StandardError
|
|
144
|
+
nil
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def without_sensitive_attributes(record)
|
|
148
|
+
record.as_json.except(*ResourceRegistry.sensitive_attributes)
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def resource_name
|
|
152
|
+
@resource[:name]
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def error(message, details: nil)
|
|
156
|
+
result = { error: message }
|
|
157
|
+
result[:details] = details if details
|
|
158
|
+
result
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
end
|
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
1
|
module ActiveadminMcp
|
|
4
2
|
class RequestHandler
|
|
5
3
|
PROTOCOL_VERSION = "2025-06-18"
|
|
@@ -40,45 +38,131 @@ module ActiveadminMcp
|
|
|
40
38
|
end
|
|
41
39
|
|
|
42
40
|
def tools_list
|
|
43
|
-
{
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
41
|
+
{ tools: built_in_tools + action_tools }
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Both gates run before the schema is built, never after: ActionSchema calls
|
|
45
|
+
# the application's `suggestions:` procs, which read the database. Filtering
|
|
46
|
+
# an already-assembled list would mean those procs had already run — and
|
|
47
|
+
# their values already been read — for a user authorized for none of it.
|
|
48
|
+
def action_tools
|
|
49
|
+
ActionCatalog.all.filter_map do |definition|
|
|
50
|
+
next unless authorized_to_run?(definition)
|
|
51
|
+
next unless authorized_to_list?(definition)
|
|
52
|
+
|
|
53
|
+
{
|
|
54
|
+
name: definition.tool_name,
|
|
55
|
+
description: definition.description,
|
|
56
|
+
inputSchema: ActionSchema.new(definition).to_h,
|
|
57
|
+
}
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# The same authorization adapter check ActionRunner makes before dispatch,
|
|
62
|
+
# and the one list_resources makes for reads. There is no record at listing
|
|
63
|
+
# time, so the subject is the resource class.
|
|
64
|
+
def authorized_to_run?(definition)
|
|
65
|
+
Authorization.for(definition.config, @current_user)
|
|
66
|
+
.authorized?(definition.action_name, definition.config.resource_class)
|
|
67
|
+
rescue StandardError => e
|
|
68
|
+
warn("[activeadmin_mcp] hiding #{definition.tool_name}: authorization check raised #{e.class}: #{e.message}")
|
|
69
|
+
false
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Collection and batch actions whose permission proc takes no record can be
|
|
73
|
+
# resolved now, so the tool is simply hidden. A member action's proc needs a
|
|
74
|
+
# record, so its tool stays listed and refusal happens at call time.
|
|
75
|
+
#
|
|
76
|
+
# The proc is evaluated in controller context, exactly as ActionRunner
|
|
77
|
+
# evaluates it at call time, so `current_admin_user`, `can?` and the rest of
|
|
78
|
+
# the admin helpers are in scope. Evaluating it bare would make every such
|
|
79
|
+
# proc raise NameError and hide its tool from everybody.
|
|
80
|
+
def authorized_to_list?(definition)
|
|
81
|
+
permission = definition.permission
|
|
82
|
+
return true unless permission
|
|
83
|
+
return true unless permission.respond_to?(:arity) && permission.arity.zero?
|
|
84
|
+
|
|
85
|
+
controller = ControllerDispatcher.new(config: definition.config, current_user: @current_user)
|
|
86
|
+
.controller_with_mcp_user
|
|
87
|
+
!!::MethodOrProcHelper.render_in_context(controller, permission)
|
|
88
|
+
rescue StandardError => e
|
|
89
|
+
# One broken proc hides its own tool and nothing else, but it says so.
|
|
90
|
+
warn("[activeadmin_mcp] hiding #{definition.tool_name}: permission proc raised #{e.class}: #{e.message}")
|
|
91
|
+
false
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def built_in_tools
|
|
95
|
+
[
|
|
96
|
+
{
|
|
97
|
+
name: "list_resources",
|
|
98
|
+
description: "List the ActiveAdmin resources the authenticated user is authorized " \
|
|
99
|
+
"to read, with their attributes",
|
|
100
|
+
inputSchema: { type: "object", properties: {} },
|
|
101
|
+
},
|
|
102
|
+
{
|
|
103
|
+
name: "query",
|
|
104
|
+
description: "Query an ActiveAdmin resource using Ransack syntax. Respects ActiveAdmin " \
|
|
105
|
+
"authorization: the resource must be readable by the authenticated user, " \
|
|
106
|
+
"and results are scoped to the records they may access.",
|
|
107
|
+
inputSchema: {
|
|
108
|
+
type: "object",
|
|
109
|
+
properties: {
|
|
110
|
+
resource: { type: "string", description: "Resource name (e.g., 'User', 'Post')" },
|
|
111
|
+
q: { type: "object", description: "Ransack query (e.g., {name_cont: 'john'})" },
|
|
112
|
+
limit: { type: "integer", description: "Max records (default: 25)" },
|
|
113
|
+
},
|
|
114
|
+
required: ["resource"],
|
|
50
115
|
},
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
116
|
+
},
|
|
117
|
+
{
|
|
118
|
+
name: "describe_form",
|
|
119
|
+
description: "Describe the fields of a resource's ActiveAdmin form — their input " \
|
|
120
|
+
"types, labels, hints, allowed values, column types and which are " \
|
|
121
|
+
"required — so a create or update call need not guess them.",
|
|
122
|
+
inputSchema: {
|
|
123
|
+
type: "object",
|
|
124
|
+
properties: {
|
|
125
|
+
resource: { type: "string", description: "Resource name (e.g., 'User', 'Post')" },
|
|
126
|
+
action: {
|
|
127
|
+
type: "string",
|
|
128
|
+
enum: %w[new edit],
|
|
129
|
+
description: "Which form to describe: 'new' for creating, 'edit' for updating " \
|
|
130
|
+
"(default: 'new')",
|
|
62
131
|
},
|
|
63
|
-
required: ["resource"],
|
|
64
132
|
},
|
|
133
|
+
required: ["resource"],
|
|
65
134
|
},
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
},
|
|
77
|
-
required: %w[resource id attributes],
|
|
135
|
+
},
|
|
136
|
+
{
|
|
137
|
+
name: "create",
|
|
138
|
+
description: "Create a new record. The write runs through the resource's real " \
|
|
139
|
+
"ActiveAdmin create action, so only fields its permit_params accepts " \
|
|
140
|
+
"are written and its callbacks and authorization all apply.",
|
|
141
|
+
inputSchema: {
|
|
142
|
+
type: "object",
|
|
143
|
+
properties: {
|
|
144
|
+
resource: { type: "string", description: "Resource name (e.g., 'User', 'Post')" },
|
|
145
|
+
attributes: { type: "object", description: "Attributes for the new record (e.g., {name: 'Ada'})" },
|
|
78
146
|
},
|
|
147
|
+
required: %w[resource attributes],
|
|
79
148
|
},
|
|
80
|
-
|
|
81
|
-
|
|
149
|
+
},
|
|
150
|
+
{
|
|
151
|
+
name: "update",
|
|
152
|
+
description: "Update an existing record. The write runs through the resource's real " \
|
|
153
|
+
"ActiveAdmin update action, so only fields its permit_params accepts " \
|
|
154
|
+
"are written and its callbacks and authorization all apply.",
|
|
155
|
+
inputSchema: {
|
|
156
|
+
type: "object",
|
|
157
|
+
properties: {
|
|
158
|
+
resource: { type: "string", description: "Resource name (e.g., 'User', 'Post')" },
|
|
159
|
+
id: { type: ["integer", "string"], description: "Primary key of the record to update" },
|
|
160
|
+
attributes: { type: "object", description: "Attributes to update (e.g., {name: 'New name'})" },
|
|
161
|
+
},
|
|
162
|
+
required: %w[resource id attributes],
|
|
163
|
+
},
|
|
164
|
+
},
|
|
165
|
+
]
|
|
82
166
|
end
|
|
83
167
|
|
|
84
168
|
def call_tool(params)
|
|
@@ -88,13 +172,22 @@ module ActiveadminMcp
|
|
|
88
172
|
result = case name
|
|
89
173
|
when "list_resources" then tool_list_resources
|
|
90
174
|
when "query" then tool_query(args)
|
|
175
|
+
when "describe_form" then tool_describe_form(args)
|
|
176
|
+
when "create" then tool_create(args)
|
|
91
177
|
when "update" then tool_update(args)
|
|
92
|
-
else
|
|
178
|
+
else tool_action(name, args)
|
|
93
179
|
end
|
|
94
180
|
|
|
95
181
|
{ content: [{ type: "text", text: JSON.pretty_generate(result) }] }
|
|
96
182
|
end
|
|
97
183
|
|
|
184
|
+
def tool_action(name, args)
|
|
185
|
+
definition = ActionCatalog.find(name)
|
|
186
|
+
return { error: "Unknown tool: #{name}" } unless definition
|
|
187
|
+
|
|
188
|
+
ActionRunner.new(definition: definition, current_user: @current_user).call(args)
|
|
189
|
+
end
|
|
190
|
+
|
|
98
191
|
def tool_list_resources
|
|
99
192
|
entries = ResourceRegistry.resources.select { |entry| authorized_to_read?(entry) }
|
|
100
193
|
{ resources: entries.map { |entry| ResourceRegistry.resource_info(entry) } }
|
|
@@ -113,6 +206,24 @@ module ActiveadminMcp
|
|
|
113
206
|
{ resource: resource[:name], count: records.size, records: filter_sensitive(records.as_json) }
|
|
114
207
|
end
|
|
115
208
|
|
|
209
|
+
def tool_describe_form(args)
|
|
210
|
+
resource = ResourceRegistry.find(args["resource"])
|
|
211
|
+
return { error: "Resource not found: #{args['resource']}" } unless resource
|
|
212
|
+
|
|
213
|
+
FormDescription.new(resource: resource, current_user: @current_user)
|
|
214
|
+
.call(action: args["action"] || "new")
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def tool_create(args)
|
|
218
|
+
resource = ResourceRegistry.find(args["resource"])
|
|
219
|
+
return { error: "Resource not found: #{args['resource']}" } unless resource
|
|
220
|
+
|
|
221
|
+
attributes = args["attributes"] || {}
|
|
222
|
+
return { error: "attributes are required" } if attributes.empty?
|
|
223
|
+
|
|
224
|
+
RecordWriter.new(resource: resource, current_user: @current_user).create(attributes: attributes)
|
|
225
|
+
end
|
|
226
|
+
|
|
116
227
|
def tool_update(args)
|
|
117
228
|
resource = ResourceRegistry.find(args["resource"])
|
|
118
229
|
return { error: "Resource not found: #{args['resource']}" } unless resource
|
|
@@ -121,8 +232,8 @@ module ActiveadminMcp
|
|
|
121
232
|
attributes = args["attributes"] || {}
|
|
122
233
|
return { error: "attributes are required" } if attributes.empty?
|
|
123
234
|
|
|
124
|
-
|
|
125
|
-
|
|
235
|
+
RecordWriter.new(resource: resource, current_user: @current_user)
|
|
236
|
+
.update(id: args["id"], attributes: attributes)
|
|
126
237
|
end
|
|
127
238
|
|
|
128
239
|
def authorized_to_read?(resource)
|
data/lib/activeadmin_mcp.rb
CHANGED
|
@@ -1,11 +1,17 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
1
|
require_relative "activeadmin_mcp/version"
|
|
4
2
|
require_relative "activeadmin_mcp/configuration"
|
|
5
3
|
require_relative "activeadmin_mcp/authorization"
|
|
4
|
+
require_relative "activeadmin_mcp/active_admin_ext"
|
|
5
|
+
require_relative "activeadmin_mcp/action_definition"
|
|
6
|
+
require_relative "activeadmin_mcp/action_schema"
|
|
7
|
+
require_relative "activeadmin_mcp/action_params"
|
|
8
|
+
require_relative "activeadmin_mcp/action_catalog"
|
|
9
|
+
require_relative "activeadmin_mcp/controller_dispatcher"
|
|
10
|
+
require_relative "activeadmin_mcp/action_runner"
|
|
6
11
|
require_relative "activeadmin_mcp/resource_registry"
|
|
7
12
|
require_relative "activeadmin_mcp/form_field_collector"
|
|
8
|
-
require_relative "activeadmin_mcp/
|
|
13
|
+
require_relative "activeadmin_mcp/form_description"
|
|
14
|
+
require_relative "activeadmin_mcp/record_writer"
|
|
9
15
|
require_relative "activeadmin_mcp/request_handler"
|
|
10
16
|
require_relative "activeadmin_mcp/engine"
|
|
11
17
|
|
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
1
|
ActiveadminMcp.configure do |config|
|
|
4
2
|
# Uncomment to enable API token authentication.
|
|
5
3
|
# Requires running the auth migration first:
|
|
@@ -19,6 +17,11 @@ ActiveadminMcp.configure do |config|
|
|
|
19
17
|
# Path where the MCP server is mounted.
|
|
20
18
|
# config.mount_path = "/mcp"
|
|
21
19
|
|
|
20
|
+
# How the engine adds its route: :prepend (default), :append, or :none.
|
|
21
|
+
# Set it to :none when you want to mount the engine yourself, for instance
|
|
22
|
+
# inside a constraints block your admin routes already sit in.
|
|
23
|
+
# config.mount_strategy = :prepend
|
|
24
|
+
|
|
22
25
|
# HTTP header used to read the Bearer token from.
|
|
23
26
|
# Useful when a reverse proxy (e.g. AWS Verified Access) strips the
|
|
24
27
|
# standard Authorization header.
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: activeadmin_mcp
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.0
|
|
4
|
+
version: 0.1.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- harunkumars
|
|
@@ -16,28 +16,28 @@ dependencies:
|
|
|
16
16
|
requirements:
|
|
17
17
|
- - ">="
|
|
18
18
|
- !ruby/object:Gem::Version
|
|
19
|
-
version: '
|
|
19
|
+
version: '7.2'
|
|
20
20
|
type: :runtime
|
|
21
21
|
prerelease: false
|
|
22
22
|
version_requirements: !ruby/object:Gem::Requirement
|
|
23
23
|
requirements:
|
|
24
24
|
- - ">="
|
|
25
25
|
- !ruby/object:Gem::Version
|
|
26
|
-
version: '
|
|
26
|
+
version: '7.2'
|
|
27
27
|
- !ruby/object:Gem::Dependency
|
|
28
28
|
name: activeadmin
|
|
29
29
|
requirement: !ruby/object:Gem::Requirement
|
|
30
30
|
requirements:
|
|
31
|
-
- - "
|
|
31
|
+
- - "~>"
|
|
32
32
|
- !ruby/object:Gem::Version
|
|
33
|
-
version: '
|
|
33
|
+
version: '3.5'
|
|
34
34
|
type: :runtime
|
|
35
35
|
prerelease: false
|
|
36
36
|
version_requirements: !ruby/object:Gem::Requirement
|
|
37
37
|
requirements:
|
|
38
|
-
- - "
|
|
38
|
+
- - "~>"
|
|
39
39
|
- !ruby/object:Gem::Version
|
|
40
|
-
version: '
|
|
40
|
+
version: '3.5'
|
|
41
41
|
- !ruby/object:Gem::Dependency
|
|
42
42
|
name: rake
|
|
43
43
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -95,11 +95,19 @@ files:
|
|
|
95
95
|
- app/models/activeadmin_mcp/api_token.rb
|
|
96
96
|
- config/routes.rb
|
|
97
97
|
- lib/activeadmin_mcp.rb
|
|
98
|
+
- lib/activeadmin_mcp/action_catalog.rb
|
|
99
|
+
- lib/activeadmin_mcp/action_definition.rb
|
|
100
|
+
- lib/activeadmin_mcp/action_params.rb
|
|
101
|
+
- lib/activeadmin_mcp/action_runner.rb
|
|
102
|
+
- lib/activeadmin_mcp/action_schema.rb
|
|
103
|
+
- lib/activeadmin_mcp/active_admin_ext.rb
|
|
98
104
|
- lib/activeadmin_mcp/authorization.rb
|
|
99
105
|
- lib/activeadmin_mcp/configuration.rb
|
|
106
|
+
- lib/activeadmin_mcp/controller_dispatcher.rb
|
|
100
107
|
- lib/activeadmin_mcp/engine.rb
|
|
108
|
+
- lib/activeadmin_mcp/form_description.rb
|
|
101
109
|
- lib/activeadmin_mcp/form_field_collector.rb
|
|
102
|
-
- lib/activeadmin_mcp/
|
|
110
|
+
- lib/activeadmin_mcp/record_writer.rb
|
|
103
111
|
- lib/activeadmin_mcp/request_handler.rb
|
|
104
112
|
- lib/activeadmin_mcp/resource_registry.rb
|
|
105
113
|
- lib/activeadmin_mcp/version.rb
|
|
@@ -122,14 +130,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
|
|
|
122
130
|
requirements:
|
|
123
131
|
- - ">="
|
|
124
132
|
- !ruby/object:Gem::Version
|
|
125
|
-
version:
|
|
133
|
+
version: 4.0.0
|
|
126
134
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
127
135
|
requirements:
|
|
128
136
|
- - ">="
|
|
129
137
|
- !ruby/object:Gem::Version
|
|
130
138
|
version: '0'
|
|
131
139
|
requirements: []
|
|
132
|
-
rubygems_version:
|
|
140
|
+
rubygems_version: 4.0.20
|
|
133
141
|
specification_version: 4
|
|
134
142
|
summary: MCP server for Rails apps with ActiveAdmin
|
|
135
143
|
test_files: []
|