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 +4 -4
- data/CHANGELOG.md +41 -1
- data/README.md +17 -3
- data/app/lib/administrate/mcp/actions.rb +78 -36
- data/app/lib/administrate/mcp/base_tool.rb +5 -1
- data/app/lib/administrate/mcp/clean_expired_upload_batches.rb +27 -0
- data/app/lib/administrate/mcp/field_serializer.rb +4 -0
- data/app/lib/administrate/mcp/server_builder.rb +7 -1
- data/app/lib/administrate/mcp/tools/uploads_prepare.rb +48 -0
- data/app/lib/administrate/mcp/uploads/declarations.rb +106 -0
- data/app/lib/administrate/mcp/uploads/shrine_storage.rb +77 -0
- data/app/lib/administrate/mcp/uploads.rb +135 -0
- data/app/models/administrate/mcp/upload_batch.rb +29 -0
- data/db/migrate/20260101000004_create_administrate_model_context_protocol_upload_batches.rb +18 -0
- data/docs/configuration.md +1 -0
- data/docs/dashboards.md +38 -4
- data/docs/development.md +5 -2
- data/docs/uploads.md +157 -0
- data/lib/administrate/mcp/configuration.rb +3 -1
- data/lib/administrate/mcp/uploads_configuration.rb +51 -0
- data/lib/administrate/mcp/version.rb +1 -1
- metadata +10 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: e16575edff64537e779f60e3d447d4bef99e19f46669c64eb882bd14867279a7
|
|
4
|
+
data.tar.gz: 2490fbf883f4980d3a152a5d245f27448f5729159b8944432a59c1d90dcb51d5
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
14
|
-
#
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
77
|
-
|
|
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
|
-
|
|
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
|
|
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 =
|
|
140
|
-
defn.
|
|
141
|
-
|
|
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
|
-
|
|
147
|
-
Actions.authorize!(admin,
|
|
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
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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:
|
|
196
|
+
{ data: success_data(defn, id, result) }
|
|
156
197
|
end
|
|
157
198
|
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
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
|
-
|
|
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
|
data/docs/configuration.md
CHANGED
|
@@ -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
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
|
17
|
-
collection filters
|
|
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
|
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
|
+
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:
|