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.
Files changed (56) hide show
  1. checksums.yaml +4 -4
  2. data/.gitignore +7 -0
  3. data/CHANGELOG.md +69 -11
  4. data/CONTRIBUTING.md +9 -4
  5. data/README.md +21 -5
  6. data/app/controllers/admin_suite/application_controller.rb +12 -11
  7. data/app/controllers/admin_suite/mcp_controller.rb +36 -0
  8. data/app/controllers/admin_suite/resources_controller.rb +7 -78
  9. data/app/views/admin_suite/resources/index.html.erb +1 -0
  10. data/config/routes.rb +5 -0
  11. data/lib/admin/base/resource.rb +7 -29
  12. data/lib/admin_suite/auth/host_user.rb +42 -0
  13. data/lib/admin_suite/auth/strategy.rb +1 -1
  14. data/lib/admin_suite/auth.rb +15 -0
  15. data/lib/admin_suite/authorization_context.rb +28 -0
  16. data/lib/admin_suite/configuration.rb +56 -2
  17. data/lib/admin_suite/mcp/authorization.rb +68 -0
  18. data/lib/admin_suite/mcp/serializer.rb +138 -0
  19. data/lib/admin_suite/mcp/tools/aggregate.rb +55 -0
  20. data/lib/admin_suite/mcp/tools/describe_resources.rb +63 -0
  21. data/lib/admin_suite/mcp/tools/get_record.rb +41 -0
  22. data/lib/admin_suite/mcp/tools/list_records.rb +67 -0
  23. data/lib/admin_suite/mcp.rb +53 -0
  24. data/lib/admin_suite/query.rb +71 -0
  25. data/lib/admin_suite/ui/show_value_formatter.rb +2 -2
  26. data/lib/admin_suite/version.rb +1 -1
  27. data/lib/admin_suite.rb +14 -9
  28. data/lib/generators/admin_suite/install/templates/admin_suite.rb +5 -2
  29. data/test/controllers/resources_controller_test.rb +19 -13
  30. data/test/integration/authentication_test.rb +10 -0
  31. data/test/integration/authorization_test.rb +20 -3
  32. data/test/integration/index_query_characterization_test.rb +229 -0
  33. data/test/integration/mcp_aggregate_test.rb +44 -0
  34. data/test/integration/mcp_authorization_test.rb +102 -0
  35. data/test/integration/mcp_endpoint_test.rb +89 -0
  36. data/test/integration/mcp_get_record_test.rb +62 -0
  37. data/test/integration/mcp_instrumentation_test.rb +107 -0
  38. data/test/integration/mcp_list_records_test.rb +75 -0
  39. data/test/integration/mcp_parity_test.rb +155 -0
  40. data/test/integration/mcp_release_test.rb +131 -0
  41. data/test/integration/read_only_resource_test.rb +128 -2
  42. data/test/integration/searchable_select_search_test.rb +5 -4
  43. data/test/lib/auth_host_user_test.rb +52 -0
  44. data/test/lib/auth_http_basic_test.rb +10 -0
  45. data/test/lib/auth_test.rb +20 -3
  46. data/test/lib/authorization_context_test.rb +127 -0
  47. data/test/lib/engine_defaults_test.rb +1 -2
  48. data/test/lib/mcp_serializer_test.rb +107 -0
  49. data/test/lib/query_test.rb +91 -0
  50. data/test/lib/removed_deprecations_test.rb +16 -0
  51. data/test/publish_workflow_test.rb +63 -0
  52. data/test/test_helper.rb +26 -0
  53. metadata +42 -5
  54. data/lib/admin_suite/renderers/legacy_gleania.rb +0 -232
  55. data/test/lib/legacy_renderer_deprecation_test.rb +0 -42
  56. 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
- attr_reader :portals
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
 
@@ -2,7 +2,7 @@
2
2
 
3
3
  module AdminSuite
4
4
  module Version
5
- VERSION = "0.5.0"
5
+ VERSION = "0.6.1"
6
6
  end
7
7
 
8
8
  # Backward-compatible constant.