administrate-mcp 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e16575edff64537e779f60e3d447d4bef99e19f46669c64eb882bd14867279a7
4
- data.tar.gz: 2490fbf883f4980d3a152a5d245f27448f5729159b8944432a59c1d90dcb51d5
3
+ metadata.gz: 7e0b5788d3dd59f527d55db5f4e6e4d748c23f1c6d4f247c2afaa660e4dce89e
4
+ data.tar.gz: 932950d82e3472408f440cdb2a736b8107978ca8016c55e625e1221e21e99ddc
5
5
  SHA512:
6
- metadata.gz: 6b74d3c9e992058ff3d896cd3d4f80075520ee3e977f4a07d0093dc9982aee9b686465c0d236d0f92e199751f863e9fc544afc44b066f1d5548d2bb9a909a19a
7
- data.tar.gz: 0142657f7abee895bd4acec60789403c2ddaa999bc55b2ffc3077f6a164f8db579bf8d835daf063e227b4bcfcc9ce9a2c96843cd5ffa91800fa6227d456668c7
6
+ metadata.gz: 41f1ca74dbe3ed51314f8ec39ea8f38d99ba1831dd725e4e19ca17e8ef940b31d359f7651526a8fe1b8dfb53bcfb74ed2cba615d45e48a8cba230cb3b463711d
7
+ data.tar.gz: e1575180b4778ba2e68addc80c8c90441045436588281fc413d66a6abe43554be3eccc39d6a3965f7a927949f4ae911019890bb0e931bb31cb5a7ee0c6c2f767
data/CHANGELOG.md CHANGED
@@ -8,6 +8,32 @@ minor release may change behaviour a host depends on; the entry says so when it
8
8
 
9
9
  ## [Unreleased]
10
10
 
