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
data/lib/admin_suite.rb CHANGED
@@ -8,6 +8,11 @@ end
8
8
 
9
9
  require "pagy"
10
10
 
11
+ # Hard dependency, declared in the gemspec but not a direct Gemfile entry in
12
+ # any host -- Bundler only auto-requires direct Gemfile dependencies, never a
13
+ # gem's transitive runtime dependencies.
14
+ require "mcp"
15
+
11
16
  # Hard dependency, declared in the gemspec but not a direct Gemfile entry in
12
17
  # any host -- Bundler's `Bundler.require` only auto-requires gems declared
13
18
  # directly in the Gemfile (or via `gemspec`'s own path entry), not a gem's
@@ -20,6 +25,15 @@ require "turbo-rails"
20
25
  require "admin_suite/version"
21
26
  require "admin_suite/deprecation"
22
27
  require "admin_suite/configuration"
28
+ require "admin_suite/authorization_context"
29
+ require "admin_suite/query"
30
+ require "admin_suite/mcp/authorization"
31
+ require "admin_suite/mcp/serializer"
32
+ require "admin_suite/mcp/tools/describe_resources"
33
+ require "admin_suite/mcp/tools/list_records"
34
+ require "admin_suite/mcp/tools/get_record"
35
+ require "admin_suite/mcp/tools/aggregate"
36
+ require "admin_suite/mcp"
23
37
  require "admin_suite/markdown_renderer"
24
38
  require "admin_suite/theme_palette"
25
39
  require "admin_suite/portal_registry"
@@ -34,7 +48,6 @@ require "admin_suite/renderers/json_renderer"
34
48
  require "admin_suite/renderers/key_value_renderer"
35
49
  require "admin_suite/renderers/table_from_renderer"
36
50
  require "admin_suite/renderers/code_renderer"
37
- require "admin_suite/renderers/legacy_gleania"
38
51
  require "admin_suite/legacy_custom_renderer_procs"
39
52
  require "admin_suite/definition_loader"
40
53
  require "admin_suite/host_autoload_policy"
