administrate-mcp 0.3.1 → 0.4.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: f3f38d54e63d5f8d6b88750c73f284d10d5e0500a0a7f47cae82556be264e8fe
4
- data.tar.gz: 1b1d5fda201d08915bdddd2c6eb92fb97f9698ece7f17cbff1a1d563f280eea9
3
+ metadata.gz: e16575edff64537e779f60e3d447d4bef99e19f46669c64eb882bd14867279a7
4
+ data.tar.gz: 2490fbf883f4980d3a152a5d245f27448f5729159b8944432a59c1d90dcb51d5
5
5
  SHA512:
6
- metadata.gz: bdc5883ed9f4d6cadab89dd8be6aca8f890e25cfc39d0a40ef6b2b7e9fd6b11af6de5e817d4d8c6047a0bcd9d3ad1a6bd05b4af095bd64c256ad6d65198bbf7e
7
- data.tar.gz: 21bd4c4e668bad2bd5d9050bc6e86abae5800a605be3604d2683687bb3b52bcf4dae42cc2423f5df2fe09713ac14784cc5188a5d76ba28694fe2f41581fa895e
6
+ metadata.gz: 6b74d3c9e992058ff3d896cd3d4f80075520ee3e977f4a07d0093dc9982aee9b686465c0d236d0f92e199751f863e9fc544afc44b066f1d5548d2bb9a909a19a
7
+ data.tar.gz: 0142657f7abee895bd4acec60789403c2ddaa999bc55b2ffc3077f6a164f8db579bf8d835daf063e227b4bcfcc9ce9a2c96843cd5ffa91800fa6227d456668c7
data/CHANGELOG.md CHANGED
@@ -8,6 +8,45 @@ minor release may change behaviour a host depends on; the entry says so when it
8
8
 
9
9
  ## [Unreleased]
10
10
 
