admin_suite 0.5.0 → 0.6.1
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/.gitignore +7 -0
- data/CHANGELOG.md +69 -11
- data/CONTRIBUTING.md +9 -4
- data/README.md +21 -5
- data/app/controllers/admin_suite/application_controller.rb +12 -11
- data/app/controllers/admin_suite/mcp_controller.rb +36 -0
- data/app/controllers/admin_suite/resources_controller.rb +7 -78
- data/app/views/admin_suite/resources/index.html.erb +1 -0
- data/config/routes.rb +5 -0
- data/lib/admin/base/resource.rb +7 -29
- data/lib/admin_suite/auth/host_user.rb +42 -0
- data/lib/admin_suite/auth/strategy.rb +1 -1
- data/lib/admin_suite/auth.rb +15 -0
- data/lib/admin_suite/authorization_context.rb +28 -0
- data/lib/admin_suite/configuration.rb +56 -2
- data/lib/admin_suite/mcp/authorization.rb +68 -0
- data/lib/admin_suite/mcp/serializer.rb +138 -0
- data/lib/admin_suite/mcp/tools/aggregate.rb +55 -0
- data/lib/admin_suite/mcp/tools/describe_resources.rb +63 -0
- data/lib/admin_suite/mcp/tools/get_record.rb +41 -0
- data/lib/admin_suite/mcp/tools/list_records.rb +67 -0
- data/lib/admin_suite/mcp.rb +53 -0
- data/lib/admin_suite/query.rb +71 -0
- data/lib/admin_suite/ui/show_value_formatter.rb +2 -2
- data/lib/admin_suite/version.rb +1 -1
- data/lib/admin_suite.rb +14 -9
- data/lib/generators/admin_suite/install/templates/admin_suite.rb +5 -2
- data/test/controllers/resources_controller_test.rb +19 -13
- data/test/integration/authentication_test.rb +10 -0
- data/test/integration/authorization_test.rb +20 -3
- data/test/integration/index_query_characterization_test.rb +229 -0
- data/test/integration/mcp_aggregate_test.rb +44 -0
- data/test/integration/mcp_authorization_test.rb +102 -0
- data/test/integration/mcp_endpoint_test.rb +89 -0
- data/test/integration/mcp_get_record_test.rb +62 -0
- data/test/integration/mcp_instrumentation_test.rb +107 -0
- data/test/integration/mcp_list_records_test.rb +75 -0
- data/test/integration/mcp_parity_test.rb +155 -0
- data/test/integration/mcp_release_test.rb +131 -0
- data/test/integration/read_only_resource_test.rb +128 -2
- data/test/integration/searchable_select_search_test.rb +5 -4
- data/test/lib/auth_host_user_test.rb +52 -0
- data/test/lib/auth_http_basic_test.rb +10 -0
- data/test/lib/auth_test.rb +20 -3
- data/test/lib/authorization_context_test.rb +127 -0
- data/test/lib/engine_defaults_test.rb +1 -2
- data/test/lib/mcp_serializer_test.rb +107 -0
- data/test/lib/query_test.rb +91 -0
- data/test/lib/removed_deprecations_test.rb +16 -0
- data/test/publish_workflow_test.rb +63 -0
- data/test/test_helper.rb +26 -0
- metadata +42 -5
- data/lib/admin_suite/renderers/legacy_gleania.rb +0 -232
- data/test/lib/legacy_renderer_deprecation_test.rb +0 -42
- data/test/lib/resource_exportable_deprecation_test.rb +0 -47
|
@@ -5,7 +5,6 @@ module AdminSuite
|
|
|
5
5
|
class Configuration
|
|
6
6
|
attr_accessor :authenticate,
|
|
7
7
|
:current_actor,
|
|
8
|
-
:authorize,
|
|
9
8
|
:auth_strategy,
|
|
10
9
|
:auth_options,
|
|
11
10
|
:allow_unauthenticated,
|
|
@@ -31,7 +30,61 @@ module AdminSuite
|
|
|
31
30
|
:on_action_executed,
|
|
32
31
|
:resolve_action_handler
|
|
33
32
|
|
|
34
|
-
|
|
33
|
+
McpConfig = Struct.new(:enabled, :max_page_size, keyword_init: true)
|
|
34
|
+
|
|
35
|
+
attr_reader :portals, :authorize, :mcp
|
|
36
|
+
|
|
37
|
+
# The authorize hook's keywords are validated at assignment rather than
|
|
38
|
+
# at call time: a hook with the pre-0.6.0 `controller:` keyword would
|
|
39
|
+
# otherwise raise deep inside a request (or, worse, bind `context:` to
|
|
40
|
+
# nothing and silently mis-evaluate). Failing in the initializer puts
|
|
41
|
+
# the error where the mistake is.
|
|
42
|
+
REQUIRED_AUTHORIZE_KEYWORDS = %i[actor action resource record context].freeze
|
|
43
|
+
|
|
44
|
+
def authorize=(hook)
|
|
45
|
+
if hook
|
|
46
|
+
parameters =
|
|
47
|
+
if hook.respond_to?(:parameters)
|
|
48
|
+
hook.parameters
|
|
49
|
+
elsif hook.respond_to?(:call)
|
|
50
|
+
# A plain object implementing #call is a legitimate hook, and its
|
|
51
|
+
# signature is still introspectable -- one level down, on the
|
|
52
|
+
# method itself. Falling back here keeps the keyword guard working
|
|
53
|
+
# for callables instead of skipping validation for them.
|
|
54
|
+
hook.method(:call).parameters
|
|
55
|
+
else
|
|
56
|
+
raise ArgumentError,
|
|
57
|
+
"config.authorize must be callable (a lambda, proc, method, or an object responding to #call), got #{hook.class}."
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
keywords = parameters.filter_map { |type, name| name if %i[key keyreq].include?(type) }
|
|
61
|
+
|
|
62
|
+
# A `**` splat absorbs every keyword, so such a hook cannot be missing one --
|
|
63
|
+
# `->(**) {}` reports `[[:keyrest, :**]]` and no :key/:keyreq at all. Test
|
|
64
|
+
# doubles and coarse "deny everything" hooks are written this way; rejecting
|
|
65
|
+
# them would be a false positive.
|
|
66
|
+
accepts_rest = parameters.any? { |type, _| type == :keyrest }
|
|
67
|
+
|
|
68
|
+
missing = accepts_rest ? [] : REQUIRED_AUTHORIZE_KEYWORDS - keywords
|
|
69
|
+
# The `extra` check still runs against explicitly named keywords even when a
|
|
70
|
+
# splat is present: `->(controller:, **)` is exactly the mistake this guard
|
|
71
|
+
# exists to catch, and the splat would otherwise hide it -- `controller:`
|
|
72
|
+
# binds to nil while `**` quietly swallows the real arguments, so the hook
|
|
73
|
+
# mis-evaluates instead of failing.
|
|
74
|
+
extra = keywords - REQUIRED_AUTHORIZE_KEYWORDS
|
|
75
|
+
|
|
76
|
+
unless missing.empty? && extra.empty?
|
|
77
|
+
raise ArgumentError, <<~MESSAGE
|
|
78
|
+
config.authorize must accept exactly (actor:, action:, resource:, record:, context:).
|
|
79
|
+
Missing: #{missing.inspect}. Unexpected: #{extra.inspect}.
|
|
80
|
+
As of admin_suite 0.6.0 the `controller:` keyword is replaced by `context:`,
|
|
81
|
+
which carries `surface` (:web or :mcp), `controller` (web only) and `request`.
|
|
82
|
+
MESSAGE
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
@authorize = hook
|
|
87
|
+
end
|
|
35
88
|
|
|
36
89
|
# Records that the host explicitly assigned portals (even to `{}`), so
|
|
37
90
|
# the engine's built-in defaults are never re-applied over explicit
|
|
@@ -77,6 +130,7 @@ module AdminSuite
|
|
|
77
130
|
@root_dashboard_loaded = false
|
|
78
131
|
@on_action_executed = nil
|
|
79
132
|
@resolve_action_handler = nil
|
|
133
|
+
@mcp = McpConfig.new(enabled: true, max_page_size: AdminSuite::Query::MAX_PAGE_SIZE)
|
|
80
134
|
end
|
|
81
135
|
|
|
82
136
|
# Sets the built-in default portals without marking portals as
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AdminSuite
|
|
4
|
+
module Mcp
|
|
5
|
+
module Authorization
|
|
6
|
+
DENIED_MESSAGE = "Not authorized, or no such resource."
|
|
7
|
+
|
|
8
|
+
class << self
|
|
9
|
+
def readable_resources(actor:, request: nil)
|
|
10
|
+
return [] if AdminSuite.config.authorize.nil? || Auth.normalize_actor(actor).nil?
|
|
11
|
+
|
|
12
|
+
resource_configs.select do |config|
|
|
13
|
+
mcp_enabled?(config) && permitted?(config: config, actor: actor, action: :read, request: request)
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def authorize_resource!(name:, actor:, action:, request: nil)
|
|
18
|
+
return nil if AdminSuite.config.authorize.nil? || Auth.normalize_actor(actor).nil?
|
|
19
|
+
|
|
20
|
+
config = resource_configs.find { |resource| resource.resource_name == name.to_s }
|
|
21
|
+
return nil unless config && mcp_enabled?(config)
|
|
22
|
+
|
|
23
|
+
config if permitted?(config: config, actor: actor, action: action, request: request)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def authorize_record?(config:, actor:, record:, request: nil)
|
|
27
|
+
return false if AdminSuite.config.authorize.nil? || Auth.normalize_actor(actor).nil?
|
|
28
|
+
|
|
29
|
+
permitted?(config: config, actor: actor, record: record, action: :read, request: request)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def denied_response
|
|
33
|
+
::MCP::Tool::Response.new([{ type: "text", text: DENIED_MESSAGE }], error: true)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def error_response(message)
|
|
37
|
+
::MCP::Tool::Response.new([{ type: "text", text: message }], error: true)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
def resource_configs
|
|
43
|
+
AdminSuite::DefinitionLoader.load!(:resources)
|
|
44
|
+
Admin::Base::Resource.registered_resources
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def mcp_enabled?(config)
|
|
48
|
+
!config.respond_to?(:mcp_enabled?) || config.mcp_enabled?
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def permitted?(config:, actor:, action:, record: nil, request: nil)
|
|
52
|
+
AdminSuite.config.authorize.call(
|
|
53
|
+
actor: actor,
|
|
54
|
+
action: action,
|
|
55
|
+
resource: config,
|
|
56
|
+
record: record,
|
|
57
|
+
context: AdminSuite::AuthorizationContext.new(surface: :mcp, request: request)
|
|
58
|
+
)
|
|
59
|
+
rescue StandardError => error
|
|
60
|
+
Rails.logger&.warn(
|
|
61
|
+
"AdminSuite: MCP authorize hook raised #{error.class}: #{error.message}; denying."
|
|
62
|
+
)
|
|
63
|
+
false
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AdminSuite
|
|
4
|
+
module Mcp
|
|
5
|
+
# Serializes only fields declared by the resource DSL. This is the MCP
|
|
6
|
+
# surface's data-exposure boundary.
|
|
7
|
+
module Serializer
|
|
8
|
+
def self.index_row(record, config)
|
|
9
|
+
config.index_config.columns_list.each_with_object({}) do |column, row|
|
|
10
|
+
row[column.name] = primitive(resolve_column(record, column))
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def self.show_payload(record, config)
|
|
15
|
+
sections = config.show_config&.sidebar_sections.to_a +
|
|
16
|
+
config.show_config&.main_sections.to_a
|
|
17
|
+
|
|
18
|
+
sections.each_with_object({}) do |section, payload|
|
|
19
|
+
Array(section.fields).each do |field|
|
|
20
|
+
payload[field] = primitive(value_for(record, field))
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def self.associations_payload(record, config, max_rows:)
|
|
26
|
+
sections = config.show_config&.sidebar_sections.to_a + config.show_config&.main_sections.to_a
|
|
27
|
+
sections.each_with_object({}) do |section, payload|
|
|
28
|
+
next if section.association.blank? || section.columns.blank?
|
|
29
|
+
|
|
30
|
+
payload[section.name] = association_panel(record, section, max_rows)
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def self.dump(payload)
|
|
35
|
+
JSON.pretty_generate(primitive(payload))
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def self.resolve_column(record, column)
|
|
39
|
+
if column.respond_to?(:type) && column.type == :toggle
|
|
40
|
+
field = column.respond_to?(:toggle_field) ? (column.toggle_field || column.name) : column.name
|
|
41
|
+
value_for(record, field)
|
|
42
|
+
elsif column.respond_to?(:type) && column.type == :label
|
|
43
|
+
proc_or_attribute(record, column)
|
|
44
|
+
elsif column.respond_to?(:content) && column.content.is_a?(Proc)
|
|
45
|
+
column.content.call(record)
|
|
46
|
+
else
|
|
47
|
+
value_for(record, column.name)
|
|
48
|
+
end
|
|
49
|
+
rescue StandardError
|
|
50
|
+
nil
|
|
51
|
+
end
|
|
52
|
+
private_class_method :resolve_column
|
|
53
|
+
|
|
54
|
+
def self.proc_or_attribute(record, column)
|
|
55
|
+
if column.respond_to?(:content) && column.content.is_a?(Proc)
|
|
56
|
+
column.content.call(record)
|
|
57
|
+
else
|
|
58
|
+
value_for(record, column.name)
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
private_class_method :proc_or_attribute
|
|
62
|
+
|
|
63
|
+
def self.association_panel(record, section, max_rows)
|
|
64
|
+
limit = Integer(section.limit || section.per_page || max_rows).clamp(1..max_rows)
|
|
65
|
+
rows = record.public_send(section.association)
|
|
66
|
+
rows = rows.respond_to?(:limit) ? rows.limit(limit) : Array(rows).first(limit)
|
|
67
|
+
{
|
|
68
|
+
applied_limit: limit,
|
|
69
|
+
rows: Array(rows).map do |row|
|
|
70
|
+
section.columns.to_h { |column| [ column, primitive(value_for(row, column)) ] }
|
|
71
|
+
end
|
|
72
|
+
}
|
|
73
|
+
rescue StandardError
|
|
74
|
+
{ applied_limit: 0, rows: [] }
|
|
75
|
+
end
|
|
76
|
+
private_class_method :association_panel
|
|
77
|
+
|
|
78
|
+
def self.value_for(record, name)
|
|
79
|
+
return nil unless record.respond_to?(name)
|
|
80
|
+
|
|
81
|
+
record.public_send(name)
|
|
82
|
+
rescue StandardError
|
|
83
|
+
nil
|
|
84
|
+
end
|
|
85
|
+
private_class_method :value_for
|
|
86
|
+
|
|
87
|
+
def self.primitive(value)
|
|
88
|
+
case value
|
|
89
|
+
when nil, true, false, String, Integer, Float
|
|
90
|
+
value
|
|
91
|
+
when Symbol
|
|
92
|
+
value.to_s
|
|
93
|
+
when Date, Time, DateTime
|
|
94
|
+
value.iso8601
|
|
95
|
+
when Array
|
|
96
|
+
value.map { |item| primitive(item) }
|
|
97
|
+
when Hash
|
|
98
|
+
value.each_with_object({}) { |(key, item), acc| acc[key] = primitive(item) }
|
|
99
|
+
else
|
|
100
|
+
coerce_object(value)
|
|
101
|
+
end
|
|
102
|
+
rescue StandardError
|
|
103
|
+
nil
|
|
104
|
+
end
|
|
105
|
+
private_class_method :primitive
|
|
106
|
+
|
|
107
|
+
def self.coerce_object(value)
|
|
108
|
+
if defined?(ActiveSupport::TimeWithZone) && value.is_a?(ActiveSupport::TimeWithZone)
|
|
109
|
+
value.iso8601
|
|
110
|
+
elsif defined?(BigDecimal) && value.is_a?(BigDecimal)
|
|
111
|
+
value.to_s("F")
|
|
112
|
+
elsif defined?(ActiveRecord::Base) && value.is_a?(ActiveRecord::Base)
|
|
113
|
+
display_name(value)
|
|
114
|
+
elsif value.respond_to?(:iso8601)
|
|
115
|
+
value.iso8601
|
|
116
|
+
else
|
|
117
|
+
display_name(value)
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
private_class_method :coerce_object
|
|
121
|
+
|
|
122
|
+
def self.display_name(item)
|
|
123
|
+
%i[name title display_title].each do |method_name|
|
|
124
|
+
next unless item.respond_to?(method_name)
|
|
125
|
+
|
|
126
|
+
text = item.public_send(method_name)
|
|
127
|
+
return text.to_s if text.present?
|
|
128
|
+
end
|
|
129
|
+
return "##{item.id}" if item.respond_to?(:id) && !item.id.nil?
|
|
130
|
+
|
|
131
|
+
item.class.name
|
|
132
|
+
rescue StandardError
|
|
133
|
+
nil
|
|
134
|
+
end
|
|
135
|
+
private_class_method :display_name
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AdminSuite
|
|
4
|
+
module Mcp
|
|
5
|
+
module Tools
|
|
6
|
+
class Aggregate < ::MCP::Tool
|
|
7
|
+
tool_name "aggregate"
|
|
8
|
+
description "Counts and index stats for a resource under a filter set. Returns no records."
|
|
9
|
+
input_schema(
|
|
10
|
+
properties: {
|
|
11
|
+
resource: { type: "string" },
|
|
12
|
+
q: { type: "string" },
|
|
13
|
+
filters: { type: "object" }
|
|
14
|
+
},
|
|
15
|
+
required: ["resource"]
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
def self.call(resource:, server_context:, q: nil, filters: {})
|
|
19
|
+
AdminSuite::Mcp.instrument(tool: "aggregate", resource: resource, actor: server_context[:actor], request: server_context[:request], filters: filters, q: q) do
|
|
20
|
+
allowed = false
|
|
21
|
+
config = Authorization.authorize_resource!(name: resource, actor: server_context[:actor], action: :read, request: server_context[:request])
|
|
22
|
+
next [Authorization.denied_response, nil, false] if config.nil?
|
|
23
|
+
|
|
24
|
+
allowed = true
|
|
25
|
+
params = (filters || {}).merge(search: q).compact
|
|
26
|
+
scope = AdminSuite::Query.new(
|
|
27
|
+
resource_config: config,
|
|
28
|
+
params: params,
|
|
29
|
+
max_page_size: AdminSuite.config.mcp.max_page_size
|
|
30
|
+
).scope
|
|
31
|
+
payload = { resource: resource, count: scope.count, stats: stats_for(config, scope) }
|
|
32
|
+
response = ::MCP::Tool::Response.new([{ type: "text", text: Serializer.dump(payload) }])
|
|
33
|
+
[response, payload[:count], true]
|
|
34
|
+
rescue StandardError => e
|
|
35
|
+
Rails.logger&.warn("AdminSuite MCP aggregate failed: #{e.class}: #{e.message}")
|
|
36
|
+
[Authorization.error_response("aggregate failed"), nil, allowed]
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def self.stats_for(config, scope)
|
|
41
|
+
Array(config.index_config&.stats_list).each_with_object({}) do |stat, stats|
|
|
42
|
+
stats[stat.name] = calculate(stat, scope)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def self.calculate(stat, scope)
|
|
47
|
+
stat.calculator.arity.zero? ? stat.calculator.call : stat.calculator.call(scope)
|
|
48
|
+
rescue StandardError
|
|
49
|
+
nil
|
|
50
|
+
end
|
|
51
|
+
private_class_method :calculate
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AdminSuite
|
|
4
|
+
module Mcp
|
|
5
|
+
module Tools
|
|
6
|
+
class DescribeResources < ::MCP::Tool
|
|
7
|
+
tool_name "describe_resources"
|
|
8
|
+
description "List the admin resources this actor may read, with their " \
|
|
9
|
+
"portals, declared fields, filters, sort keys, page size and actions."
|
|
10
|
+
input_schema(properties: {}, required: [])
|
|
11
|
+
|
|
12
|
+
def self.call(server_context:)
|
|
13
|
+
AdminSuite::Mcp.instrument(tool: "describe_resources", actor: server_context[:actor], request: server_context[:request]) do
|
|
14
|
+
allowed = false
|
|
15
|
+
if AdminSuite.config.authorize.nil? || Auth.normalize_actor(server_context[:actor]).nil?
|
|
16
|
+
[Authorization.denied_response, nil, false]
|
|
17
|
+
else
|
|
18
|
+
allowed = true
|
|
19
|
+
payload = Authorization
|
|
20
|
+
.readable_resources(actor: server_context[:actor], request: server_context[:request])
|
|
21
|
+
.map { |config| describe(config) }
|
|
22
|
+
response = ::MCP::Tool::Response.new([{ type: "text", text: Serializer.dump(payload) }])
|
|
23
|
+
[response, payload.size, true]
|
|
24
|
+
end
|
|
25
|
+
rescue StandardError => e
|
|
26
|
+
Rails.logger&.warn("AdminSuite MCP describe_resources failed: #{e.class}: #{e.message}")
|
|
27
|
+
[Authorization.error_response("describe_resources failed"), nil, allowed]
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def self.describe(config)
|
|
32
|
+
index = config.index_config
|
|
33
|
+
{
|
|
34
|
+
name: config.resource_name,
|
|
35
|
+
model: config.model_class.to_s,
|
|
36
|
+
portal: config.portal_name,
|
|
37
|
+
section: config.section_name,
|
|
38
|
+
fields: Array(index&.columns_list).map do |column|
|
|
39
|
+
{ name: column.name, label: column.header, type: column.type }
|
|
40
|
+
end,
|
|
41
|
+
filters: Array(index&.filters_list).map(&:name),
|
|
42
|
+
sortable: Array(index&.sortable_fields),
|
|
43
|
+
searchable: Array(index&.searchable_fields),
|
|
44
|
+
default_page_size: index&.per_page,
|
|
45
|
+
actions: declared_actions(config)
|
|
46
|
+
}
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def self.declared_actions(config)
|
|
50
|
+
actions = config.actions_config
|
|
51
|
+
return [] unless actions
|
|
52
|
+
|
|
53
|
+
[
|
|
54
|
+
*Array(actions.member_actions).map { |action| { name: action.name, kind: "member" } },
|
|
55
|
+
*Array(actions.collection_actions).map { |action| { name: action.name, kind: "collection" } },
|
|
56
|
+
*Array(actions.bulk_actions).map { |action| { name: action.name, kind: "bulk" } }
|
|
57
|
+
]
|
|
58
|
+
end
|
|
59
|
+
private_class_method :declared_actions
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AdminSuite
|
|
4
|
+
module Mcp
|
|
5
|
+
module Tools
|
|
6
|
+
class GetRecord < ::MCP::Tool
|
|
7
|
+
tool_name "get_record"
|
|
8
|
+
description "One record by id, using the resource's show-page field set."
|
|
9
|
+
input_schema(
|
|
10
|
+
properties: { resource: { type: "string" }, id: { type: "string" } },
|
|
11
|
+
required: %w[resource id]
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
def self.call(resource:, id:, server_context:)
|
|
15
|
+
AdminSuite::Mcp.instrument(tool: "get_record", resource: resource, actor: server_context[:actor], request: server_context[:request]) do
|
|
16
|
+
allowed = false
|
|
17
|
+
config = Authorization.authorize_resource!(name: resource, actor: server_context[:actor], action: :read, request: server_context[:request])
|
|
18
|
+
next [Authorization.denied_response, nil, false] if config.nil?
|
|
19
|
+
|
|
20
|
+
allowed = true
|
|
21
|
+
record = config.model_class.find_by(id: id)
|
|
22
|
+
next [Authorization.denied_response, nil, false] if record.nil?
|
|
23
|
+
unless Authorization.authorize_record?(config: config, actor: server_context[:actor], record: record, request: server_context[:request])
|
|
24
|
+
next [Authorization.denied_response, nil, false]
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
payload = {
|
|
28
|
+
resource: resource, id: id, fields: Serializer.show_payload(record, config),
|
|
29
|
+
associations: Serializer.associations_payload(record, config, max_rows: AdminSuite.config.mcp.max_page_size)
|
|
30
|
+
}
|
|
31
|
+
response = ::MCP::Tool::Response.new([{ type: "text", text: Serializer.dump(payload) }])
|
|
32
|
+
[response, 1, true]
|
|
33
|
+
rescue StandardError => e
|
|
34
|
+
Rails.logger&.warn("AdminSuite MCP get_record failed: #{e.class}: #{e.message}")
|
|
35
|
+
[Authorization.error_response("get_record failed"), nil, allowed]
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AdminSuite
|
|
4
|
+
module Mcp
|
|
5
|
+
module Tools
|
|
6
|
+
class ListRecords < ::MCP::Tool
|
|
7
|
+
tool_name "list_records"
|
|
8
|
+
description "List records using the filters, search, and sort keys the resource declares."
|
|
9
|
+
input_schema(
|
|
10
|
+
properties: {
|
|
11
|
+
resource: { type: "string" },
|
|
12
|
+
q: { type: "string" },
|
|
13
|
+
filters: { type: "object" },
|
|
14
|
+
sort: { type: "string" },
|
|
15
|
+
direction: { type: "string", enum: %w[asc desc] },
|
|
16
|
+
page: { type: "integer" },
|
|
17
|
+
per_page: { type: "integer" }
|
|
18
|
+
},
|
|
19
|
+
required: ["resource"]
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
def self.call(resource:, server_context:, q: nil, filters: {}, sort: nil, direction: nil, page: 1, per_page: nil)
|
|
23
|
+
AdminSuite::Mcp.instrument(tool: "list_records", resource: resource, actor: server_context[:actor], request: server_context[:request], filters: filters, q: q) do
|
|
24
|
+
allowed = false
|
|
25
|
+
config = Authorization.authorize_resource!(name: resource, actor: server_context[:actor], action: :read, request: server_context[:request])
|
|
26
|
+
next [Authorization.denied_response, nil, false] unless config
|
|
27
|
+
|
|
28
|
+
allowed = true
|
|
29
|
+
query = build_query(config, filters, q:, sort:, direction:, per_page:)
|
|
30
|
+
payload = response_payload(resource, config, query, page)
|
|
31
|
+
response = ::MCP::Tool::Response.new([{ type: "text", text: Serializer.dump(payload) }])
|
|
32
|
+
[response, payload[:rows].size, true]
|
|
33
|
+
rescue StandardError => e
|
|
34
|
+
Rails.logger&.warn("AdminSuite MCP list_records failed: #{e.class}: #{e.message}")
|
|
35
|
+
[Authorization.error_response("list_records failed"), nil, allowed]
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def self.build_query(config, filters, **params)
|
|
40
|
+
AdminSuite::Query.new(
|
|
41
|
+
resource_config: config,
|
|
42
|
+
params: (filters || {}).merge(params.except(:q), search: params[:q]).compact,
|
|
43
|
+
max_page_size: AdminSuite.config.mcp.max_page_size
|
|
44
|
+
)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def self.response_payload(resource, config, query, page)
|
|
48
|
+
applied_page = page.to_i.clamp(1..)
|
|
49
|
+
{
|
|
50
|
+
resource: resource,
|
|
51
|
+
page: applied_page,
|
|
52
|
+
applied_per_page: query.per_page,
|
|
53
|
+
rows: paginated(query, applied_page).map { |record| Serializer.index_row(record, config) }
|
|
54
|
+
}
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def self.paginated(query, page)
|
|
58
|
+
offset = (page - 1) * query.per_page
|
|
59
|
+
scope = query.scope
|
|
60
|
+
return scope.offset(offset).limit(query.per_page) if scope.respond_to?(:offset)
|
|
61
|
+
|
|
62
|
+
Array(scope).drop(offset).first(query.per_page)
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AdminSuite
|
|
4
|
+
module Mcp
|
|
5
|
+
TOOLS = [
|
|
6
|
+
AdminSuite::Mcp::Tools::DescribeResources,
|
|
7
|
+
AdminSuite::Mcp::Tools::ListRecords,
|
|
8
|
+
AdminSuite::Mcp::Tools::GetRecord,
|
|
9
|
+
AdminSuite::Mcp::Tools::Aggregate
|
|
10
|
+
].freeze
|
|
11
|
+
|
|
12
|
+
# A fresh server is built for each request, so its advertised tool list is
|
|
13
|
+
# derived from the current fail-closed authorization posture rather than
|
|
14
|
+
# cached across actors or requests.
|
|
15
|
+
def self.server_for(actor:, request: nil)
|
|
16
|
+
tools = AdminSuite.config.authorize.nil? || Auth.normalize_actor(actor).nil? ? [] : TOOLS
|
|
17
|
+
|
|
18
|
+
::MCP::Server.new(
|
|
19
|
+
name: "admin_suite",
|
|
20
|
+
version: AdminSuite::VERSION,
|
|
21
|
+
tools: tools,
|
|
22
|
+
server_context: { actor: actor, request: request }
|
|
23
|
+
)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def self.instrument(tool:, resource: nil, actor: nil, action: :read, request: nil, filters: nil, q: nil)
|
|
27
|
+
payload = {
|
|
28
|
+
tool: tool,
|
|
29
|
+
resource: resource,
|
|
30
|
+
actor_type: actor&.class&.name,
|
|
31
|
+
actor_id: actor.respond_to?(:id) ? actor.id.to_s : nil,
|
|
32
|
+
request_id: request&.request_id,
|
|
33
|
+
action: action,
|
|
34
|
+
filters: filters,
|
|
35
|
+
q: q,
|
|
36
|
+
allowed: false,
|
|
37
|
+
error: true,
|
|
38
|
+
result_count: nil
|
|
39
|
+
}
|
|
40
|
+
started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
41
|
+
|
|
42
|
+
ActiveSupport::Notifications.instrument("admin_suite.mcp.tool_call", payload) do
|
|
43
|
+
response, count, allowed = yield
|
|
44
|
+
payload[:allowed] = allowed
|
|
45
|
+
payload[:result_count] = count
|
|
46
|
+
payload[:error] = response.error?
|
|
47
|
+
response
|
|
48
|
+
ensure
|
|
49
|
+
payload[:duration_ms] = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000).round(2)
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AdminSuite
|
|
4
|
+
# The one place an index-style query is built.
|
|
5
|
+
#
|
|
6
|
+
# Both the web index action and the MCP `list_records` tool go through
|
|
7
|
+
# this object, so the two surfaces cannot disagree about what a filter,
|
|
8
|
+
# a sort, or a page size means.
|
|
9
|
+
class Query
|
|
10
|
+
MAX_PAGE_SIZE = 100
|
|
11
|
+
|
|
12
|
+
def initialize(resource_config:, params: {}, max_page_size: MAX_PAGE_SIZE)
|
|
13
|
+
@resource_config = resource_config
|
|
14
|
+
@params = params || {}
|
|
15
|
+
@max_page_size = max_page_size
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def scope
|
|
19
|
+
@scope ||= apply_includes(filtered)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def per_page
|
|
23
|
+
@per_page ||= clamp(@params[:per_page] || @params["per_page"])
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
def index_config = @resource_config&.index_config
|
|
29
|
+
def model_class = @resource_config.model_class
|
|
30
|
+
|
|
31
|
+
def filtered
|
|
32
|
+
return model_class.all unless index_config
|
|
33
|
+
|
|
34
|
+
Admin::Base::FilterBuilder.new(@resource_config, @params).apply(model_class.all)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Applies the index's `includes:` DSL option when the relation supports
|
|
38
|
+
# it. Invalid associations degrade to an unoptimized working query.
|
|
39
|
+
def apply_includes(relation)
|
|
40
|
+
list = index_config&.includes_list
|
|
41
|
+
return relation if list.blank?
|
|
42
|
+
return relation unless relation.respond_to?(:includes)
|
|
43
|
+
|
|
44
|
+
relation.includes(*list)
|
|
45
|
+
rescue StandardError => e
|
|
46
|
+
Rails.logger&.warn(
|
|
47
|
+
"AdminSuite: #{model_class}'s index `includes(#{list.inspect})` raised " \
|
|
48
|
+
"#{e.class}: #{e.message}; rendering the index without eager loading."
|
|
49
|
+
)
|
|
50
|
+
relation
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def clamp(requested)
|
|
54
|
+
cap = page_cap
|
|
55
|
+
fallback = (index_config&.per_page || 25).clamp(1..cap)
|
|
56
|
+
value = Integer(requested)
|
|
57
|
+
return fallback if value <= 0
|
|
58
|
+
|
|
59
|
+
value.clamp(..cap)
|
|
60
|
+
rescue ArgumentError, TypeError
|
|
61
|
+
fallback
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def page_cap
|
|
65
|
+
cap = Integer(@max_page_size)
|
|
66
|
+
cap.positive? ? cap : MAX_PAGE_SIZE
|
|
67
|
+
rescue ArgumentError, TypeError
|
|
68
|
+
MAX_PAGE_SIZE
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
@@ -53,9 +53,9 @@ module AdminSuite
|
|
|
53
53
|
end
|
|
54
54
|
end
|
|
55
55
|
|
|
56
|
-
if defined?(ActiveStorage::Attached::One) && value.is_a?(ActiveStorage::Attached::One)
|
|
56
|
+
if defined?(::ActiveStorage::Attached::One) && value.is_a?(::ActiveStorage::Attached::One)
|
|
57
57
|
return render_attachment_preview(value)
|
|
58
|
-
elsif defined?(ActiveStorage::Attached::Many) && value.is_a?(ActiveStorage::Attached::Many)
|
|
58
|
+
elsif defined?(::ActiveStorage::Attached::Many) && value.is_a?(::ActiveStorage::Attached::Many)
|
|
59
59
|
return render_attachments_preview(value)
|
|
60
60
|
end
|
|
61
61
|
|