@@ -58,14 +71,6 @@ AdminSuite::RendererRegistry.register_default(:code, AdminSuite::Renderers::Code
58
71
  AdminSuite::RendererRegistry.register_default(:json_preview, AdminSuite::Renderers::JsonRenderer)
59
72
  AdminSuite::RendererRegistry.register_default(:code_preview, AdminSuite::Renderers::CodeRenderer)
60
73
 
61
- # Deprecated (removal targeted at 0.6.0, moved from the originally-planned
62
- # 0.5.0 -- both host migrations, gleania and trust_growth, are in flight):
63
- # the four Gleania-specific LLM chat-transcript renderers. See
64
- # `AdminSuite::Renderers::LegacyGleania` for details.
65
- AdminSuite::RendererRegistry.register_default(:prompt_template_preview, AdminSuite::Renderers::LegacyGleania::PromptTemplateRenderer)
66
- AdminSuite::RendererRegistry.register_default(:messages_preview, AdminSuite::Renderers::LegacyGleania::MessagesPreviewRenderer)
67
- AdminSuite::RendererRegistry.register_default(:tool_args_preview, AdminSuite::Renderers::LegacyGleania::ToolArgsRenderer)
68
- AdminSuite::RendererRegistry.register_default(:turn_messages_preview, AdminSuite::Renderers::LegacyGleania::TurnMessagesRenderer)
69
74
 
70
75
  module AdminSuite
71
76
  class << self
@@ -26,9 +26,12 @@ AdminSuite.configure do |config|
26
26
  # provide one (legacy fallback):
27
27
  config.current_actor = ->(controller) { controller.respond_to?(:current_user) ? controller.current_user : nil }
28
28
 
29
- # Authorization hook — called for every resource request:
29
+ # Authorization hook — called for every resource request.
30
+ # Signature: ->(actor:, action:, resource:, record:, context:) { true }
30
31
  # action is one of :read, :create, :update, :destroy, :execute.
31
- # config.authorize = ->(actor:, action:, resource:, record:, controller:) { true }
32
+ # nil hook: authenticated web requests are allowed; MCP serves no tools/data.
33
+ # Hook return of false or nil denies the request (no disclose/mutate).
34
+ # config.authorize = ->(actor:, action:, resource:, record:, context:) { actor&.admin? }
32
35
  config.authorize = nil
33
36
 
34
37
  # Optional sign-out action in the topbar.
@@ -6,12 +6,7 @@ module AdminSuite
6
6
  class ResourcesControllerTest < ActiveSupport::TestCase
7
7
  class TestController < ResourcesController
8
8
  attr_writer :test_resource_config, :test_params
9
- attr_reader :filter_calls, :paginated_scope
10
-
11
- def initialize
12
- super
13
- @filter_calls = 0
14
- end
9
+ attr_reader :paginated_scope, :pagy_limit
15
10
 
16
11
  def params
17
12
  @test_params ||= {}
@@ -23,18 +18,27 @@ module AdminSuite
23
18
  @test_resource_config
24
19
  end
25
20
 
26
- def filtered_collection
27
- @filter_calls += 1
28
- { total: 37 }
29
- end
30
-
31
- def paginate_collection(scope)
21
+ def pagy(scope, limit:)
32
22
  @paginated_scope = scope
23
+ @pagy_limit = limit
33
24
  [ Object.new, :paginated ]
34
25
  end
35
26
  end
36
27
 
28
+ class StatsModel
29
+ class << self
30
+ attr_accessor :all_calls
31
+
32
+ def all
33
+ self.all_calls = all_calls.to_i + 1
34
+ { total: 37 }
35
+ end
36
+ end
37
+ end
38
+
37
39
  class StatsResource < Admin::Base::Resource
40
+ model StatsModel
41
+
38
42
  index do
39
43
  stats do
40
44
  stat :legacy, -> { 11 }
@@ -72,11 +76,13 @@ module AdminSuite
72
76
  test "index reuses one filtered unpaginated scope for stats and pagination" do
73
77
  controller = TestController.new
74
78
  controller.test_resource_config = StatsResource
79
+ StatsModel.all_calls = 0
75
80
 
76
81
  controller.index
77
82
 
78
- assert_equal 1, controller.filter_calls
83
+ assert_equal 1, StatsModel.all_calls
79
84
  assert_equal({ total: 37 }, controller.paginated_scope)
85
+ assert_equal 25, controller.pagy_limit
80
86
  assert_equal 37, controller.instance_variable_get(:@stats).second[:value]
81
87
  assert_equal :paginated, controller.instance_variable_get(:@collection)
82
88
  end
@@ -43,6 +43,16 @@ module AdminSuite
43
43
  end
44
44
  end
45
45
 
46
+ test "unknown auth strategy fails closed and lists registered strategies" do
47
+ with_config(allow_unauthenticated: false, auth_strategy: :typo) do
48
+ get ROOT
49
+
50
+ assert_response :forbidden
51
+ assert_includes response.body, "Unknown AdminSuite auth strategy :typo"
52
+ assert_includes response.body, "http_basic"
53
+ end
54
+ end
55
+
46
56
  test "legacy authenticate lambda still works" do
47
57
  denials = ->(controller) { controller.head :forbidden }
48
58
  with_config(allow_unauthenticated: false, auth_strategy: nil, authenticate: denials) do
@@ -66,13 +66,29 @@ module AdminSuite
66
66
  with_authorize(->(**) { false }) do
67
67
  get BASE
68
68
  assert_response :forbidden
69
+ refute_includes response.body, "Gadget one"
69
70
  end
70
71
  end
71
72
 
72
- test "authorize receives actor, mapped action, resource, record, controller" do
73
+ test "nil authorize result denies with 403 and does not disclose the record" do
74
+ with_authorize(->(**) { nil }) do
75
+ get "#{BASE}/1"
76
+ assert_response :forbidden
77
+ refute_includes response.body, "Gadget one"
78
+ end
79
+ end
80
+
81
+ test "nil authorize result cannot mutate" do
82
+ with_authorize(->(**) { nil }) do
83
+ delete "#{BASE}/1"
84
+ assert_response :forbidden
85
+ end
86
+ end
87
+
88
+ test "authorize receives actor, mapped action, resource, record, context" do
73
89
  captured = nil
74
- hook = lambda do |actor:, action:, resource:, record:, controller:|
75
- captured = { action: action, resource: resource, record: record }
90
+ hook = lambda do |actor:, action:, resource:, record:, context:|
91
+ captured = { action: action, resource: resource, record: record, context: context }
76
92
  true
77
93
  end
78
94
 
@@ -83,6 +99,7 @@ module AdminSuite
83
99
  assert_equal :read, captured[:action]
84
100
  assert_equal Admin::Resources::AuthzGadgetResource, captured[:resource]
85
101
  assert_instance_of AuthzFixtures::Gadget, captured[:record]
102
+ assert_equal :web, captured[:context].surface
86
103
  end
87
104
 
88
105
  test "destroy maps to :destroy" do
@@ -0,0 +1,229 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "test_helper"
4
+
5
+ # Pins the index query path as it behaves at v0.5.0, before
6
+ # AdminSuite::Query is extracted. These assertions intentionally exercise
7
+ # routed requests and rendered rows rather than controller internals.
8
+ module IndexQueryCharacterizationFixtures
9
+ class Relation
10
+ include Enumerable
11
+
12
+ attr_reader :includes_calls
13
+
14
+ def initialize(records, offset: 0, includes_calls: [])
15
+ @records = records
16
+ @offset = offset
17
+ @includes_calls = includes_calls
18
+ end
19
+
20
+ def each(&block) = @records.each(&block)
21
+ def count(*) = @records.length
22
+ def offset(value) = self.class.new(@records, offset: value, includes_calls: includes_calls)
23
+ def limit(value) = @records[@offset, value] || []
24
+
25
+ def where(condition = nil, *values, **bindings)
26
+ condition = bindings if condition.nil?
27
+ selected =
28
+ if condition.is_a?(Hash)
29
+ @records.select { |record| condition.all? { |field, value| record.public_send(field).to_s == value.to_s } }
30
+ else
31
+ term = (bindings[:search] || values.first).to_s.delete("%").downcase
32
+ fields = condition.scan(/(\w+) ILIKE/).flatten
33
+ @records.select { |record| fields.any? { |field| record.public_send(field).to_s.downcase.include?(term) } }
34
+ end
35
+ self.class.new(selected, includes_calls: includes_calls)
36
+ end
37
+
38
+ def order(ordering)
39
+ field, direction = ordering.first
40
+ sorted = @records.sort_by { |record| record.public_send(field).to_s }
41
+ sorted.reverse! if direction.to_sym == :desc
42
+ self.class.new(sorted, includes_calls: includes_calls)
43
+ end
44
+
45
+ def includes(*associations)
46
+ includes_calls << associations
47
+ self
48
+ end
49
+ end
50
+
51
+ class BadIncludesRelation < Relation
52
+ def includes(*) = raise(ArgumentError, "unknown association")
53
+ end
54
+
55
+ class Widget
56
+ extend ActiveModel::Naming
57
+
58
+ attr_reader :id, :name, :secret, :status
59
+
60
+ def initialize(id:, name:, secret:, status:)
61
+ @id = id
62
+ @name = name
63
+ @secret = secret
64
+ @status = status
65
+ end
66
+
67
+ ROWS = [
68
+ new(id: 1, name: "Zulu", secret: "hidden alpha", status: "open"),
69
+ new(id: 2, name: "Alpha", secret: "hidden zulu", status: "closed"),
70
+ new(id: 3, name: "Bravo", secret: "private", status: "open")
71
+ ].freeze
72
+ INCLUDES_CALLS = []
73
+
74
+ def self.all = Relation.new(ROWS, includes_calls: INCLUDES_CALLS)
75
+ def self.column_names = %w[id name secret status]
76
+ def self.primary_key = "id"
77
+ def self.columns_hash = { "id" => Struct.new(:type).new(:integer) }
78
+ def self.find(id) = ROWS.find { |row| row.to_param == id.to_s }
79
+ def to_param = id.to_s
80
+ def attributes = { "id" => id, "name" => name, "secret" => secret, "status" => status }
81
+ end
82
+
83
+ class BadIncludesWidget < Widget
84
+ def self.all = BadIncludesRelation.new(ROWS)
85
+ end
86
+
87
+ class PagedWidget
88
+ extend ActiveModel::Naming
89
+
90
+ attr_reader :id
91
+
92
+ def initialize(id:) = @id = id
93
+
94
+ ROWS = (1..150).map { |id| new(id: id) }.freeze
95
+
96
+ def self.all = Relation.new(ROWS)
97
+ def self.column_names = %w[id]
98
+ def self.primary_key = "id"
99
+ def self.columns_hash = { "id" => Struct.new(:type).new(:integer) }
100
+ def self.find(id) = ROWS.find { |row| row.to_param == id.to_s }
101
+ def to_param = id.to_s
102
+ def attributes = { "id" => id }
103
+ end
104
+ end
105
+
106
+ module Admin
107
+ module Resources
108
+ class IndexQueryCharacterizationWidgetResource < Admin::Base::Resource
109
+ model IndexQueryCharacterizationFixtures::Widget
110
+ portal :ops
111
+ section :observability
112
+
113
+ index do
114
+ searchable :name
115
+ sortable :name, default: :name, direction: :desc
116
+ filters { filter :status, type: :select, options: %w[open closed] }
117
+ columns do
118
+ column :name, sortable: true
119
+ column :status
120
+ end
121
+ includes :owner
122
+ end
123
+ end
124
+
125
+ class IndexQueryCharacterizationBadIncludesWidgetResource < Admin::Base::Resource
126
+ model IndexQueryCharacterizationFixtures::BadIncludesWidget
127
+ portal :ops
128
+ section :observability
129
+ index do
130
+ columns { column :name }
131
+ includes :missing
132
+ end
133
+ end
134
+
135
+ class IndexQueryCharacterizationPagedWidgetResource < Admin::Base::Resource
136
+ model IndexQueryCharacterizationFixtures::PagedWidget
137
+ portal :ops
138
+ section :observability
139
+ index do
140
+ columns { column :id }
141
+ paginate 30
142
+ end
143
+ end
144
+ end
145
+ end
146
+
147
+ class IndexQueryCharacterizationTest < ActionDispatch::IntegrationTest
148
+ WIDGETS_PATH = "/internal/admin_suite/ops/index_query_characterization_widgets"
149
+ PAGED_PATH = "/internal/admin_suite/ops/index_query_characterization_paged_widgets"
150
+
151
+ def rendered_names
152
+ css_select("tbody tr td:first-child").map { |cell| cell.text.strip }
153
+ end
154
+
155
+ def rendered_row_count = css_select("tbody tr").size
156
+
157
+ test "default sort applies when no sort param is given" do
158
+ get WIDGETS_PATH
159
+ assert_equal %w[Zulu Bravo Alpha], rendered_names
160
+ end
161
+
162
+ test "explicit sort and direction override the default" do
163
+ get WIDGETS_PATH, params: { sort: "name", direction: "asc" }
164
+ assert_equal %w[Alpha Bravo Zulu], rendered_names
165
+ end
166
+
167
+ test "a sort param naming an undeclared field is ignored" do
168
+ get WIDGETS_PATH, params: { sort: "secret", direction: "asc" }
169
+ assert_equal %w[Alpha Bravo Zulu], rendered_names
170
+ end
171
+
172
+ test "search examines only declared searchable fields" do
173
+ get WIDGETS_PATH, params: { search: "alpha" }
174
+ assert_equal [ "Alpha" ], rendered_names
175
+ end
176
+
177
+ test "a blank search returns the unfiltered scope" do
178
+ get WIDGETS_PATH, params: { search: " " }
179
+ assert_equal %w[Zulu Bravo Alpha], rendered_names
180
+ end
181
+
182
+ test "a declared filter narrows while an undeclared filter is ignored" do
183
+ get WIDGETS_PATH, params: { status: "open", secret: "hidden zulu" }
184
+ assert_equal %w[Zulu Bravo], rendered_names
185
+ end
186
+
187
+ test "declared includes are applied" do
188
+ IndexQueryCharacterizationFixtures::Widget::INCLUDES_CALLS.clear
189
+ get WIDGETS_PATH
190
+ assert_equal [ [ :owner ] ], IndexQueryCharacterizationFixtures::Widget::INCLUDES_CALLS
191
+ end
192
+
193
+ test "a bad includes association logs and still renders" do
194
+ logged = []
195
+ Rails.logger.stub(:warn, ->(message) { logged << message }) do
196
+ get "/internal/admin_suite/ops/index_query_characterization_bad_includes_widgets"
197
+ end
198
+ assert_response :success
199
+ assert_includes response.body, "Zulu"
200
+ assert_equal 1, logged.size
201
+ end
202
+
203
+ test "paginate 30 yields 30 rows per page" do
204
+ get PAGED_PATH
205
+ assert_equal 30, rendered_row_count
206
+ end
207
+
208
+ test "per_page 50 overrides the DSL value" do
209
+ get PAGED_PATH, params: { per_page: "50" }
210
+ assert_equal 50, rendered_row_count
211
+ end
212
+
213
+ test "an excessive per_page clamps to 100" do
214
+ get PAGED_PATH, params: { per_page: "999999" }
215
+ assert_equal 100, rendered_row_count
216
+ end
217
+
218
+ test "invalid and zero per_page values fall back to the DSL value" do
219
+ [ "abc", "0" ].each do |value|
220
+ get PAGED_PATH, params: { per_page: value }
221
+ assert_equal 30, rendered_row_count
222
+ end
223
+ end
224
+
225
+ test "a leading-zero per_page parses with Integer octal semantics" do
226
+ get PAGED_PATH, params: { per_page: "010" }
227
+ assert_equal 8, rendered_row_count
228
+ end
229
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "test_helper"
4
+
5
+ module McpAggregateFixtures
6
+ class Widget
7
+ extend ActiveModel::Naming
8
+
9
+ def self.all = ReadOnlyResourceFixtures::Relation.new([new, new])
10
+ end
11
+ end
12
+
13
+ module Admin
14
+ module Resources
15
+ class McpAggregateWidgetResource < Admin::Base::Resource
16
+ model McpAggregateFixtures::Widget
17
+ portal :ops
18
+ section :observability
19
+ index do
20
+ stats { stat :total, ->(scope) { scope.count } }
21
+ end
22
+ end
23
+ end
24
+ end
25
+
26
+ class McpAggregateTest < McpIntegrationTest
27
+ def call_tool(arguments)
28
+ post "/internal/admin_suite/mcp",
29
+ params: { jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "aggregate", arguments: arguments } }.to_json,
30
+ headers: { "CONTENT_TYPE" => "application/json", "HTTP_ACCEPT" => "application/json, text/event-stream" }
31
+ JSON.parse(response.body)
32
+ end
33
+
34
+ test "returns a count and declared stats without records" do
35
+ result = with_authorize(->(**) { true }) do
36
+ call_tool(resource: "mcp_aggregate_widget")
37
+ end
38
+
39
+ payload = JSON.parse(result.dig("result", "content", 0, "text"))
40
+ assert_equal 2, payload.fetch("count")
41
+ assert_equal 2, payload.dig("stats", "total")
42
+ refute payload.key?("rows"), "aggregate must never return records"
43
+ end
44
+ end
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "test_helper"
4
+
5
+ module McpAuthorizationFixtures
6
+ class Widget
7
+ extend ActiveModel::Naming
8
+
9
+ def self.all = ReadOnlyResourceFixtures::Relation.new([])
10
+ def self.find_by(id:) = new
11
+ end
12
+ end
13
+
14
+ module Admin
15
+ module Resources
16
+ class McpAuthorizationWidgetResource < Admin::Base::Resource
17
+ model McpAuthorizationFixtures::Widget
18
+ portal :ops
19
+ section :observability
20
+ index { columns { column :name } }
21
+ end
22
+
23
+ class McpAuthorizationDisabledResource < Admin::Base::Resource
24
+ model McpAuthorizationFixtures::Widget
25
+ portal :ops
26
+ section :observability
27
+ mcp false
28
+ end
29
+ end
30
+ end
31
+
32
+ class McpAuthorizationTest < McpIntegrationTest
33
+ def rpc(method, params = {})
34
+ post "/internal/admin_suite/mcp",
35
+ params: { jsonrpc: "2.0", id: 1, method: method, params: params }.to_json,
36
+ headers: { "CONTENT_TYPE" => "application/json", "HTTP_ACCEPT" => "application/json, text/event-stream" }
37
+ JSON.parse(response.body)
38
+ end
39
+
40
+ def call_tool(name, arguments = {})
41
+ rpc("tools/call", { name: name, arguments: arguments })
42
+ end
43
+
44
+ test "a nil authorize hook advertises no tools" do
45
+ with_authorize(nil) do
46
+ assert_empty rpc("tools/list").dig("result", "tools")
47
+ end
48
+ end
49
+
50
+ test "a nil authorize hook denies a direct call to every tool" do
51
+ ctx = { actor: "x" }
52
+
53
+ with_authorize(nil) do
54
+ [
55
+ AdminSuite::Mcp::Tools::DescribeResources.call(server_context: ctx),
56
+ AdminSuite::Mcp::Tools::ListRecords.call(resource: "mcp_authorization_widget", server_context: ctx),
57
+ AdminSuite::Mcp::Tools::GetRecord.call(resource: "mcp_authorization_widget", id: "1", server_context: ctx),
58
+ AdminSuite::Mcp::Tools::Aggregate.call(resource: "mcp_authorization_widget", server_context: ctx)
59
+ ].each do |response|
60
+ assert response.error?, response.inspect
61
+ assert_equal AdminSuite::Mcp::Authorization::DENIED_MESSAGE, response.content.first[:text]
62
+ end
63
+ end
64
+ end
65
+
66
+ test "the authorize hook receives an mcp surface and a read action" do
67
+ seen = []
68
+ hook = lambda do |action:, record:, context:, **|
69
+ seen << [action, context.surface, record]
70
+ true
71
+ end
72
+
73
+ with_authorize(hook) do
74
+ call_tool("list_records", { resource: "mcp_authorization_widget" })
75
+ end
76
+
77
+ assert_includes seen, [:read, :mcp, nil]
78
+ end
79
+
80
+ test "describe_resources omits resources the hook denies" do
81
+ hook = ->(resource:, **) { resource.resource_name != "mcp_authorization_widget" }
82
+
83
+ with_authorize(hook) do
84
+ refute_match(/mcp_authorization_widget/, call_tool("describe_resources").to_s)
85
+ end
86
+ end
87
+
88
+ test "an mcp-disabled resource is invisible and unreachable" do
89
+ with_authorize(->(**) { true }) do
90
+ refute_match(/mcp_authorization_disabled/, call_tool("describe_resources").to_s)
91
+ assert call_tool("list_records", { resource: "mcp_authorization_disabled" }).dig("result", "isError")
92
+ end
93
+ end
94
+
95
+ test "a raising authorize hook denies instead of returning a server error" do
96
+ with_authorize(->(**) { raise "boom" }) do
97
+ result = call_tool("list_records", { resource: "mcp_authorization_widget" })
98
+
99
+ assert result.dig("result", "isError"), result.inspect
100
+ end
101
+ end
102
+ end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "test_helper"
4
+
5
+ module McpEndpointFixtures
6
+ class Widget
7
+ extend ActiveModel::Naming
8
+ end
9
+ end
10
+
11
+ module Admin
12
+ module Resources
13
+ class McpEndpointWidgetResource < Admin::Base::Resource
14
+ model McpEndpointFixtures::Widget
15
+ portal :ops
16
+ section :observability
17
+ index do
18
+ columns { column :name }
19
+ end
20
+ actions do
21
+ action :approve
22
+ bulk_action :archive
23
+ end
24
+ end
25
+ end
26
+ end
27
+
28
+ class McpEndpointTest < McpIntegrationTest
29
+ def rpc(method, params = {}, id: 1)
30
+ post "/internal/admin_suite/mcp",
31
+ params: { jsonrpc: "2.0", id: id, method: method, params: params }.to_json,
32
+ headers: { "CONTENT_TYPE" => "application/json", "HTTP_ACCEPT" => "application/json, text/event-stream" }
33
+ JSON.parse(response.body)
34
+ end
35
+
36
+ test "tools/list advertises the read tools when an authorize hook permits" do
37
+ with_authorize(->(**) { true }) do
38
+ names = rpc("tools/list").dig("result", "tools").map { |tool| tool["name"] }
39
+
40
+ assert_includes names, "describe_resources"
41
+ end
42
+ end
43
+
44
+ test "describe_resources returns declared resources and their fields" do
45
+ with_authorize(->(**) { true }) do
46
+ result = rpc("tools/call", { name: "describe_resources", arguments: {} })
47
+
48
+ refute result.dig("result", "isError"), result.inspect
49
+ assert_match(/mcp_endpoint_widget/, result.to_s)
50
+ assert_match(/name/, result.to_s)
51
+ end
52
+ end
53
+
54
+ test "describe_resources includes portal, section, and declared actions" do
55
+ with_authorize(->(**) { true }) do
56
+ result = rpc("tools/call", { name: "describe_resources", arguments: {} })
57
+ payload = JSON.parse(result.dig("result", "content", 0, "text"))
58
+ widget = payload.find { |resource| resource["name"] == "mcp_endpoint_widget" }
59
+
60
+ assert_equal "ops", widget.fetch("portal")
61
+ assert_equal "observability", widget.fetch("section")
62
+ assert_includes widget.fetch("actions"), { "name" => "approve", "kind" => "member" }
63
+ assert_includes widget.fetch("actions"), { "name" => "archive", "kind" => "bulk" }
64
+ end
65
+ end
66
+
67
+ test "describe_resources returns a generic error without the raised message" do
68
+ with_authorize(->(**) { true }) do
69
+ AdminSuite::Mcp::Tools::DescribeResources.stub(:describe, proc { raise "SECRET-/db/password-detail" }) do
70
+ result = rpc("tools/call", { name: "describe_resources", arguments: {} })
71
+
72
+ refute result["error"], result.inspect
73
+ assert result.dig("result", "isError")
74
+ body = result.to_s
75
+ assert_match(/describe_resources failed/, body)
76
+ refute_match(/SECRET-\/db\/password-detail/, body)
77
+ end
78
+ end
79
+ end
80
+
81
+ test "a nil authorize hook advertises no tools and denies direct calls" do
82
+ with_authorize(nil) do
83
+ assert_empty rpc("tools/list").dig("result", "tools")
84
+
85
+ result = rpc("tools/call", { name: "describe_resources", arguments: {} })
86
+ assert result["error"], result.inspect
87
+ end
88
+ end
89
+ end