11
+ ## [0.4.0] - 2026-10-21
12
+
13
+ ### Added
14
+
15
+ - Collection-level write actions: `mcp_action name, on: :collection, ...` publishes a tool with no
16
+ `id` in its input schema. Nothing is loaded; the block receives `admin:` and `params:`, and the
17
+ authorization predicate, `<name>?` by default as for member actions and overridable with
18
+ `predicate:` or `pundit:`, is checked against a new instance of the model through
19
+ `config.authorization`, when the tool list is built and on every call. The `write` scope gate,
20
+ the `on_tool_call` audit and the error reporting work as for member actions, and the tool keeps
21
+ the `<resource>_<action>` name. When the block returns an Active Record object, the response
22
+ includes its primary key as `id`. `on: :member` stays the default.
23
+ - Presigned uploads for Shrine attachments, off until `config.uploads.enabled` is set. The
24
+ `uploads_prepare` tool takes the target action's `tool` name, an `attribute` and `files`, an
25
+ object of filename to size in bytes. It is only published to, and only answers, a caller
26
+ authorized for an action that accepts uploads for that attribute. It records an
27
+ `administrate_mcp_upload_batches` row and returns `{ filename => { upload_id, method, url,
28
+ fields } }`, one presigned `POST` per file whose policy bounds the content length. An
29
+ `mcp_action` declared with `uploads:` (`true`, a list of attributes, or per-attribute
30
+ `content_types:` and `max_size:`) takes an optional `<attribute>_upload_id` for each attachment
31
+ attribute; the engine resolves it and the block receives the file as `params[:<attribute>]`.
32
+ Resolving checks the owning admin (another admin's id is not found), the action and attribute
33
+ the upload was prepared for, the batch expiry, that the file was uploaded, and its real size
34
+ and content type, on a copy under a key no URL points to; the copy is reused if the same id is
35
+ passed again. Defaults: png, jpeg and webp, 10 MB, 10 files per call, 15-minute URLs, 1-hour
36
+ batches. `Administrate::MCP::CleanExpiredUploadBatches` deletes expired batches. The default
37
+ storage is the Shrine `:cache` storage, replaceable through `config.uploads.storage`; the gem
38
+ gains no dependency. Hosts that enable uploads need the new migration
39
+ (`bin/rails administrate_mcp:install:migrations`).
40
+
41
+ ### Changed
42
+
43
+ - **Behaviour change for hosts:** an `mcp_action` block that returns an Active Record object that
44
+ was never saved or has errors is now reported as an error, with the record's validation messages,
45
+ instead of `status: ok`. A record the block destroyed is still a success. A block that returns
46
+ `false`, as `record.update(...)` does on a validation error and `record.destroy` does when a
47
+ callback aborts it, is now an error too, with the loaded record's validation messages for a member
48
+ action when it has any. A block that returned `false` to mean success must return something else.
49
+
11
50
  ## [0.3.1] - 2026-10-20
12
51
 
13
52
  ### Security
@@ -138,7 +177,8 @@ minor release may change behaviour a host depends on; the entry says so when it
138
177
  took the gem from git before 0.1.0 must reinstall its migrations rather than migrate
139
178
  incrementally.
140
179
 
141
- [Unreleased]: https://github.com/sorare/administrate-mcp/compare/v0.3.1...HEAD
180
+ [Unreleased]: https://github.com/sorare/administrate-mcp/compare/v0.4.0...HEAD
181
+ [0.4.0]: https://github.com/sorare/administrate-mcp/compare/v0.3.1...v0.4.0
142
182
  [0.3.1]: https://github.com/sorare/administrate-mcp/compare/v0.3.0...v0.3.1
143
183
  [0.3.0]: https://github.com/sorare/administrate-mcp/compare/v0.2.0...v0.3.0
144
184
  [0.2.0]: https://github.com/sorare/administrate-mcp/compare/v0.1.0...v0.2.0
data/README.md CHANGED
@@ -21,6 +21,7 @@ permissions the admin UI enforces.
21
21
  - [Configuration](#configuration)
22
22
  - [Routes](#routes)
23
23
  - [Dashboard declarations](#dashboard-declarations)
24
+ - [Presigned uploads](#presigned-uploads)
24
25
  - [Authentication](#authentication)
25
26
  - [OAuth](#oauth)
26
27
  - [Improving the server from its own use](#improving-the-server-from-its-own-use)
@@ -45,7 +46,14 @@ about your application; everything host-specific goes through `Administrate::MCP
45
46
  - Three generic tools built from every Administrate dashboard: `admin_resource_list_resources`,
46
47
  `admin_resource_list`, `admin_resource_show`.
47
48
  - Write actions declared per dashboard with `mcp_action`, each published as its own tool, gated by
48
- the `write` scope (an API key with write access, or an OAuth token granted it) and the resource's own authorization predicate on the loaded record.
49
+ the `write` scope (an API key with write access, or an OAuth token granted it) and the resource's
50
+ own authorization predicate: on the loaded record for a member action, or on the model class for a
51
+ collection action (`on: :collection`), such as a create, that takes no `id`.
52
+ - Presigned uploads for Shrine attachments, opt-in: `uploads_prepare` hands out presigned `POST`
53
+ URLs for one action and attribute, and an action declared with `uploads:` takes an
54
+ `<attribute>_upload_id` for each attachment attribute. The file's owner, target, expiry, real size
55
+ and real content type are checked on a copy the client cannot overwrite before the action runs.
56
+ See [Presigned uploads](docs/uploads.md).
49
57
  - Three ways to authenticate a caller: API keys stored as a digest, an external identity provider
50
58
  through `identity_fallback` (a Cloudflare Access verifier ships with the gem), and an optional
51
59
  built-in OAuth 2.1 server with Dynamic Client Registration, PKCE and refresh tokens, on by default
@@ -157,8 +165,8 @@ bin/rails db:migrate
157
165
  ```
158
166
 
159
167
  The tables are `administrate_mcp_api_keys`, `administrate_mcp_feedbacks`,
160
- `administrate_mcp_oauth_applications`, `administrate_mcp_oauth_access_grants` and
161
- `administrate_mcp_oauth_access_tokens`. They use uuid primary keys and a uuid column, `admin_id` by
168
+ `administrate_mcp_oauth_applications`, `administrate_mcp_oauth_access_grants`,
169
+ `administrate_mcp_oauth_access_tokens` and `administrate_mcp_upload_batches`. They use uuid primary keys and a uuid column, `admin_id` by
162
170
  default, that is indexed but carries no foreign key constraint, so the engine works with any admin
163
171
  table. Set `config.admin_foreign_key` before running the migrations if the host's own admin table
164
172
  already uses a different column name and renaming it is not an option, for example a live
@@ -166,6 +174,7 @@ credentials table.
166
174
 
167
175
  `administrate_mcp_feedbacks` is only needed when `config.persist_feedback` is `true`; leave it
168
176
  `false`, the default, and the migration ships but the table is never read from or written to.
177
+ Likewise, `administrate_mcp_upload_batches` is only used once `config.uploads.enabled` is `true`.
169
178
 
170
179
  ## Quick start
171
180
 
@@ -219,6 +228,11 @@ helper adds: [docs/routes.md](docs/routes.md).
219
228
  and `mcp_action`, the constants and macro that turn one dashboard into an MCP resource with its own
220
229
  readable fields and writable actions: [docs/dashboards.md](docs/dashboards.md).
221
230
 
231
+ ## Presigned uploads
232
+
233
+ How a client uploads a file to a presigned URL and sets it on an attachment through an action, the
234
+ checks on the uploaded file, and the storage it goes to: [docs/uploads.md](docs/uploads.md).
235
+
222
236
  ## Authentication
223
237
 
224
238
  How a request is authenticated (API key, then OAuth token, then `identity_fallback`), how to issue
@@ -9,50 +9,68 @@ module Administrate
9
9
  # declaration we build a tool whose name, input schema and description let non-developer users
10
10
  # discover and call it through the protocol.
11
11
  #
12
+ # An action is a member action by default: it takes an `id` and runs against the loaded record.
13
+ # Declared with `on: :collection`, it takes no `id`, loads nothing and runs against the model.
14
+ #
15
+ # An action declared with `uploads:` (true, a list of attachment attributes, or a hash of
16
+ # attribute to `content_types:` / `max_size:` overrides) also takes an optional
17
+ # `<attribute>_upload_id` for each of them. The id is resolved into the attachment value before
18
+ # the block runs, so the block sees `params[:<attribute>]`.
19
+ #
12
20
  # Enforcement is layered: the generated tool requires an OAuth `write` scope (the rollout gate)
13
- # AND runs the resource's authorization predicate against the loaded record (the per-admin
14
- # truth, identical to the admin UI). Auditing is inherited from BaseTool.
21
+ # AND runs the resource's authorization predicate against the loaded record, or against a new
22
+ # instance of the model for a collection action, as Administrate does for `new` and `create`.
23
+ # Auditing is inherited from BaseTool.
15
24
  module Actions
16
25
  Definition =
17
26
  Struct.new(
18
27
  :model_class,
19
28
  :name,
29
+ :on,
20
30
  :predicate,
21
31
  :scope,
22
32
  :destructive,
23
33
  :description,
24
34
  :params,
25
35
  :required,
36
+ :uploads,
26
37
  :invoke,
27
38
  keyword_init: true
28
39
  )
29
40
 
30
41
  RESERVED_PARAMS = %i[admin id].freeze
31
42
 
43
+ TARGETS = %i[member collection].freeze
44
+
32
45
  class << self
33
46
  # Normalizes the arguments of a `mcp_action` declaration into a spec (everything but the
34
47
  # model, which is inferred from the owning dashboard when definitions are built).
35
- def build_spec(name, description:, params: {}, predicate: nil, pundit: nil, scope: :write,
36
- destructive: true, &invoke)
48
+ def build_spec(name, description:, on: :member, params: {}, predicate: nil, pundit: nil, scope: :write,
49
+ destructive: true, uploads: false, &invoke)
37
50
  raise ArgumentError, "#{name}: a block is required to invoke the action" unless invoke
51
+ raise ArgumentError, "#{name}: on: must be :member or :collection" unless TARGETS.include?(on)
38
52
 
39
53
  reject_reserved_params!(name, params)
40
54
  normalized = normalize_params(params)
41
55
 
42
56
  {
43
57
  name: name.to_sym,
58
+ on:,
44
59
  predicate: (predicate || pundit || :"#{name}?").to_sym,
45
60
  scope: scope.to_sym,
46
61
  destructive:,
47
62
  description:,
48
63
  params: normalized.transform_values { |spec| spec.except(:required) },
49
64
  required: normalized.reject { |_, spec| spec[:required] == false }.keys,
65
+ uploads: Uploads::Declarations.normalize(uploads, action_name: name),
50
66
  invoke:
51
67
  }
52
68
  end
53
69
 
54
70
  def normalize_params(params)
55
- params.transform_values { |spec| spec.is_a?(String) ? { type: 'string', description: spec } : spec }
71
+ params.to_h do |name, spec|
72
+ [name.to_sym, spec.is_a?(String) ? { type: 'string', description: spec } : spec.to_h.symbolize_keys]
73
+ end
56
74
  end
57
75
 
58
76
  def definitions
@@ -61,11 +79,20 @@ module Administrate
61
79
  next [] unless entry.dashboard_class.respond_to?(:mcp_action_specs)
62
80
 
63
81
  entry.dashboard_class.mcp_action_specs.map do |spec|
64
- Definition.new(model_class: entry.model_class, **spec)
82
+ uploads = Uploads::Declarations.action_attributes(entry.dashboard_class, spec[:name], spec[:uploads])
83
+ Definition.new(model_class: entry.model_class, **spec, uploads:)
65
84
  end
66
85
  end
67
86
  end
68
87
 
88
+ def tool_name(definition)
89
+ "#{definition.model_class.name.underscore.tr('/', '_')}_#{definition.name}"
90
+ end
91
+
92
+ def available_to?(admin, definition, scopes:)
93
+ scopes.map(&:to_sym).include?(definition.scope) && authorized_for_discovery?(admin, definition)
94
+ end
95
+
69
96
  def all_tools
70
97
  @all_tools ||= definitions.map { |defn| ToolFactory.build(defn) }
71
98
  end
@@ -73,13 +100,8 @@ module Administrate
73
100
  # Tool classes the caller may both see and run: write scope granted AND the authorization
74
101
  # predicate satisfied at the role level. The actual call re-checks it against the record.
75
102
  def tools_for(server_context)
76
- granted = (server_context[:scopes] || []).map(&:to_sym)
77
- admin = server_context[:admin]
78
-
79
- all_tools.select do |tool|
80
- defn = tool.definition
81
- granted.include?(defn.scope) && authorized_for_discovery?(admin, defn)
82
- end
103
+ scopes = server_context[:scopes] || []
104
+ all_tools.select { |tool| available_to?(server_context[:admin], tool.definition, scopes:) }
83
105
  end
84
106
 
85
107
  def reset!
@@ -106,7 +128,8 @@ module Administrate
106
128
  end
107
129
 
108
130
  def authorized_for_discovery?(admin, definition)
109
- Administrate::MCP.config.authorization.authorized?(admin, definition.model_class, definition.predicate)
131
+ target = definition.on == :collection ? definition.model_class.new : definition.model_class
132
+ Administrate::MCP.config.authorization.authorized?(admin, target, definition.predicate)
110
133
  rescue StandardError
111
134
  false
112
135
  end
@@ -126,7 +149,7 @@ module Administrate
126
149
  def build(defn)
127
150
  resource = defn.model_class.name.underscore
128
151
  klass = Class.new(BaseTool)
129
- klass.tool_name "#{resource.tr('/', '_')}_#{defn.name}"
152
+ klass.tool_name Actions.tool_name(defn)
130
153
  klass.description defn.description
131
154
  klass.annotations(read_only_hint: false, destructive_hint: defn.destructive, open_world_hint: true)
132
155
  klass.requires_scope defn.scope
@@ -135,42 +158,61 @@ module Administrate
135
158
  klass
136
159
  end
137
160
 
161
+ private
162
+
138
163
  def schema_for(defn, resource)
139
- properties = { id: { type: 'string', description: "ID or slug of the #{resource}" } }
140
- defn.params.each { |name, spec| properties[name] = spec }
141
- { properties:, required: %w[id] + defn.required.map(&:to_s), additionalProperties: false }
164
+ properties = defn.params.merge(Uploads::Declarations.param_schemas(defn))
165
+ required = defn.required.map(&:to_s)
166
+ if defn.on == :member
167
+ properties = { id: { type: 'string', description: "ID or slug of the #{resource}" }, **properties }
168
+ required = %w[id] + required
169
+ end
170
+ { properties:, required:, additionalProperties: false }
171
+ end
172
+
173
+ def define_behaviors(klass, defn)
174
+ klass.define_singleton_method(:definition) { defn }
175
+ klass.define_singleton_method(:check_roles!) do |admin, id: nil, **|
176
+ ToolFactory.send(:authorize!, defn, admin, id)
177
+ end
178
+ klass.define_singleton_method(:execute) do |admin:, id: nil, **params|
179
+ outcome = ToolFactory.send(:invoke, defn, admin, id, params)
180
+ outcome.key?(:error) ? error_response(outcome[:error]) : json_response(outcome[:data])
181
+ end
142
182
  end
143
183
 
144
- # Replaces the role-based default with instance-level authorization on the loaded record.
145
184
  def authorize!(defn, admin, id)
146
- record = Actions.find_record!(defn.model_class, id)
147
- Actions.authorize!(admin, record, defn.predicate)
185
+ target = defn.on == :member ? Actions.find_record!(defn.model_class, id) : defn.model_class.new
186
+ Actions.authorize!(admin, target, defn.predicate)
148
187
  end
149
188
 
150
189
  def invoke(defn, admin, id, params)
151
- record = Actions.find_record!(defn.model_class, id)
152
- result = defn.invoke.call(record:, admin:, params:)
153
- return { error: result.try(:error) || "#{defn.name} failed" } if failed?(result)
190
+ declared = params.slice(*defn.params.keys, *Uploads::Declarations.param_schemas(defn).keys)
191
+ arguments = { admin:, params: Uploads::Declarations.resolve_params(defn, admin, declared) }
192
+ 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)
154
195
 
155
- { data: { status: 'ok', model: defn.model_class.name.underscore, id:, action: defn.name.to_s } }
196
+ { data: success_data(defn, id, result) }
156
197
  end
157
198
 
158
- private
159
-
160
- def define_behaviors(klass, defn)
161
- klass.define_singleton_method(:definition) { defn }
162
- klass.define_singleton_method(:check_roles!) do |admin, id: nil, **|
163
- ToolFactory.authorize!(defn, admin, id)
164
- end
165
- klass.define_singleton_method(:execute) do |admin:, id:, **params|
166
- outcome = ToolFactory.invoke(defn, admin, id, params)
167
- outcome.key?(:error) ? error_response(outcome[:error]) : json_response(outcome[:data])
168
- end
199
+ def success_data(defn, id, result)
200
+ 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
169
202
  end
170
203
 
171
204
  def failed?(result)
205
+ return true if result == false
206
+ return result.new_record? || result.errors.any? if result.is_a?(ActiveRecord::Base)
207
+
172
208
  result.respond_to?(:success?) && !result.success?
173
209
  end
210
+
211
+ def failure_message(defn, result, record)
212
+ invalid = result.is_a?(ActiveRecord::Base) ? result : (record if result == false)
213
+ message = invalid ? invalid.errors.full_messages.to_sentence : result.try(:error)
214
+ message.presence || "#{defn.name} failed"
215
+ end
174
216
  end
175
217
  end
176
218
  end
@@ -55,7 +55,11 @@ module Administrate
55
55
  check_roles!(admin, **args)
56
56
  audit!(admin, args, server_context)
57
57
  reject_reserved_arguments!(args)
58
- execute(**args, admin:)
58
+ execute(**args, admin:, **execute_context(server_context))
59
+ end
60
+
61
+ def execute_context(_server_context)
62
+ {}
59
63
  end
60
64
 
61
65
  def reject_reserved_arguments!(args)
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Administrate
4
+ module MCP
5
+ # Removes upload batches that expired before the given cutoff, one batch at a time. The caller
6
+ # decides whether to run the next batch: `call` reports whether the batch was full. The
7
+ # uploaded objects are left to the bucket's own lifecycle rule.
8
+ class CleanExpiredUploadBatches
9
+ BATCH_SIZE = 10_000
10
+
11
+ Result = Struct.new(:deleted_count, :more?, keyword_init: true)
12
+
13
+ def self.call(...)
14
+ new(...).call
15
+ end
16
+
17
+ def initialize(till: Time.current)
18
+ @till = till
19
+ end
20
+
21
+ def call
22
+ deleted_count = UploadBatch.expired(@till).limit(BATCH_SIZE).delete_all
23
+ Result.new(deleted_count:, more?: deleted_count == BATCH_SIZE)
24
+ end
25
+ end
26
+ end
27
+ end
@@ -119,6 +119,10 @@ module Administrate
119
119
  matches?(field_class, Administrate::MCP.config.has_many_field_classes)
120
120
  end
121
121
 
122
+ def serializer_for(field_spec)
123
+ lookup_serializer(resolve_field_class(field_spec))
124
+ end
125
+
122
126
  def resolve_field_class(field_spec)
123
127
  case field_spec
124
128
  when Administrate::Field::Deferred
@@ -16,6 +16,8 @@ module Administrate
16
16
 
17
17
  SIDEKIQ_STATS_TOOL = 'Administrate::MCP::Tools::SidekiqStats'
18
18
 
19
+ UPLOADS_PREPARE_TOOL = 'Administrate::MCP::Tools::UploadsPrepare'
20
+
19
21
  class << self
20
22
  def build(server_context:)
21
23
  server_context[:authorization_errors] ||= []
@@ -46,12 +48,16 @@ module Administrate
46
48
 
47
49
  def discover_tools(server_context)
48
50
  load_host_tools
49
- (built_in_tools + host_tools).uniq + Actions.tools_for(server_context)
51
+ action_tools = Actions.tools_for(server_context)
52
+ built_in = built_in_tools
53
+ built_in -= [Tools::UploadsPrepare] unless Uploads::Declarations.any?(action_tools)
54
+ (built_in + host_tools).uniq + action_tools
50
55
  end
51
56
 
52
57
  def built_in_tools
53
58
  names = BUILT_IN_TOOLS.dup
54
59
  names << REPORT_IMPROVEMENT_TOOL if Administrate::MCP.config.feedback_tool
60
+ names << UPLOADS_PREPARE_TOOL if Administrate::MCP.config.uploads.enabled
55
61
  names << SIDEKIQ_RETRIES_TOOL if defined?(::Sidekiq)
56
62
  names << SIDEKIQ_STATS_TOOL if defined?(::Sidekiq) && Administrate::MCP.config.sidekiq_stats_provider
57
63
  names.filter_map(&:safe_constantize)
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Administrate
4
+ module MCP
5
+ module Tools
6
+ # Hands out presigned upload URLs for one attachment attribute of one action, so a client can
7
+ # upload a file and then pass its upload_id to that action. Published only when uploads are
8
+ # enabled and the caller has an action that accepts uploads.
9
+ class UploadsPrepare < BaseTool
10
+ tool_name 'uploads_prepare'
11
+ requires_scope :write
12
+ description 'Prepare file uploads for an action that accepts them. Pass the action\'s `tool` name, the ' \
13
+ '`attribute` the files are for (the action takes `<attribute>_upload_id`), and `files` as an ' \
14
+ 'object of filename to size in bytes. Each file gets an `upload_id`, a `method`, a `url` and ' \
15
+ 'either `fields` or `headers`. For `POST`, send a multipart form to `url` with every entry of ' \
16
+ '`fields` followed by the file as `file`, for example ' \
17
+ '`curl -F key=... -F policy=... (one -F per field) -F file=@logo.png "<url>"`. For `PUT`, ' \
18
+ 'send the file as the body with every header in `headers`. Then pass the `upload_id` as ' \
19
+ '`<attribute>_upload_id` to that action. URLs expire after 15 minutes by default and upload ' \
20
+ 'ids after an hour.'
21
+ annotations(read_only_hint: false, destructive_hint: false, open_world_hint: true)
22
+
23
+ input_schema(
24
+ properties: {
25
+ tool: { type: 'string', description: 'The action tool the files are for, for example widget_update.' },
26
+ attribute: { type: 'string', description: 'The attachment attribute, for example picture.' },
27
+ files: {
28
+ type: 'object',
29
+ description: 'Filename to size in bytes, for example {"logo.png": 48213}.',
30
+ additionalProperties: { type: 'integer' }
31
+ }
32
+ },
33
+ required: %w[tool attribute files],
34
+ additionalProperties: false
35
+ )
36
+
37
+ def self.execute(admin:, scopes:, tool:, attribute:, files:)
38
+ json_response(Administrate::MCP::Uploads.prepare(admin:, scopes:, tool:, attribute:, files:))
39
+ end
40
+
41
+ def self.execute_context(server_context)
42
+ { scopes: server_context[:scopes] || [] }
43
+ end
44
+ private_class_method :execute_context
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Administrate
4
+ module MCP
5
+ module Uploads
6
+ # How an `mcp_action` declares uploads: which attachment attributes take an
7
+ # `<attribute>_upload_id`, the schema of those params, and turning them into attachment
8
+ # values before the action's block runs.
9
+ module Declarations
10
+ RULE_KEYS = %i[content_types max_size].freeze
11
+
12
+ class << self
13
+ def normalize(uploads, action_name:)
14
+ return true if uploads == true
15
+ return {} unless uploads
16
+ return Array(uploads).to_h { |attribute| [attribute.to_sym, {}] } unless uploads.is_a?(Hash)
17
+
18
+ uploads.to_h do |attribute, rules|
19
+ [attribute.to_sym, validate_rules!("#{action_name}: uploads: #{attribute}", rules)]
20
+ end
21
+ end
22
+
23
+ def any?(action_tools)
24
+ config.enabled && action_tools.any? { |tool| tool.definition.uploads.any? }
25
+ end
26
+
27
+ def action_attributes(dashboard_class, action_name, declared)
28
+ return {} if declared.blank? || !config.enabled
29
+
30
+ attachments = attachment_attributes(dashboard_class)
31
+ if declared == true
32
+ return attachments.index_with({}) if attachments.any?
33
+
34
+ raise ArgumentError, "#{dashboard_class.name} #{action_name}: uploads: true, but the dashboard has " \
35
+ 'no attachment attribute'
36
+ end
37
+
38
+ unknown = declared.keys - attachments
39
+ return declared if unknown.empty?
40
+
41
+ raise ArgumentError, "#{dashboard_class.name} #{action_name}: uploads: names #{unknown.join(', ')}, " \
42
+ 'which are not attachment attributes'
43
+ end
44
+
45
+ def param_schemas(definition)
46
+ definition.uploads.keys.to_h do |attribute|
47
+ [param_name(attribute),
48
+ { type: 'string', description: "An upload_id from uploads_prepare, to set #{attribute} to that file." }]
49
+ end
50
+ end
51
+
52
+ def resolve_params(definition, admin, params)
53
+ tool = Actions.tool_name(definition)
54
+ definition.uploads.each_key.with_object(params.dup) do |attribute, resolved|
55
+ upload_id = resolved.delete(param_name(attribute))
56
+ next if upload_id.blank?
57
+
58
+ resolved[attribute] = Uploads.resolve(admin:, upload_id:, tool:, attribute: attribute.to_s,
59
+ rules: Uploads.rules_for(definition, attribute))
60
+ end
61
+ end
62
+
63
+ private
64
+
65
+ def validate_rules!(context, rules)
66
+ raise ArgumentError, "#{context} must be a hash of rules" unless rules.is_a?(Hash)
67
+
68
+ rules = rules.symbolize_keys
69
+ unknown = rules.keys - RULE_KEYS
70
+ raise ArgumentError, "#{context}: unknown rule(s) #{unknown.join(', ')}" if unknown.any?
71
+ unless rules[:content_types].nil? || content_types?(rules[:content_types])
72
+ raise ArgumentError, "#{context}: content_types: must be a hash of extension to content type, " \
73
+ "such as { '.png' => 'image/png' }"
74
+ end
75
+ return rules if rules[:max_size].nil? || positive_integer?(rules[:max_size])
76
+
77
+ raise ArgumentError, "#{context}: max_size: must be a positive number of bytes"
78
+ end
79
+
80
+ def positive_integer?(value)
81
+ value.is_a?(Integer) && value.positive?
82
+ end
83
+
84
+ def content_types?(content_types)
85
+ content_types.is_a?(Hash) && content_types.any? &&
86
+ content_types.all? { |extension, type| extension.to_s.match?(/\A\.\w+\z/) && type.is_a?(String) }
87
+ end
88
+
89
+ def param_name(attribute)
90
+ :"#{attribute}_upload_id"
91
+ end
92
+
93
+ def attachment_attributes(dashboard_class)
94
+ dashboard_class.new.attribute_types.filter_map do |attribute, field_spec|
95
+ attribute if FieldSerializer.serializer_for(field_spec) == :attachment
96
+ end
97
+ end
98
+
99
+ def config
100
+ Administrate::MCP.config.uploads
101
+ end
102
+ end
103
+ end
104
+ end
105
+ end
106
+ end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Administrate
4
+ module MCP
5
+ module Uploads
6
+ # The default upload storage, on a Shrine storage (`:cache` by default). It presigns a POST
7
+ # whose policy bounds the content length, so the storage itself refuses a file above the
8
+ # maximum size. Fetching copies the uploaded object to a key no URL points to, so what is
9
+ # checked and attached cannot be replaced afterwards; on S3 the copy happens server side.
10
+ # Metadata comes from the uploader's own extraction, so the host's `determine_mime_type` and
11
+ # `store_dimensions` plugins apply, and runs only once the size is known to be acceptable.
12
+ class ShrineStorage
13
+ Upload = Data.define(:mime_type, :size, :value)
14
+
15
+ def initialize(uploader: 'Shrine', storage_key: :cache)
16
+ @uploader = uploader
17
+ @storage_key = storage_key.to_sym
18
+ end
19
+
20
+ def presignable?
21
+ storage.respond_to?(:presign)
22
+ end
23
+
24
+ def description
25
+ "Shrine storage :#{@storage_key} (#{storage.class.name})"
26
+ end
27
+
28
+ def presign(key, content_type:, max_size:, expires_in:)
29
+ presigned = storage.presign(key, method: :post, content_type:, content_length_range: 1..max_size,
30
+ signature_expiration: expires_in.seconds.from_now)
31
+ { method: 'POST', url: presigned[:url], fields: presigned[:fields] }
32
+ end
33
+
34
+ def fetch(key, filename:, max_size:)
35
+ original = uploaded_file(key, filename)
36
+ return unless original.exists?
37
+
38
+ copy = uploader.upload(original, location: "#{SecureRandom.uuid}#{File.extname(key)}")
39
+ copy.open do
40
+ next Upload.new(mime_type: nil, size: copy.size, value: copy.data) if copy.size.to_i > max_size
41
+
42
+ describe(copy, filename)
43
+ end
44
+ end
45
+
46
+ def delete(value)
47
+ uploader_class.uploaded_file(value).delete
48
+ end
49
+
50
+ private
51
+
52
+ def describe(file, filename)
53
+ metadata = file.metadata.merge(uploader.extract_metadata(file).compact, 'filename' => filename)
54
+ Upload.new(mime_type: metadata['mime_type'], size: metadata['size'],
55
+ value: file.data.merge('metadata' => metadata))
56
+ end
57
+
58
+ def uploaded_file(key, filename)
59
+ uploader_class.uploaded_file('storage' => @storage_key.to_s, 'id' => key,
60
+ 'metadata' => { 'filename' => filename })
61
+ end
62
+
63
+ def uploader
64
+ uploader_class.new(@storage_key)
65
+ end
66
+
67
+ def uploader_class
68
+ @uploader.is_a?(Class) ? @uploader : @uploader.to_s.constantize
69
+ end
70
+
71
+ def storage
72
+ uploader_class.storages.fetch(@storage_key)
73
+ end
74
+ end
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,135 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Administrate
4
+ module MCP
5
+ # Presigned uploads. `prepare` checks that the caller may use the target action, records a
6
+ # batch, and hands out one presigned upload per file. `resolve` turns an upload_id back into an
7
+ # attachment value for that same action and attribute: it copies the uploaded object to a key
8
+ # the client cannot write to, checks the copy's real size and content type, and remembers the
9
+ # copy so a retried action gets the same file.
10
+ module Uploads
11
+ class << self
12
+ def prepare(admin:, scopes:, tool:, attribute:, files:)
13
+ definition = upload_action!(admin, scopes, tool.to_s, attribute.to_sym)
14
+ rules = rules_for(definition, attribute.to_sym)
15
+ storage = presignable_storage!
16
+ entries = build_entries(files, rules, tool: tool.to_s, attribute: attribute.to_s)
17
+ UploadBatch.create!(admin:, expires_at: config.batch_ttl.seconds.from_now, files: entries)
18
+
19
+ entries.to_h do |entry|
20
+ presigned = storage.presign(entry[:key], content_type: entry[:content_type], max_size: rules.max_size,
21
+ expires_in: config.url_ttl.to_i)
22
+ [entry[:filename], { upload_id: entry[:upload_id], **presigned }]
23
+ end
24
+ end
25
+
26
+ def resolve(admin:, upload_id:, tool:, attribute:, rules:)
27
+ batch, entry = find_entry!(admin, upload_id.to_s, tool, attribute)
28
+ return entry['value'] if entry['value']
29
+
30
+ record_value(batch, upload_id.to_s, fetch!(upload_id, entry, rules).value)
31
+ end
32
+
33
+ def rules_for(definition, attribute)
34
+ config.rules(**definition.uploads.fetch(attribute))
35
+ end
36
+
37
+ private
38
+
39
+ def config
40
+ Administrate::MCP.config.uploads
41
+ end
42
+
43
+ def upload_action!(admin, scopes, tool, attribute)
44
+ definition = Actions.definitions.find { |candidate| Actions.tool_name(candidate) == tool }
45
+ return definition if definition&.uploads&.key?(attribute) && Actions.available_to?(admin, definition, scopes:)
46
+
47
+ raise InvalidArgumentError, "No action #{tool} that accepts an upload for #{attribute} is available to you"
48
+ end
49
+
50
+ def presignable_storage!
51
+ storage = config.storage
52
+ raise ConfigurationError, 'No upload storage is configured; set config.uploads.storage' unless storage
53
+ return storage if storage.presignable?
54
+
55
+ raise ConfigurationError, "#{storage.description} cannot presign uploads. Configure a storage that " \
56
+ 'can, such as Shrine::Storage::S3, or set config.uploads.storage'
57
+ end
58
+
59
+ def build_entries(files, rules, **target)
60
+ raise InvalidArgumentError, 'files must name at least one file' if files.blank?
61
+ if files.size > config.max_files
62
+ raise InvalidArgumentError, "files can name at most #{config.max_files} files"
63
+ end
64
+
65
+ errors = files.flat_map { |filename, size| entry_errors(filename.to_s, size, rules) }
66
+ raise InvalidArgumentError, errors.join('; ') if errors.any?
67
+
68
+ files.map { |filename, size| build_entry(filename.to_s, Integer(size), rules, **target) }
69
+ end
70
+
71
+ def entry_errors(filename, size, rules)
72
+ errors = []
73
+ unless rules.content_type_for(filename)
74
+ errors << "#{filename} is not an allowed file type (#{rules.content_types.keys.join(', ')})"
75
+ end
76
+ unless Integer(size, exception: false)&.between?(1, rules.max_size)
77
+ errors << "#{filename} size must be between 1 and #{rules.max_size} bytes"
78
+ end
79
+ errors
80
+ end
81
+
82
+ def build_entry(filename, size, rules, tool:, attribute:)
83
+ upload_id = SecureRandom.uuid
84
+ extension = File.extname(filename).downcase
85
+ { upload_id:, key: "#{upload_id}#{extension}", filename:, content_type: rules.content_type_for(filename),
86
+ size:, tool:, attribute: }
87
+ end
88
+
89
+ def find_entry!(admin, upload_id, tool, attribute)
90
+ batch = UploadBatch.with_upload(upload_id).find_by(admin:)
91
+ raise InvalidArgumentError, "Upload #{upload_id} not found" unless batch
92
+ raise InvalidArgumentError, "Upload #{upload_id} expired at #{batch.expires_at.iso8601}" if batch.expired?
93
+
94
+ entry = batch.file(upload_id)
95
+ unless entry['tool'] == tool && entry['attribute'] == attribute
96
+ raise InvalidArgumentError, "Upload #{upload_id} was prepared for #{entry['tool']} #{entry['attribute']}"
97
+ end
98
+
99
+ [batch, entry]
100
+ end
101
+
102
+ def fetch!(upload_id, entry, rules)
103
+ upload = config.storage.fetch(entry['key'], filename: entry['filename'], max_size: rules.max_size)
104
+ raise InvalidArgumentError, "Upload #{upload_id} has not been uploaded yet" unless upload
105
+
106
+ error = content_error(upload_id, upload, rules)
107
+ return upload unless error
108
+
109
+ config.storage.delete(upload.value)
110
+ raise InvalidArgumentError, error
111
+ end
112
+
113
+ def record_value(batch, upload_id, value)
114
+ UploadBatch.transaction do
115
+ locked = UploadBatch.lock.find(batch.id)
116
+ recorded = locked.file(upload_id)['value']
117
+ next locked.record_value!(upload_id, value) && value unless recorded
118
+
119
+ config.storage.delete(value)
120
+ recorded
121
+ end
122
+ end
123
+
124
+ def content_error(upload_id, upload, rules)
125
+ if upload.size.to_i > rules.max_size
126
+ "Upload #{upload_id} is #{upload.size} bytes, the maximum is #{rules.max_size}"
127
+ elsif !rules.allowed_content_type?(upload.mime_type)
128
+ "Upload #{upload_id} is #{upload.mime_type || 'of an unknown type'}, " \
129
+ "expected one of #{rules.content_types.values.uniq.join(', ')}"
130
+ end
131
+ end
132
+ end
133
+ end
134
+ end
135
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Administrate
4
+ module MCP
5
+ # The files an admin was handed presigned upload URLs for. Each entry of `files` holds the
6
+ # upload_id given to the client, the storage key, the declared filename, content type and size,
7
+ # the action tool and attribute it was prepared for, and, once resolved, the checked value.
8
+ class UploadBatch < ApplicationRecord
9
+ self.table_name = 'administrate_mcp_upload_batches'
10
+
11
+ belongs_to_admin
12
+
13
+ scope :with_upload, ->(upload_id) { where('files @> ?', [{ upload_id: }].to_json) }
14
+ scope :expired, ->(at = Time.current) { where(expires_at: ...at) }
15
+
16
+ def expired?
17
+ expires_at <= Time.current
18
+ end
19
+
20
+ def file(upload_id)
21
+ files.find { |entry| entry['upload_id'] == upload_id }
22
+ end
23
+
24
+ def record_value!(upload_id, value)
25
+ update!(files: files.map { |entry| entry['upload_id'] == upload_id ? entry.merge('value' => value) : entry })
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ class CreateAdministrateModelContextProtocolUploadBatches < ActiveRecord::Migration[8.1]
4
+ def change
5
+ admin_foreign_key = Administrate::MCP.config.admin_foreign_key
6
+
7
+ create_table :administrate_mcp_upload_batches, id: :uuid, if_not_exists: true do |t|
8
+ t.uuid admin_foreign_key, null: false
9
+ t.datetime :expires_at, null: false
10
+ t.jsonb :files, null: false, default: []
11
+
12
+ t.timestamps
13
+ end
14
+
15
+ add_index :administrate_mcp_upload_batches, admin_foreign_key, if_not_exists: true
16
+ add_index :administrate_mcp_upload_batches, :files, using: :gin, opclass: :jsonb_path_ops, if_not_exists: true
17
+ end
18
+ end
@@ -202,6 +202,7 @@ end
202
202
  | `skipped_field_classes` | Password | Field class names never serialized |
203
203
  | `has_many_field_classes` | HasMany | Field class names treated as expandable collections |
204
204
  | `field_serializers` | see above | Field class name to serializer |
205
+ | `uploads` | disabled | Presigned uploads: `enabled`, `storage`, `allowed_content_types`, `max_size`, `max_files`, `url_ttl`, `batch_ttl`. See [Presigned uploads](uploads.md) |
205
206
 
206
207
  ## Authorization adapters
207
208
 
data/docs/dashboards.md CHANGED
@@ -17,6 +17,14 @@ class OrderDashboard < Administrate::BaseDashboard
17
17
  params: { reason: { type: 'string', description: 'Why the order is being refunded.' } } do |record:, admin:, params:|
18
18
  Orders::Refund.call!(order: record, admin:, reason: params[:reason])
19
19
  end
20
+
21
+ mcp_action :import,
22
+ on: :collection,
23
+ destructive: false,
24
+ description: 'Import an order from a marketplace.',
25
+ params: { reference: 'The marketplace order reference.' } do |admin:, params:|
26
+ Orders::Import.call!(reference: params[:reference], admin:).order
27
+ end
20
28
  end
21
29
  ```
22
30
 
@@ -33,10 +41,36 @@ base class, is not applied to every resource. A subclass that wants one declares
33
41
  `scoped_resource`.
34
42
  - `COLLECTION_FILTERS` are published as filters; a one-argument lambda is a boolean filter, a
35
43
  two-argument one takes a value. Foreign key columns are filterable without being declared.
36
- - `mcp_action` publishes one tool per declaration. It requires the OAuth `write` scope, and the
37
- authorization predicate (`refund?` by default, or `predicate:`) on the loaded record. The block
38
- receives `record:`, `admin:` and `params:`; a result answering `success? == false` is reported as
39
- an error.
44
+ - `mcp_action` publishes one tool per declaration, named `<resource>_<action>` (`order_refund`). It
45
+ requires the OAuth `write` scope, and the authorization predicate (`refund?` for `:refund` by
46
+ default, or `predicate:`, also spelled `pundit:`), checked through `config.authorization`. Every
47
+ call is reported to `config.on_tool_call`.
48
+ - The input schema lists `params` and closes the object (`additionalProperties: false`), so an
49
+ argument that is not declared is refused before the action runs, and the block only ever sees
50
+ declared params.
51
+ - The block's result decides the answer. A result answering `success? == false` is an error, with
52
+ the result's `error` as the message when it has one. An Active Record object that was never
53
+ saved or has errors is an error too, with its validation messages. So is `false`, the result of
54
+ a failed `update` or of a `destroy` a callback aborted; for a member action the message is the
55
+ loaded record's validation messages when it has any. Anything else is a success, including a
56
+ record the block destroyed.
57
+ - The tool is announced as destructive unless the declaration says `destructive: false`. Clients
58
+ may ask for confirmation before running a destructive tool, so set it on actions that only add
59
+ or prepare something.
60
+ - A member action (`on: :member`, the default) takes an `id` (or slug), loads the record and checks
61
+ the predicate against it. The block receives `record:`, `admin:` and `params:`. The response
62
+ echoes the `id` the caller passed.
63
+ - A collection action (`on: :collection`) takes no `id` and loads nothing, so it suits creating a
64
+ record or preparing something that is not a record yet. The predicate is checked against a new
65
+ instance of the model, as Administrate does for `new` and `create`, both when the tool list is
66
+ built and on every call; a creating action usually declares `predicate: :create?`. The block
67
+ receives `admin:` and `params:`, and owns any scoping of what it creates, such as a tenant,
68
+ since no record lookup does it. The response carries the primary key of the record the block
69
+ returns, so return the created record for the client to act on it; a block that returns
70
+ something else gets a response without `id`.
71
+ - `uploads:` (`true`, a list of attachment attributes, or per-attribute `content_types:` and
72
+ `max_size:`) adds an optional `<attribute>_upload_id` param for each of them, and the block
73
+ receives the uploaded file as `params[:<attribute>]`. See [Presigned uploads](uploads.md).
40
74
 
41
75
  ## Search
42
76
 
data/docs/development.md CHANGED
@@ -13,7 +13,10 @@ spec helper catches `ActiveRecord::NoDatabaseError` and calls `ActiveRecord::Tas
13
13
  so no separate `createdb` step is needed. Point the specs at another Postgres with
14
14
  `ADMINISTRATE_MCP_DB_HOST`, `_PORT`, `_USER`, `_PASSWORD` and `_NAME`.
15
15
 
16
- The dummy application under `spec/dummy` has an `Admin`, a `Widget` (with `MCP_BASE_SCOPE` and
17
- collection filters) and a `Gadget` (with a `belongs_to` and an `mcp_action`).
16
+ The dummy application under `spec/dummy` has an `Admin`, a `Widget` (with `MCP_BASE_SCOPE`,
17
+ collection filters, a Shrine `picture` attachment shown through a host-style `PictureField`, and a
18
+ collection `mcp_action` that accepts uploads) and a `Gadget` (with a `belongs_to` and a member
19
+ `mcp_action`). Upload specs use the `with presignable storage` shared context, which swaps the Shrine
20
+ cache storage for an S3 storage with a stubbed client.
18
21
 
19
22
  See [CONTRIBUTING.md](../CONTRIBUTING.md) for how to propose a change.
data/docs/uploads.md ADDED
@@ -0,0 +1,157 @@
1
+ # Presigned uploads
2
+
3
+ [Back to README](../README.md)
4
+
5
+ A client such as Claude Code can upload a file and set it on an attachment attribute through an
6
+ action, without the file passing through the MCP server:
7
+
8
+ 1. The client calls `uploads_prepare` with the action's `tool` name, the `attribute` and `files`,
9
+ an object of filename to size in bytes. Each file gets back an `upload_id`, a `method`, a `url`
10
+ and the `fields` (for a `POST`) or `headers` (for a `PUT`) to send.
11
+ 2. The client uploads each file. With the default storage that is a multipart `POST` carrying every
12
+ field, then the file:
13
+ `curl -F key=... -F policy=... -F file=@logo.png "<url>"`.
14
+ 3. The client calls the action with `<attribute>_upload_id`. The engine resolves it into the
15
+ attachment value, and the action's block receives it as `params[:<attribute>]`.
16
+
17
+ ```ruby
18
+ class ArticleDashboard < Administrate::BaseDashboard
19
+ ATTRIBUTE_TYPES = { title: Field::String, cover: Field::Shrine, contract: Field::Shrine }.freeze
20
+
21
+ mcp_action :publish, on: :collection, predicate: :create?, destructive: false,
22
+ uploads: {
23
+ cover: {},
24
+ contract: { content_types: { '.pdf' => 'application/pdf' }, max_size: 5 * 1024 * 1024 }
25
+ },
26
+ description: 'Publish an article.',
27
+ params: { title: 'The title.' } do |admin:, params:|
28
+ Articles::Publish.call(admin:, **params.slice(:title, :cover, :contract)).article
29
+ end
30
+ end
31
+ ```
32
+
33
+ `uploads:` takes:
34
+
35
+ - `true`, for every attribute whose field is serialized as `:attachment`:
36
+ `Administrate::Field::Shrine`, or any field class registered with
37
+ `register_field(..., as: :attachment)`. It raises when the dashboard has none.
38
+ - a list of attributes, such as `%i[cover]`.
39
+ - a hash of attribute to `content_types:` (extension to content type, like
40
+ `allowed_content_types`) and `max_size:`, which override the defaults for that attribute.
41
+
42
+ An attribute that is not an attachment raises when the tools are built. So does a rule other than
43
+ `content_types:` and `max_size:`, a `content_types:` that is not a hash of `.ext` keys, or a
44
+ `max_size:` that is not a positive integer. A call without the param leaves `params[:<attribute>]`
45
+ unset.
46
+
47
+ Out of the box, uploads work with Shrine: `Administrate::Field::Shrine` is the only field mapped to
48
+ `:attachment` by default, and the default storage is a Shrine storage. An Active Storage dashboard
49
+ gets no upload param until its field is registered as `:attachment` and `config.uploads.storage` is
50
+ set to a storage for it.
51
+
52
+ ## Enabling
53
+
54
+ ```ruby
55
+ Administrate::MCP.configure do |c|
56
+ c.uploads.enabled = true
57
+ end
58
+ ```
59
+
60
+ Uploads need the `administrate_mcp_upload_batches` table, created by the engine's migrations
61
+ (`bin/rails administrate_mcp:install:migrations`). While `enabled` is `false`, the default,
62
+ `uploads_prepare` is not published, actions publish no upload param, and the table is never used.
63
+
64
+ | Setting | Default | Meaning |
65
+ | ----------------------- | --------------------------- | -------------------------------------------------------------------- |
66
+ | `enabled` | `false` | Publish `uploads_prepare` and the upload params |
67
+ | `storage` | the Shrine `:cache` storage | Where files are uploaded; see below |
68
+ | `allowed_content_types` | png, jpg, jpeg, webp | Default file extension (with the dot) to content type |
69
+ | `max_size` | 10 MB | Default largest file in bytes |
70
+ | `max_files` | 10 | Most files one `uploads_prepare` call can prepare |
71
+ | `url_ttl` | 15 minutes | How long a presigned URL is valid, in seconds |
72
+ | `batch_ttl` | 1 hour | How long an `upload_id` can be resolved, in seconds |
73
+
74
+ Do not allow `image/svg+xml`, `text/html` or another type a browser runs. The object is served from
75
+ the bucket with that type, so such a file is stored cross-site scripting for anyone who opens it.
76
+
77
+ ## Checks
78
+
79
+ `uploads_prepare` requires the OAuth `write` scope and the `default_required_roles`, and is only
80
+ published to a caller who can use an action that accepts uploads. It refuses the call unless the
81
+ named action exists, accepts an upload for the named attribute, and is available to the caller: its
82
+ scope is granted and its predicate passes, the same check that decides whether the action is
83
+ listed. It also refuses a file whose extension
84
+ is not allowed for that attribute, a declared size above its maximum, and more than `max_files`
85
+ files, and records nothing in that case.
86
+
87
+ Resolving an `upload_id` checks that:
88
+
89
+ - it was handed out to the calling admin. Another admin's `upload_id` is answered as not found;
90
+ - it was prepared for this action and attribute;
91
+ - its batch has not expired;
92
+ - the file was uploaded;
93
+ - the file's real size is at most the maximum. The default storage's presigned `POST` already makes
94
+ the storage refuse a larger file; the size is read again before any content is;
95
+ - the file's real content type, read from its content, is allowed, whatever its extension.
96
+
97
+ The checks run on a copy of the uploaded object, under a key no presigned URL points to, and the
98
+ action receives that copy. Uploading again to the same URL afterwards changes nothing that was
99
+ checked. A copy that fails a check is deleted. The checked copy is remembered: passing the same
100
+ `upload_id` again, for example after the action failed on another param, returns it without a new
101
+ upload. The storage is read outside any database transaction; only recording the checked copy
102
+ locks the batch row, and if two calls race, the first recorded copy wins and the other is deleted.
103
+
104
+ A failed check is a tool error and the action's block does not run.
105
+
106
+ ## Clean-up
107
+
108
+ Expired batches stay in the table until removed. Call `Administrate::MCP::CleanExpiredUploadBatches`
109
+ from your scheduler; it deletes up to 10,000 expired batches per call and reports `more?` when there
110
+ may be others:
111
+
112
+ ```ruby
113
+ loop { break unless Administrate::MCP::CleanExpiredUploadBatches.call.more? }
114
+ ```
115
+
116
+ The uploaded objects and their checked copies are not deleted by the engine. Give the bucket a
117
+ lifecycle rule that expires the cache prefix after a period longer than `batch_ttl`. Shrine copies
118
+ the file from the cache to its permanent storage when it promotes it.
119
+
120
+ ## Storage
121
+
122
+ When Shrine is loaded, the default storage is `Administrate::MCP::Uploads::ShrineStorage`, which
123
+ presigns a `POST` on `Shrine.storages[:cache]` with a policy bounding the content length, and copies
124
+ an uploaded object to a fresh key before checking it. On S3 the copy is server side.
125
+
126
+ - The storage must be able to presign: `Shrine::Storage::S3` can, `FileSystem` and `Memory`
127
+ cannot, and `uploads_prepare` answers with an error naming the storage.
128
+ - The content type comes from the uploader's own metadata extraction, so load Shrine's
129
+ `determine_mime_type` plugin. Without it the type is unknown and every upload is refused.
130
+ Plugins such as `store_dimensions` add their metadata too.
131
+ - Pass a different uploader or storage with
132
+ `c.uploads.storage = Administrate::MCP::Uploads::ShrineStorage.new(uploader: 'ImageUploader', storage_key: :cache)`.
133
+ The uploader is a class or a class name.
134
+
135
+ The gem does not depend on Shrine or the AWS SDK. A host without Shrine sets `storage` to an object
136
+ answering:
137
+
138
+ ```ruby
139
+ class MyStorage
140
+ def presignable? = true
141
+ def description = 'my storage'
142
+
143
+ # => { method: 'POST', url: 'https://...', fields: { ... } }
144
+ # or { method: 'PUT', url: 'https://...', headers: { 'Content-Type' => content_type } }
145
+ # The storage should refuse a file above max_size.
146
+ def presign(key, content_type:, max_size:, expires_in:); end
147
+
148
+ # => nil when nothing was uploaded under key, otherwise an object answering mime_type, size
149
+ # and value (what the action receives as params[:<attribute>]). Copy the object to a key the
150
+ # client cannot write to and describe the copy, and when it is above max_size, return its size
151
+ # without reading the content.
152
+ def fetch(key, filename:, max_size:); end
153
+
154
+ # Deletes the copy behind a value fetch returned, when it failed a check or lost a race.
155
+ def delete(value); end
156
+ end
157
+ ```
@@ -5,6 +5,7 @@ require 'administrate/mcp/version'
5
5
  require 'administrate/mcp/authorization/base'
6
6
  require 'administrate/mcp/authorization/permissive'
7
7
  require 'administrate/mcp/authorization/pundit'
8
+ require 'administrate/mcp/uploads_configuration'
8
9
 
9
10
  module Administrate
10
11
  module MCP
@@ -64,7 +65,7 @@ module Administrate
64
65
  :has_many_field_classes,
65
66
  :field_serializers
66
67
 
67
- attr_reader :api_key_token_prefix
68
+ attr_reader :api_key_token_prefix, :uploads
68
69
 
69
70
  def initialize
70
71
  assign_identity
@@ -101,6 +102,7 @@ module Administrate
101
102
  @persist_feedback = false
102
103
  @feedback_tool = true
103
104
  @sidekiq_stats_provider = nil
105
+ @uploads = UploadsConfiguration.new
104
106
  end
105
107
 
106
108
  def assign_fields
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Administrate
4
+ module MCP
5
+ # Settings for presigned uploads: the `uploads_prepare` tool and the `<attribute>_upload_id`
6
+ # params of actions declared with `uploads:`. Off until the host enables it, because it needs
7
+ # the upload batches table. `allowed_content_types` and `max_size` are defaults an action can
8
+ # override per attribute.
9
+ class UploadsConfiguration
10
+ CONTENT_TYPES = {
11
+ '.png' => 'image/png',
12
+ '.jpg' => 'image/jpeg',
13
+ '.jpeg' => 'image/jpeg',
14
+ '.webp' => 'image/webp'
15
+ }.freeze
16
+
17
+ Rules =
18
+ Data.define(:content_types, :max_size) do
19
+ def content_type_for(filename)
20
+ content_types[File.extname(filename.to_s).downcase]
21
+ end
22
+
23
+ def allowed_content_type?(content_type)
24
+ content_types.value?(content_type)
25
+ end
26
+ end
27
+
28
+ attr_accessor :enabled, :allowed_content_types, :max_size, :max_files, :url_ttl, :batch_ttl
29
+ attr_writer :storage
30
+
31
+ def initialize
32
+ @enabled = false
33
+ @storage = nil
34
+ @allowed_content_types = CONTENT_TYPES.dup
35
+ @max_size = 10 * 1024 * 1024
36
+ @max_files = 10
37
+ @url_ttl = 15 * 60
38
+ @batch_ttl = 60 * 60
39
+ end
40
+
41
+ def storage
42
+ @storage || (Uploads::ShrineStorage.new if defined?(::Shrine))
43
+ end
44
+
45
+ def rules(content_types: nil, max_size: nil)
46
+ Rules.new(content_types: (content_types || allowed_content_types).transform_keys(&:to_s),
47
+ max_size: max_size || self.max_size)
48
+ end
49
+ end
50
+ end
51
+ end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Administrate
4
4
  module MCP
5
- VERSION = '0.3.1'
5
+ VERSION = '0.4.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.3.1
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Sorare
@@ -93,6 +93,7 @@ files:
93
93
  - app/lib/administrate/mcp/admin_dashboard_tool.rb
94
94
  - app/lib/administrate/mcp/authentication.rb
95
95
  - app/lib/administrate/mcp/base_tool.rb
96
+ - app/lib/administrate/mcp/clean_expired_upload_batches.rb
96
97
  - app/lib/administrate/mcp/clean_old_feedbacks.rb
97
98
  - app/lib/administrate/mcp/dashboard_registry.rb
98
99
  - app/lib/administrate/mcp/fast_search.rb
@@ -109,17 +110,23 @@ files:
109
110
  - app/lib/administrate/mcp/tools/report_improvement.rb
110
111
  - app/lib/administrate/mcp/tools/sidekiq_retries.rb
111
112
  - app/lib/administrate/mcp/tools/sidekiq_stats.rb
113
+ - app/lib/administrate/mcp/tools/uploads_prepare.rb
114
+ - app/lib/administrate/mcp/uploads.rb
115
+ - app/lib/administrate/mcp/uploads/declarations.rb
116
+ - app/lib/administrate/mcp/uploads/shrine_storage.rb
112
117
  - app/models/administrate/mcp/api_key.rb
113
118
  - app/models/administrate/mcp/application_record.rb
114
119
  - app/models/administrate/mcp/feedback.rb
115
120
  - app/models/administrate/mcp/o_auth_access_grant.rb
116
121
  - app/models/administrate/mcp/o_auth_access_token.rb
117
122
  - app/models/administrate/mcp/o_auth_application.rb
123
+ - app/models/administrate/mcp/upload_batch.rb
118
124
  - app/views/administrate/mcp/o_auth/authorize.html.erb
119
125
  - config/routes.rb
120
126
  - db/migrate/20260101000001_create_administrate_model_context_protocol_api_keys.rb
121
127
  - db/migrate/20260101000002_create_administrate_model_context_protocol_feedbacks.rb
122
128
  - db/migrate/20260101000003_create_administrate_model_context_protocol_authorization_tables.rb
129
+ - db/migrate/20260101000004_create_administrate_model_context_protocol_upload_batches.rb
123
130
  - docs/admin-integration.md
124
131
  - docs/authentication.md
125
132
  - docs/configuration.md
@@ -127,6 +134,7 @@ files:
127
134
  - docs/development.md
128
135
  - docs/oauth.md
129
136
  - docs/routes.md
137
+ - docs/uploads.md
130
138
  - lib/administrate-mcp.rb
131
139
  - lib/administrate/mcp.rb
132
140
  - lib/administrate/mcp/authorization/base.rb
@@ -140,6 +148,7 @@ files:
140
148
  - lib/administrate/mcp/loopback_uri.rb
141
149
  - lib/administrate/mcp/rack_attack.rb
142
150
  - lib/administrate/mcp/routes.rb
151
+ - lib/administrate/mcp/uploads_configuration.rb
143
152
  - lib/administrate/mcp/version.rb
144
153
  homepage: https://github.com/sorare/administrate-mcp
145
154
  licenses: