admin_suite 0.5.0 → 0.6.2

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 (61) hide show
  1. checksums.yaml +4 -4
  2. data/.gitignore +7 -0
  3. data/CHANGELOG.md +78 -11
  4. data/CONTRIBUTING.md +9 -4
  5. data/Gemfile +1 -0
  6. data/README.md +25 -5
  7. data/app/controllers/admin_suite/application_controller.rb +12 -11
  8. data/app/controllers/admin_suite/mcp_controller.rb +36 -0
  9. data/app/controllers/admin_suite/resources_controller.rb +7 -78
  10. data/app/views/admin_suite/resources/index.html.erb +1 -0
  11. data/config/routes.rb +5 -0
  12. data/lib/admin/base/resource.rb +7 -29
  13. data/lib/admin_suite/auth/host_user.rb +42 -0
  14. data/lib/admin_suite/auth/strategy.rb +1 -1
  15. data/lib/admin_suite/auth.rb +15 -0
  16. data/lib/admin_suite/authorization_context.rb +28 -0
  17. data/lib/admin_suite/configuration.rb +56 -2
  18. data/lib/admin_suite/mcp/authorization.rb +68 -0
  19. data/lib/admin_suite/mcp/serializer.rb +138 -0
  20. data/lib/admin_suite/mcp/tools/aggregate.rb +55 -0
  21. data/lib/admin_suite/mcp/tools/describe_resources.rb +63 -0
  22. data/lib/admin_suite/mcp/tools/get_record.rb +41 -0
  23. data/lib/admin_suite/mcp/tools/list_records.rb +67 -0
  24. data/lib/admin_suite/mcp.rb +53 -0
  25. data/lib/admin_suite/query.rb +71 -0
  26. data/lib/admin_suite/ui/show_value_formatter.rb +2 -2
  27. data/lib/admin_suite/version.rb +1 -1
  28. data/lib/admin_suite.rb +14 -9
  29. data/lib/generators/admin_suite/install/templates/admin_suite.rb +5 -2
  30. data/test/archspec/enforcement_test.rb +115 -0
  31. data/test/controllers/resources_controller_test.rb +19 -13
  32. data/test/dummy/config/environments/development.rb +9 -6
  33. data/test/dummy_boot_test.rb +21 -0
  34. data/test/integration/authentication_test.rb +10 -0
  35. data/test/integration/authorization_test.rb +20 -3
  36. data/test/integration/index_query_characterization_test.rb +229 -0
  37. data/test/integration/mcp_aggregate_test.rb +44 -0
  38. data/test/integration/mcp_authorization_test.rb +102 -0
  39. data/test/integration/mcp_endpoint_test.rb +89 -0
  40. data/test/integration/mcp_get_record_test.rb +62 -0
  41. data/test/integration/mcp_instrumentation_test.rb +107 -0
  42. data/test/integration/mcp_list_records_test.rb +75 -0
  43. data/test/integration/mcp_parity_test.rb +155 -0
  44. data/test/integration/mcp_release_test.rb +131 -0
  45. data/test/integration/read_only_resource_test.rb +128 -2
  46. data/test/integration/searchable_select_search_test.rb +5 -4
  47. data/test/lib/action_executor_test.rb +17 -9
  48. data/test/lib/auth_host_user_test.rb +52 -0
  49. data/test/lib/auth_http_basic_test.rb +10 -0
  50. data/test/lib/auth_test.rb +20 -3
  51. data/test/lib/authorization_context_test.rb +127 -0
  52. data/test/lib/engine_defaults_test.rb +1 -2
  53. data/test/lib/mcp_serializer_test.rb +107 -0
  54. data/test/lib/query_test.rb +91 -0
  55. data/test/lib/removed_deprecations_test.rb +16 -0
  56. data/test/publish_workflow_test.rb +63 -0
  57. data/test/test_helper.rb +26 -0
  58. metadata +44 -5
  59. data/lib/admin_suite/renderers/legacy_gleania.rb +0 -232
  60. data/test/lib/legacy_renderer_deprecation_test.rb +0 -42
  61. data/test/lib/resource_exportable_deprecation_test.rb +0 -47