11
+ ## [0.5.0] - 2026-10-22
12
+
13
+ ### Added
14
+
15
+ - An `mcp_action` block can send data back to the client. It returns
16
+ `Administrate::MCP::ActionResult.success(record:, data:)`, or any result object answering
17
+ `mcp_data`, and the answer gains a `data` key next to `status`, `model`, `id` and `action`, which
18
+ are unchanged. The record, optional, goes through the same checks as a record returned directly,
19
+ so an unsaved or invalid one is still an error and a collection action still answers its `id`.
20
+ `ActionResult.failure(message, data:)` is an error whose first content block is the message, as
21
+ before; when there is data, a second block carries `{ "error": message, "data": … }` as JSON and
22
+ the result's `structuredContent` carries the same object. The data must be a Hash of plain JSON
23
+ values nested at most 32 levels deep, with valid UTF-8 strings; the engine encodes a copy
24
+ built from those values, never the host's objects, and its JSON may be at most
25
+ `config.action_data_max_bytes` (new, 32 KB by default, `nil` for no limit). Anything else is
26
+ answered as an error naming the offending path and saying the action ran. A plain Hash returned
27
+ by a block is still not sent back. See
28
+ [Returning data from an action](docs/dashboards.md#returning-data-from-an-action).
29
+
30
+ ### Changed
31
+
32
+ - A successful `mcp_action` call now also returns its answer as the result's `structuredContent`,
33
+ the same object as the JSON in its text content, which is unchanged.
34
+ - A tool error may now carry a second content block. Clients that read only the first block still
35
+ get the message on its own; only failures that return data add the second block.
36
+
11
37
  ## [0.4.0] - 2026-10-21
12
38
 
13
39
  ### Added
@@ -177,7 +203,8 @@ minor release may change behaviour a host depends on; the entry says so when it
177
203
  took the gem from git before 0.1.0 must reinstall its migrations rather than migrate
178
204
  incrementally.
179
205
 
180
- [Unreleased]: https://github.com/sorare/administrate-mcp/compare/v0.4.0...HEAD
206
+ [Unreleased]: https://github.com/sorare/administrate-mcp/compare/v0.5.0...HEAD
207
+ [0.5.0]: https://github.com/sorare/administrate-mcp/compare/v0.4.0...v0.5.0
181
208
  [0.4.0]: https://github.com/sorare/administrate-mcp/compare/v0.3.1...v0.4.0
182
209
  [0.3.1]: https://github.com/sorare/administrate-mcp/compare/v0.3.0...v0.3.1
183
210
  [0.3.0]: https://github.com/sorare/administrate-mcp/compare/v0.2.0...v0.3.0
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Administrate
4
+ module MCP
5
+ # What an `mcp_action` block may return to send data back to the client. `record` goes through
6
+ # the same checks as a record returned directly, and gives a collection action its `id`; `data`
7
+ # is answered as `mcp_data`, which the engine adds to the response under `data`, or to the error
8
+ # of a failure. Any result object that answers `mcp_data` works the same way.
9
+ class ActionResult
10
+ attr_reader :record, :data, :error
11
+
12
+ alias mcp_data data
13
+
14
+ private_class_method :new
15
+
16
+ def self.success(record: nil, data: nil)
17
+ new(record:, data:, error: nil, success: true)
18
+ end
19
+
20
+ def self.failure(error = nil, data: nil)
21
+ new(record: nil, data:, error:, success: false)
22
+ end
23
+
24
+ def initialize(record:, data:, error:, success:)
25
+ @record = record
26
+ @data = data
27
+ @error = error
28
+ @success = success
29
+ end
30
+
31
+ def success?
32
+ @success
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Administrate
4
+ module MCP
5
+ module Actions
6
+ # Reads the `mcp_data` of an action's result and turns it into what goes back to the client: a
7
+ # copy built only from plain Hashes with String keys, Arrays, Strings and JSON scalars, no deeper
8
+ # than MAX_DEPTH and no larger than `config.action_data_max_bytes` once encoded. The copy, not
9
+ # the host's objects, is what gets encoded, so no `as_json` of theirs runs. The action has
10
+ # already run when this is checked, so the error says so.
11
+ module ResultData
12
+ MAX_DEPTH = 32
13
+
14
+ SCALARS = [Integer, TrueClass, FalseClass, NilClass].freeze
15
+
16
+ class << self
17
+ def extract!(result, tool_name:)
18
+ return nil unless result.respond_to?(:mcp_data)
19
+
20
+ data = result.mcp_data
21
+ return nil if data.nil?
22
+
23
+ normalize_root!(data, tool_name)
24
+ end
25
+
26
+ private
27
+
28
+ def normalize_root!(data, tool_name)
29
+ raise TypeError, "data (#{data.class}) is not a Hash" unless data.is_a?(Hash)
30
+
31
+ normalize!(data, 'data').tap { |copy| check_size!(copy) }
32
+ rescue TypeError, RangeError => e
33
+ raise Error, "#{tool_name} ran, but its data cannot be returned: #{e.message}"
34
+ end
35
+
36
+ def normalize!(value, path, depth = 0)
37
+ raise RangeError, "#{path} is nested deeper than #{MAX_DEPTH} levels" if depth > MAX_DEPTH
38
+
39
+ case value
40
+ when Hash then normalize_hash!(value, path, depth)
41
+ when Array then value.each_with_index.map { |item, index| normalize!(item, "#{path}[#{index}]", depth + 1) }
42
+ when String, Symbol then normalize_string!(value, path)
43
+ else normalize_scalar!(value, path)
44
+ end
45
+ end
46
+
47
+ def normalize_string!(value, path)
48
+ copy = String.new(value.to_s).encode(Encoding::UTF_8)
49
+ raise TypeError, "#{path} is not valid UTF-8" unless copy.valid_encoding?
50
+
51
+ copy
52
+ rescue EncodingError
53
+ raise TypeError, "#{path} cannot be converted to UTF-8"
54
+ end
55
+
56
+ def normalize_hash!(hash, path, depth)
57
+ hash.each_with_object({}) do |(key, item), copy|
58
+ name = key_name!(key, path)
59
+ raise TypeError, "#{path} has the key #{name} twice" if copy.key?(name)
60
+
61
+ copy[name] = normalize!(item, "#{path}.#{name}", depth + 1)
62
+ end
63
+ end
64
+
65
+ def key_name!(key, path)
66
+ return normalize_string!(key, "a key of #{path}") if key.is_a?(String) || key.is_a?(Symbol)
67
+
68
+ raise TypeError, "#{path} has a key (#{key.class}) that is not a String or Symbol"
69
+ end
70
+
71
+ def normalize_scalar!(value, path)
72
+ if value.instance_of?(Float)
73
+ raise TypeError, "#{path} is not a finite number" unless value.finite?
74
+
75
+ return value
76
+ end
77
+ return value if SCALARS.any? { |type| value.instance_of?(type) }
78
+
79
+ raise TypeError, "#{path} (#{value.class}) is not a JSON value"
80
+ end
81
+
82
+ def check_size!(copy)
83
+ limit = Administrate::MCP.config.action_data_max_bytes
84
+ return if limit.nil?
85
+
86
+ bytes = copy.to_json.bytesize
87
+ return if bytes <= limit
88
+
89
+ raise RangeError,
90
+ "data is #{bytes} bytes of JSON, over the #{limit}-byte limit (config.action_data_max_bytes)"
91
+ end
92
+ end
93
+ end
94
+ end
95
+ end
96
+ end
@@ -177,7 +177,11 @@ module Administrate
177
177
  end
178
178
  klass.define_singleton_method(:execute) do |admin:, id: nil, **params|
179
179
  outcome = ToolFactory.send(:invoke, defn, admin, id, params)
180
- outcome.key?(:error) ? error_response(outcome[:error]) : json_response(outcome[:data])
180
+ if outcome.key?(:error)
181
+ error_response(outcome[:error], data: outcome[:details])
182
+ else
183
+ json_response(outcome[:data], structured: true)
184
+ end
181
185
  end
182
186
  end
183
187
 
@@ -190,15 +194,20 @@ module Administrate
190
194
  declared = params.slice(*defn.params.keys, *Uploads::Declarations.param_schemas(defn).keys)
191
195
  arguments = { admin:, params: Uploads::Declarations.resolve_params(defn, admin, declared) }
192
196
  arguments[:record] = Actions.find_record!(defn.model_class, id) if defn.on == :member
193
- result = defn.invoke.call(**arguments)
194
- return { error: failure_message(defn, result, arguments[:record]) } if failed?(result)
197
+ returned = defn.invoke.call(**arguments)
198
+ result = returned.is_a?(ActionResult) ? returned.record : returned
199
+ data = ResultData.extract!(returned, tool_name: Actions.tool_name(defn))
200
+ failures = [returned, result].select { |candidate| failed?(candidate) }
201
+ unless failures.empty?
202
+ return { error: failure_message(defn, failures.first, arguments[:record]), details: data }
203
+ end
195
204
 
196
- { data: success_data(defn, id, result) }
205
+ { data: success_data(defn, id, result, data) }
197
206
  end
198
207
 
199
- def success_data(defn, id, result)
208
+ def success_data(defn, id, result, data)
200
209
  id = result.id if defn.on == :collection && result.is_a?(ActiveRecord::Base)
201
- { status: 'ok', model: defn.model_class.name.underscore, id:, action: defn.name.to_s }.compact
210
+ { status: 'ok', model: defn.model_class.name.underscore, id:, action: defn.name.to_s, data: }.compact
202
211
  end
203
212
 
204
213
  def failed?(result)
@@ -106,12 +106,17 @@ module Administrate
106
106
  ::MCP::Tool::Response.new([{ type: 'text', text: }])
107
107
  end
108
108
 
109
- def json_response(data)
110
- ::MCP::Tool::Response.new([{ type: 'text', text: data.to_json }])
109
+ def json_response(data, structured: false)
110
+ ::MCP::Tool::Response.new([{ type: 'text', text: data.to_json }], structured_content: (data if structured))
111
111
  end
112
112
 
113
- def error_response(message)
114
- ::MCP::Tool::Response.new([{ type: 'text', text: message }], error: true)
113
+ def error_response(message, data: nil)
114
+ content = [{ type: 'text', text: message }]
115
+ return ::MCP::Tool::Response.new(content, error: true) if data.nil?
116
+
117
+ structured = { error: message, data: }
118
+ content << { type: 'text', text: structured.to_json }
119
+ ::MCP::Tool::Response.new(content, error: true, structured_content: structured)
115
120
  end
116
121
  end
117
122
  end
@@ -111,6 +111,7 @@ class Configuration
111
111
  :persist_feedback,
112
112
  :feedback_tool,
113
113
  :sidekiq_stats_provider,
114
+ :action_data_max_bytes,
114
115
  :admin_route_namespace,
115
116
  :admin_url_options,
116
117
  :skipped_field_classes,
@@ -148,6 +149,7 @@ class Configuration
148
149
  @persist_feedback = false
149
150
  @feedback_tool = true
150
151
  @sidekiq_stats_provider = nil
152
+ @action_data_max_bytes = 32_768
151
153
  end
152
154
 
153
155
  def assign_fields
@@ -197,6 +199,7 @@ end
197
199
  | `persist_feedback` | `false` | Whether `report_mcp_improvement` creates an `Administrate::MCP::Feedback` row before calling `on_feedback`. False needs no `administrate_mcp_feedbacks` table at all |
198
200
  | `feedback_tool` | `true` | Whether `report_mcp_improvement` is published |
199
201
  | `sidekiq_stats_provider` | `nil` | Object answering `counts`, `total_counts`, `queues`, `stats_cleared_at`; a class name String or a Proc is resolved lazily so autoloaded providers can be named in an initializer |
202
+ | `action_data_max_bytes` | `32_768` | Largest `data` an `mcp_action` may return, in bytes of JSON; larger data is an error. `nil` removes the limit. See [Returning data from an action](dashboards.md#returning-data-from-an-action) |
200
203
  | `admin_route_namespace` | `:admin` | Namespace record URLs are built from |
201
204
  | `admin_url_options` | `{}` | Options passed to `polymorphic_url`; when no `:host` is given, host, protocol and port are taken from `admin_origin` |
202
205
  | `skipped_field_classes` | Password | Field class names never serialized |
data/docs/dashboards.md CHANGED
@@ -53,7 +53,8 @@ base class, is not applied to every resource. A subclass that wants one declares
53
53
  saved or has errors is an error too, with its validation messages. So is `false`, the result of
54
54
  a failed `update` or of a `destroy` a callback aborted; for a member action the message is the
55
55
  loaded record's validation messages when it has any. Anything else is a success, including a
56
- record the block destroyed.
56
+ record the block destroyed. To send data back with the answer, return an
57
+ `Administrate::MCP::ActionResult`; see [Returning data from an action](#returning-data-from-an-action).
57
58
  - The tool is announced as destructive unless the declaration says `destructive: false`. Clients
58
59
  may ask for confirmation before running a destructive tool, so set it on actions that only add
59
60
  or prepare something.
@@ -72,6 +73,60 @@ base class, is not applied to every resource. A subclass that wants one declares
72
73
  `max_size:`) adds an optional `<attribute>_upload_id` param for each of them, and the block
73
74
  receives the uploaded file as `params[:<attribute>]`. See [Presigned uploads](uploads.md).
74
75
 
76
+ ## Returning data from an action
77
+
78
+ An action answers `{ status, model, id, action }` by default, as JSON text and, identically, as the
79
+ result's `structuredContent`. When the client needs more than that, such as what an import created
80
+ and what it skipped, the block returns an `Administrate::MCP::ActionResult` and the answer gains a
81
+ `data` key:
82
+
83
+ ```ruby
84
+ mcp_action :import_batch,
85
+ on: :collection,
86
+ description: 'Import orders from a marketplace export.',
87
+ params: { references: { type: 'array', items: { type: 'string' } } } do |admin:, params:|
88
+ report = Orders::ImportBatch.call!(references: params[:references], admin:)
89
+ next Administrate::MCP::ActionResult.failure('No order matched', data: { unknown: report.unknown }) if report.empty?
90
+
91
+ Administrate::MCP::ActionResult.success(record: report.batch,
92
+ data: { created: report.created.size, unknown: report.unknown })
93
+ end
94
+ ```
95
+
96
+ ```json
97
+ { "status": "ok", "model": "order", "id": 42, "action": "import_batch", "data": { "created": 3, "unknown": ["X-9"] } }
98
+ ```
99
+
100
+ - `ActionResult.success(record:, data:)`: `record` is optional and is checked as if the block had
101
+ returned it, so an unsaved or invalid record is still an error, and a collection action still
102
+ answers its `id`.
103
+ - `ActionResult.failure(message, data:)` is an error with `message`, or `<action> failed` without
104
+ one. The error's first content block stays the message alone. When there is data, a second block
105
+ carries `{ "error": message, "data": … }` as JSON, and the result's `structuredContent` carries the
106
+ same object, so clients that only read the content still see the data.
107
+ - These two are the only constructors. Any result object that answers `mcp_data` with a Hash works
108
+ the same way, so a host's own service result can carry its data without being wrapped; `success?`
109
+ and `error` are read from it as for any other result.
110
+ - A plain Hash returned by the block is not sent back, as before: data reaches the client only when
111
+ the block asks for it, never because a service happened to return a Hash.
112
+ - `data` must be a Hash of JSON values: Hashes with String or Symbol keys, Arrays, Strings, Symbols,
113
+ Integers, finite Floats, `true`, `false` and `nil`, nested at most 32 levels deep. Strings and
114
+ keys must be valid UTF-8 or convertible to it, so check bytes read from a file. Convert
115
+ anything else, such as a time (`at.iso8601`) or a record (`record.id`), yourself. The engine
116
+ encodes a plain copy it builds from those values, so an object's own `as_json` never runs, and a
117
+ key given both as a String and as a Symbol is refused rather than sent twice. The copy's JSON may
118
+ be at most `config.action_data_max_bytes` (32 KB by default; `nil` removes the limit), so that an
119
+ action cannot fill the client's context. Return counts and the few items the client must act on,
120
+ not every row.
121
+ - Data that breaks a rule is answered as an error naming the offending path, for example
122
+ `data.report.rows[1] (Object) is not a JSON value`. The block has already run by then, so the
123
+ message says the action ran: fix the action rather than retrying the call.
124
+ - `data` reaches the client as it is, without the protections fields get from
125
+ `skipped_field_classes` and the field serializers. Don't return secrets, tokens or credentials. A
126
+ download URL is a bearer credential that ends up in transcripts and client logs, so make it
127
+ short-lived. Strings that came from end users (names, notes, references) are untrusted text in the
128
+ model's context, as they are in any field.
129
+
75
130
  ## Search
76
131
 
77
132
  The `admin_resource_list` tool's `query` argument matches exactly by default, so searching for an id
@@ -59,6 +59,7 @@ module Administrate
59
59
  :persist_feedback,
60
60
  :feedback_tool,
61
61
  :sidekiq_stats_provider,
62
+ :action_data_max_bytes,
62
63
  :admin_route_namespace,
63
64
  :admin_url_options,
64
65
  :skipped_field_classes,
@@ -102,6 +103,7 @@ module Administrate
102
103
  @persist_feedback = false
103
104
  @feedback_tool = true
104
105
  @sidekiq_stats_provider = nil
106
+ @action_data_max_bytes = 32_768
105
107
  @uploads = UploadsConfiguration.new
106
108
  end
107
109
 
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Administrate
4
4
  module MCP
5
- VERSION = '0.4.0'
5
+ VERSION = '0.5.0'
6
6
  end
7
7
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: administrate-mcp
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.0
4
+ version: 0.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Sorare
@@ -89,7 +89,9 @@ files:
89
89
  - app/controllers/concerns/administrate/mcp/resource_controller.rb
90
90
  - app/dashboards/administrate/mcp/api_key_dashboard.rb
91
91
  - app/dashboards/administrate/mcp/feedback_dashboard.rb
92
+ - app/lib/administrate/mcp/action_result.rb
92
93
  - app/lib/administrate/mcp/actions.rb
94
+ - app/lib/administrate/mcp/actions/result_data.rb
93
95
  - app/lib/administrate/mcp/admin_dashboard_tool.rb
94
96
  - app/lib/administrate/mcp/authentication.rb
95
97
  - app/lib/administrate/mcp/base_tool.rb