@@ -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
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "test_helper"
4
+
5
+ module McpGetRecordFixtures
6
+ class Widget
7
+ extend ActiveModel::Naming
8
+
9
+ attr_reader :id, :name, :secret_token
10
+
11
+ def initialize(id: 1, name: "Widget", secret_token: "sh-hh")
12
+ @id = id
13
+ @name = name
14
+ @secret_token = secret_token
15
+ end
16
+
17
+ def self.find_by(id:)
18
+ new if id.to_s == "1"
19
+ end
20
+ end
21
+ end
22
+
23
+ module Admin
24
+ module Resources
25
+ class McpGetRecordWidgetResource < Admin::Base::Resource
26
+ model McpGetRecordFixtures::Widget
27
+ portal :ops
28
+ section :observability
29
+ show { panel :identity, fields: %i[id name] }
30
+ end
31
+ end
32
+ end
33
+
34
+ class McpGetRecordTest < McpIntegrationTest
35
+ def call_tool(arguments)
36
+ post "/internal/admin_suite/mcp",
37
+ params: { jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "get_record", arguments: arguments } }.to_json,
38
+ headers: { "CONTENT_TYPE" => "application/json", "HTTP_ACCEPT" => "application/json, text/event-stream" }
39
+ JSON.parse(response.body)
40
+ end
41
+
42
+ test "returns one record using only the show field set" do
43
+ result = with_authorize(->(**) { true }) do
44
+ call_tool(resource: "mcp_get_record_widget", id: "1")
45
+ end
46
+
47
+ refute result.dig("result", "isError"), result.inspect
48
+ assert_match(/Widget/, result.to_s)
49
+ refute_match(/sh-hh/, result.to_s)
50
+ end
51
+
52
+ test "a missing id is indistinguishable from a denied read" do
53
+ missing = with_authorize(->(**) { true }) do
54
+ call_tool(resource: "mcp_get_record_widget", id: "999999")
55
+ end
56
+ denied = with_authorize(->(**) { false }) do
57
+ call_tool(resource: "mcp_get_record_widget", id: "1")
58
+ end
59
+
60
+ assert_equal missing.dig("result", "content"), denied.dig("result", "content")
61
+ end
62
+ end
@@ -0,0 +1,107 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "test_helper"
4
+
5
+ module McpInstrumentationFixtures
6
+ class Widget
7
+ extend ActiveModel::Naming
8
+
9
+ def self.all = ReadOnlyResourceFixtures::Relation.new([])
10
+ end
11
+ end
12
+
13
+ module Admin
14
+ module Resources
15
+ class McpInstrumentationWidgetResource < Admin::Base::Resource
16
+ model McpInstrumentationFixtures::Widget
17
+ portal :ops
18
+ section :observability
19
+ index { columns { column :name } }
20
+ end
21
+ end
22
+ end
23
+
24
+ class McpInstrumentationTest < McpIntegrationTest
25
+ AuditActor = Struct.new(:id)
26
+ def call_tool(arguments = { resource: "mcp_instrumentation_widget" })
27
+ post "/internal/admin_suite/mcp",
28
+ params: { jsonrpc: "2.0", id: 1, method: "tools/call", params: {
29
+ name: "list_records", arguments: arguments
30
+ } }.to_json,
31
+ headers: { "CONTENT_TYPE" => "application/json", "HTTP_ACCEPT" => "application/json, text/event-stream" }
32
+ end
33
+
34
+ test "every tool call emits one complete event whether allowed or denied" do
35
+ events = []
36
+ subscriber = ActiveSupport::Notifications.subscribe("admin_suite.mcp.tool_call") do |*args|
37
+ events << ActiveSupport::Notifications::Event.new(*args).payload
38
+ end
39
+
40
+ with_authorize(->(**) { true }) { call_tool }
41
+ with_authorize(nil) { AdminSuite::Mcp::Tools::ListRecords.call(
42
+ resource: "mcp_instrumentation_widget", server_context: { actor: nil }
43
+ ) }
44
+
45
+ assert_equal [true, false], events.map { |event| event[:allowed] }
46
+ assert events.all? { |event| event[:tool] == "list_records" }
47
+ assert events.all? { |event| event[:action] == :read }
48
+ assert events.all? { |event| event[:duration_ms].is_a?(Numeric) }
49
+ ensure
50
+ ActiveSupport::Notifications.unsubscribe(subscriber) if subscriber
51
+ end
52
+
53
+ test "a raise inside authorize_resource! is not logged as an authorized read" do
54
+ events = []
55
+ subscriber = ActiveSupport::Notifications.subscribe("admin_suite.mcp.tool_call") do |*args|
56
+ events << ActiveSupport::Notifications::Event.new(*args).payload
57
+ end
58
+
59
+ AdminSuite::Mcp::Authorization.stub(:authorize_resource!, proc { raise "boom" }) do
60
+ AdminSuite::Mcp::Tools::ListRecords.call(
61
+ resource: "mcp_instrumentation_widget",
62
+ server_context: { actor: "x" }
63
+ )
64
+ end
65
+
66
+ assert_equal false, events.last[:allowed]
67
+ ensure
68
+ ActiveSupport::Notifications.unsubscribe(subscriber) if subscriber
69
+ end
70
+
71
+ test "audit metadata identifies the operator and distinguishes tool failures" do
72
+ actor = AuditActor.new(42)
73
+ request = Struct.new(:request_id).new("audit-request-123")
74
+ events = []
75
+ subscriber = ActiveSupport::Notifications.subscribe("admin_suite.mcp.tool_call") do |*args|
76
+ events << ActiveSupport::Notifications::Event.new(*args).payload
77
+ end
78
+
79
+ AdminSuite::Mcp.instrument(tool: "list_records", actor: actor, request: request) do
80
+ [MCP::Tool::Response.new([{ type: "text", text: "failed" }], error: true), nil, true]
81
+ end
82
+
83
+ assert_equal "42", events.first.fetch(:actor_id)
84
+ assert_equal actor.class.name, events.first.fetch(:actor_type)
85
+ assert_equal "audit-request-123", events.first.fetch(:request_id)
86
+ assert_equal true, events.first.fetch(:error)
87
+ assert_equal true, events.first.fetch(:allowed)
88
+ ensure
89
+ ActiveSupport::Notifications.unsubscribe(subscriber) if subscriber
90
+ end
91
+
92
+ test "the audit event carries the applied filter set" do
93
+ events = []
94
+ subscriber = ActiveSupport::Notifications.subscribe("admin_suite.mcp.tool_call") do |*args|
95
+ events << ActiveSupport::Notifications::Event.new(*args).payload
96
+ end
97
+
98
+ with_authorize(->(**) { true }) do
99
+ call_tool(resource: "mcp_instrumentation_widget", q: "alpha", filters: { state: "open" })
100
+ end
101
+
102
+ assert_equal "alpha", events.last.fetch(:q)
103
+ assert_equal({ state: "open" }, events.last.fetch(:filters))
104
+ ensure
105
+ ActiveSupport::Notifications.unsubscribe(subscriber) if subscriber
106
+ end
107
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "test_helper"
4
+
5
+ module McpListRecordsFixtures
6
+ class Widget
7
+ extend ActiveModel::Naming
8
+
9
+ attr_reader :id, :name, :secret_token
10
+
11
+ def initialize(id: 1, name: "Widget", secret_token: "sh-hh")
12
+ @id = id
13
+ @name = name
14
+ @secret_token = secret_token
15
+ end
16
+
17
+ def self.all = ReadOnlyResourceFixtures::Relation.new([new])
18
+ end
19
+ end
20
+
21
+ module Admin
22
+ module Resources
23
+ class McpListRecordsWidgetResource < Admin::Base::Resource
24
+ model McpListRecordsFixtures::Widget
25
+ portal :ops
26
+ section :observability
27
+ index do
28
+ columns do
29
+ column :id
30
+ column :name
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end
36
+
37
+ class McpListRecordsTest < McpIntegrationTest
38
+ def call_tool(name, arguments)
39
+ post "/internal/admin_suite/mcp",
40
+ params: { jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: name, arguments: arguments } }.to_json,
41
+ headers: { "CONTENT_TYPE" => "application/json", "HTTP_ACCEPT" => "application/json, text/event-stream" }
42
+ JSON.parse(response.body)
43
+ end
44
+
45
+ test "returns rows limited to declared columns" do
46
+ result = with_authorize(->(**) { true }) do
47
+ call_tool("list_records", { resource: "mcp_list_records_widget" })
48
+ end
49
+
50
+ refute result.dig("result", "isError"), result.inspect
51
+ assert_match(/Widget/, result.to_s)
52
+ refute_match(/sh-hh/, result.to_s)
53
+ end
54
+
55
+ test "clamps per_page to the configured maximum and says so" do
56
+ result = with_authorize(->(**) { true }) do
57
+ call_tool("list_records", { resource: "mcp_list_records_widget", per_page: 5000 })
58
+ end
59
+
60
+ payload = JSON.parse(result.dig("result", "content", 0, "text"))
61
+ assert_equal 100, payload.fetch("applied_per_page")
62
+ end
63
+
64
+ test "an unknown resource and a denied resource return identical errors" do
65
+ unknown = with_authorize(->(**) { true }) do
66
+ call_tool("list_records", { resource: "no_such_thing" })
67
+ end
68
+ denied = with_authorize(->(**) { false }) do
69
+ call_tool("list_records", { resource: "mcp_list_records_widget" })
70
+ end
71
+
72
+ assert_equal unknown.dig("result", "content"), denied.dig("result", "content"),
73
+ "an existence oracle: an unknown resource must be indistinguishable from a denied one"
74
+ end
75
+ end
@@ -0,0 +1,155 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "test_helper"
4
+
5
+ module McpParityFixtures
6
+ class Relation
7
+ include Enumerable
8
+
9
+ def initialize(records, offset: 0)
10
+ @records = records
11
+ @offset = offset
12
+ end
13
+
14
+ def each(&block) = @records.each(&block)
15
+ def count(*) = @records.size
16
+ def offset(value) = self.class.new(@records, offset: value)
17
+ def limit(value) = @records[@offset, value] || []
18
+
19
+ def where(condition, search:)
20
+ fields = condition.scan(/(\w+) ILIKE/).flatten
21
+ term = search.delete("%").downcase
22
+ self.class.new(@records.select do |record|
23
+ fields.any? { |field| record.public_send(field).to_s.downcase.include?(term) }
24
+ end)
25
+ end
26
+
27
+ def order(ordering)
28
+ field, direction = ordering.first
29
+ rows = @records.sort_by { |record| record.public_send(field).to_s }
30
+ rows.reverse! if direction.to_sym == :desc
31
+ self.class.new(rows)
32
+ end
33
+ end
34
+
35
+ class Application < ActiveRecord::Base
36
+ attr_reader :id, :name
37
+
38
+ def initialize(id:, name:)
39
+ @id = id
40
+ @name = name
41
+ end
42
+
43
+ def to_param = id.to_s
44
+ end
45
+
46
+ class Widget
47
+ extend ActiveModel::Naming
48
+
49
+ attr_reader :id, :name, :created_at, :application
50
+
51
+ def initialize(id:, name:, created_at: Time.utc(2026, 9, 10, 12, 0, 0), application: Application.new(id: 7, name: "Acme"))
52
+ @id = id
53
+ @name = name
54
+ @created_at = created_at
55
+ @application = application
56
+ end
57
+
58
+ ROWS = [new(id: 1, name: "Zulu"), new(id: 2, name: "Alpha"), new(id: 3, name: "Bravo")].freeze
59
+
60
+ def self.all = Relation.new(ROWS)
61
+ def self.column_names = %w[id name]
62
+ def self.primary_key = "id"
63
+ def self.columns_hash = { "id" => Struct.new(:type).new(:integer) }
64
+ def to_param = id.to_s
65
+ end
66
+ end
67
+
68
+ module Admin
69
+ module Resources
70
+ class McpParityWidgetResource < Admin::Base::Resource
71
+ model McpParityFixtures::Widget
72
+ portal :ops
73
+ section :observability
74
+ index do
75
+ searchable :name
76
+ sortable :name, default: :name, direction: :asc
77
+ columns do
78
+ column :id
79
+ column :name, sortable: true
80
+ column :listings_count, ->(_r) { 42 }
81
+ column :status, ->(_r) { "active" }, type: :label
82
+ column :created_at
83
+ column :application
84
+ end
85
+ end
86
+ end
87
+ end
88
+ end
89
+
90
+ class McpParityTest < McpIntegrationTest
91
+ PATH = "/internal/admin_suite/ops/mcp_parity_widgets"
92
+
93
+ def mcp_payload(tool, arguments)
94
+ post "/internal/admin_suite/mcp",
95
+ params: { jsonrpc: "2.0", id: 1, method: "tools/call", params: {
96
+ name: tool, arguments: arguments
97
+ } }.to_json,
98
+ headers: { "CONTENT_TYPE" => "application/json", "HTTP_ACCEPT" => "application/json, text/event-stream" }
99
+ rpc = JSON.parse(response.body)
100
+ JSON.parse(rpc.dig("result", "content", 0, "text"))
101
+ end
102
+
103
+ test "list_records and the index return identical ids for one query" do
104
+ with_authorize(->(**) { true }) do
105
+ get PATH, params: { sort: "name", direction: "asc" }
106
+ assert_response :success
107
+ ui_ids = css_select("tbody tr").map { |row| row["data-record-id"] }
108
+
109
+ mcp_ids = mcp_payload("list_records", {
110
+ resource: "mcp_parity_widget", sort: "name", direction: "asc"
111
+ }).fetch("rows").map { |row| row.fetch("id").to_s }
112
+
113
+ assert_equal %w[2 3 1], ui_ids
114
+ assert_equal ui_ids, mcp_ids
115
+
116
+ get PATH, params: { search: "alpha" }
117
+ search_ui_ids = css_select("tbody tr").map { |row| row["data-record-id"] }
118
+ search_mcp_ids = mcp_payload("list_records", {
119
+ resource: "mcp_parity_widget", q: "alpha"
120
+ }).fetch("rows").map { |row| row.fetch("id").to_s }
121
+ assert_equal search_ui_ids, search_mcp_ids
122
+ end
123
+ end
124
+
125
+ test "lambda columns match the index cell and object fields serialize as primitives" do
126
+ with_authorize(->(**) { true }) do
127
+ get PATH
128
+ assert_response :success
129
+ cells = css_select("tbody tr[data-record-id='1'] td")
130
+ row = mcp_payload("list_records", { resource: "mcp_parity_widget" })
131
+ .fetch("rows").find { |item| item.fetch("id") == 1 }
132
+
133
+ assert_equal "42", cells[2].text.strip
134
+ assert_equal 42, row.fetch("listings_count")
135
+ assert_includes cells[3].text, "active"
136
+ assert_equal "active", row.fetch("status")
137
+ assert_equal Time.utc(2026, 9, 10, 12, 0, 0), Time.iso8601(row.fetch("created_at"))
138
+ assert_equal "Acme", row.fetch("application")
139
+ refute_match(/#<|0x[0-9a-f]+/i, row.fetch("application"))
140
+ end
141
+ end
142
+
143
+ test "aggregate count matches the shared query's index total" do
144
+ with_authorize(->(**) { true }) do
145
+ get PATH, params: { search: "alpha" }
146
+ ui_total = css_select("tbody tr").size
147
+
148
+ mcp_total = mcp_payload("aggregate", {
149
+ resource: "mcp_parity_widget", q: "alpha"
150
+ }).fetch("count")
151
+
152
+ assert_equal ui_total, mcp_total
153
+ end
154
+ end
155
+ end