solidus_mcp 0.2.0.rc1
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 +7 -0
- data/CHANGELOG.md +52 -0
- data/CONTRIBUTING.md +31 -0
- data/LICENSE +27 -0
- data/README.md +355 -0
- data/app/assets/stylesheets/solidus_mcp/admin.css +284 -0
- data/app/controllers/concerns/solidus_mcp/http_access.rb +33 -0
- data/app/controllers/solidus_admin/mcp_administrators_controller.rb +91 -0
- data/app/controllers/solidus_admin/mcp_connections_controller.rb +132 -0
- data/app/controllers/solidus_mcp/authorizations_controller.rb +40 -0
- data/app/controllers/solidus_mcp/endpoint_controller.rb +61 -0
- data/app/controllers/solidus_mcp/metadata_controller.rb +23 -0
- data/app/controllers/solidus_mcp/registrations_controller.rb +48 -0
- data/app/controllers/solidus_mcp/tokens_controller.rb +23 -0
- data/app/models/solidus_mcp/administrator.rb +35 -0
- data/app/models/solidus_mcp/administrator_change.rb +8 -0
- data/app/models/solidus_mcp/connection_policy.rb +53 -0
- data/app/models/solidus_mcp/credential.rb +36 -0
- data/app/models/solidus_mcp/policy_change.rb +9 -0
- data/app/services/solidus_mcp/client_registration.rb +94 -0
- data/app/services/solidus_mcp/price_update.rb +42 -0
- data/app/services/solidus_mcp/serializers.rb +49 -0
- data/app/tools/solidus_mcp/tools/base.rb +37 -0
- data/app/tools/solidus_mcp/tools/bulk_update_variant_prices.rb +48 -0
- data/app/tools/solidus_mcp/tools/create_product.rb +42 -0
- data/app/tools/solidus_mcp/tools/get_inventory_levels.rb +26 -0
- data/app/tools/solidus_mcp/tools/get_order.rb +29 -0
- data/app/tools/solidus_mcp/tools/get_order_status.rb +21 -0
- data/app/tools/solidus_mcp/tools/get_product.rb +21 -0
- data/app/tools/solidus_mcp/tools/get_product_variants.rb +24 -0
- data/app/tools/solidus_mcp/tools/get_variant_prices.rb +25 -0
- data/app/tools/solidus_mcp/tools/search_inventory.rb +27 -0
- data/app/tools/solidus_mcp/tools/search_orders.rb +28 -0
- data/app/tools/solidus_mcp/tools/search_products.rb +27 -0
- data/app/tools/solidus_mcp/tools/update_product.rb +35 -0
- data/app/tools/solidus_mcp/tools/update_variant_price.rb +27 -0
- data/app/views/solidus_admin/mcp_administrators/_account_search.html.erb +161 -0
- data/app/views/solidus_admin/mcp_administrators/index.html.erb +67 -0
- data/app/views/solidus_admin/mcp_connections/_permission_selection.html.erb +51 -0
- data/app/views/solidus_admin/mcp_connections/_permissions.html.erb +54 -0
- data/app/views/solidus_admin/mcp_connections/edit.html.erb +59 -0
- data/app/views/solidus_admin/mcp_connections/index.html.erb +48 -0
- data/app/views/solidus_mcp/authorizations/new.html.erb +43 -0
- data/config/locales/en.yml +4 -0
- data/config/routes.rb +34 -0
- data/db/migrate/20261023000000_create_solidus_mcp_connection_policies.rb +26 -0
- data/db/migrate/20261024000000_add_solidus_mcp_oauth_attributes.rb +17 -0
- data/db/migrate/20261024010000_add_deleted_at_to_solidus_mcp_connection_policies.rb +7 -0
- data/db/migrate/20261108000000_create_solidus_mcp_administrators.rb +21 -0
- data/docs/administration.md +121 -0
- data/lib/solidus_mcp/administration.rb +90 -0
- data/lib/solidus_mcp/configuration.rb +36 -0
- data/lib/solidus_mcp/connection_scopes.rb +25 -0
- data/lib/solidus_mcp/engine.rb +27 -0
- data/lib/solidus_mcp/oauth.rb +44 -0
- data/lib/solidus_mcp/policy.rb +48 -0
- data/lib/solidus_mcp/registry.rb +69 -0
- data/lib/solidus_mcp/server.rb +28 -0
- data/lib/solidus_mcp/tool.rb +63 -0
- data/lib/solidus_mcp/version.rb +5 -0
- data/lib/solidus_mcp.rb +57 -0
- data/lib/tasks/solidus_mcp.rake +9 -0
- metadata +189 -0
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
.solidus-mcp-admin {
|
|
2
|
+
box-sizing: border-box;
|
|
3
|
+
min-width: 0;
|
|
4
|
+
padding: 1.5rem;
|
|
5
|
+
color: #222222;
|
|
6
|
+
line-height: 1.5;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
.solidus-mcp-admin p {
|
|
10
|
+
margin: 0.75rem 0;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
.solidus-mcp-admin h1,
|
|
14
|
+
.solidus-mcp-admin h2 {
|
|
15
|
+
font-weight: 600;
|
|
16
|
+
line-height: 1.3;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
.solidus-mcp-admin .solidus-mcp-page-title {
|
|
20
|
+
margin: 0 0 0.5rem;
|
|
21
|
+
font-size: 1.5rem;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
.solidus-mcp-admin .solidus-mcp-connection-title {
|
|
25
|
+
margin: 1rem 0 0.5rem;
|
|
26
|
+
font-size: 1.25rem;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
.solidus-mcp-admin h2 {
|
|
30
|
+
margin: 0 0 0.75rem;
|
|
31
|
+
font-size: 1.125rem;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
.solidus-mcp-admin .solidus-mcp-intro {
|
|
35
|
+
margin-bottom: 1.5rem;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
.solidus-mcp-admin a {
|
|
39
|
+
color: inherit;
|
|
40
|
+
text-decoration: underline;
|
|
41
|
+
text-underline-offset: 0.15em;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
.solidus-mcp-admin .solidus-mcp-connection-toggle {
|
|
45
|
+
display: flex;
|
|
46
|
+
align-items: center;
|
|
47
|
+
gap: 0.5rem;
|
|
48
|
+
margin-bottom: 1.5rem;
|
|
49
|
+
cursor: pointer;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
.solidus-mcp-admin input[type="checkbox"] {
|
|
53
|
+
width: 1rem;
|
|
54
|
+
height: 1rem;
|
|
55
|
+
margin: 0;
|
|
56
|
+
flex-shrink: 0;
|
|
57
|
+
accent-color: #222222;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
.solidus-mcp-admin .solidus-mcp-section,
|
|
61
|
+
.solidus-mcp-admin .solidus-mcp-audit-heading {
|
|
62
|
+
margin-top: 2rem;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
.solidus-mcp-admin .solidus-mcp-audit-entry {
|
|
66
|
+
margin-bottom: 0.75rem;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
.solidus-mcp-admin summary {
|
|
70
|
+
cursor: pointer;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
.solidus-mcp-admin .solidus-mcp-button {
|
|
74
|
+
display: inline-block;
|
|
75
|
+
box-sizing: border-box;
|
|
76
|
+
padding: 0.5rem 1rem;
|
|
77
|
+
border: 1px solid #757575;
|
|
78
|
+
border-radius: 0.25rem;
|
|
79
|
+
background: #ffffff;
|
|
80
|
+
color: #222222;
|
|
81
|
+
font: inherit;
|
|
82
|
+
line-height: 1.5;
|
|
83
|
+
cursor: pointer;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
.solidus-mcp-admin .solidus-mcp-button-primary {
|
|
87
|
+
background: #222222;
|
|
88
|
+
color: #ffffff;
|
|
89
|
+
border-color: #222222;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
.solidus-mcp-admin .solidus-mcp-button:hover {
|
|
93
|
+
filter: brightness(0.95);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
.solidus-mcp-admin :is(a, button, input, summary):focus-visible {
|
|
97
|
+
outline: 2px solid #2563eb;
|
|
98
|
+
outline-offset: 3px;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
.solidus-mcp-admin .solidus-mcp-pagination {
|
|
102
|
+
display: flex;
|
|
103
|
+
gap: 1rem;
|
|
104
|
+
margin-top: 1rem;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
solidus-mcp-permissions {
|
|
108
|
+
display: block;
|
|
109
|
+
min-width: 0;
|
|
110
|
+
margin: 1.5rem 0;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
.solidus-mcp-permissions-heading,
|
|
114
|
+
.solidus-mcp-permissions-group-heading {
|
|
115
|
+
display: flex;
|
|
116
|
+
align-items: center;
|
|
117
|
+
justify-content: space-between;
|
|
118
|
+
flex-wrap: wrap;
|
|
119
|
+
gap: 0.75rem 1rem;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
.solidus-mcp-permissions-heading {
|
|
123
|
+
margin-bottom: 1rem;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
.solidus-mcp-permissions-heading h2 {
|
|
127
|
+
font-size: 1.125rem;
|
|
128
|
+
font-weight: 600;
|
|
129
|
+
margin: 0;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
.solidus-mcp-table-scroll,
|
|
133
|
+
.solidus-mcp-permissions-scroll {
|
|
134
|
+
overflow-x: auto;
|
|
135
|
+
border: 1px solid #d8dad8;
|
|
136
|
+
border-radius: 0.5rem;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
.solidus-mcp-admin .solidus-mcp-table {
|
|
140
|
+
width: 100%;
|
|
141
|
+
min-width: 48rem;
|
|
142
|
+
border-collapse: collapse;
|
|
143
|
+
text-align: left;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
.solidus-mcp-admin .solidus-mcp-table th,
|
|
147
|
+
.solidus-mcp-admin .solidus-mcp-table td {
|
|
148
|
+
padding: 0.875rem 1rem;
|
|
149
|
+
border-bottom: 1px solid #d8dad8;
|
|
150
|
+
vertical-align: top;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
.solidus-mcp-admin .solidus-mcp-table thead {
|
|
154
|
+
background: #f5f3f0;
|
|
155
|
+
font-size: 0.875rem;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
.solidus-mcp-admin .solidus-mcp-table th {
|
|
159
|
+
font-weight: 600;
|
|
160
|
+
text-align: inherit;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
.solidus-mcp-permissions-category {
|
|
164
|
+
background: #f5f3f0;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
.solidus-mcp-admin .solidus-mcp-table tbody:last-child tr:last-child > * {
|
|
168
|
+
border-bottom: 0;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
solidus-mcp-permissions th[scope="row"] {
|
|
172
|
+
min-width: 12rem;
|
|
173
|
+
font-weight: 600;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
solidus-mcp-permissions td:nth-child(3) {
|
|
177
|
+
white-space: nowrap;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
solidus-mcp-permissions label {
|
|
181
|
+
display: inline-flex;
|
|
182
|
+
align-items: baseline;
|
|
183
|
+
gap: 0.5rem;
|
|
184
|
+
cursor: pointer;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
solidus-mcp-permissions [data-selection-control] {
|
|
188
|
+
align-items: center;
|
|
189
|
+
font-size: 0.875rem;
|
|
190
|
+
font-weight: 400;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
solidus-mcp-permissions [hidden] {
|
|
194
|
+
display: none;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
.solidus-mcp-admin input[type="checkbox"]:disabled {
|
|
199
|
+
cursor: default;
|
|
200
|
+
opacity: 0.65;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
.solidus-mcp-admin .solidus-mcp-role-form {
|
|
204
|
+
display: flex;
|
|
205
|
+
align-items: center;
|
|
206
|
+
flex-wrap: wrap;
|
|
207
|
+
gap: 0.75rem;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
solidus-mcp-account-search {
|
|
211
|
+
display: block;
|
|
212
|
+
width: 28rem;
|
|
213
|
+
max-width: 100%;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
solidus-mcp-account-search label {
|
|
217
|
+
display: block;
|
|
218
|
+
margin-bottom: 0.25rem;
|
|
219
|
+
font-weight: 600;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
.solidus-mcp-account-field {
|
|
223
|
+
position: relative;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
solidus-mcp-account-search input[type="search"] {
|
|
227
|
+
box-sizing: border-box;
|
|
228
|
+
width: 100%;
|
|
229
|
+
padding: 0.625rem 0.75rem;
|
|
230
|
+
border: 1px solid #757575;
|
|
231
|
+
border-radius: 0.25rem;
|
|
232
|
+
background: #ffffff;
|
|
233
|
+
color: #222222;
|
|
234
|
+
font: inherit;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
solidus-mcp-account-search [role="listbox"] {
|
|
238
|
+
box-sizing: border-box;
|
|
239
|
+
position: absolute;
|
|
240
|
+
top: 100%;
|
|
241
|
+
left: 0;
|
|
242
|
+
right: 0;
|
|
243
|
+
z-index: 30;
|
|
244
|
+
max-height: 16rem;
|
|
245
|
+
overflow-y: auto;
|
|
246
|
+
margin: 0.25rem 0 0;
|
|
247
|
+
padding: 0.25rem;
|
|
248
|
+
border: 1px solid #757575;
|
|
249
|
+
border-radius: 0.25rem;
|
|
250
|
+
background: #ffffff;
|
|
251
|
+
color: #222222;
|
|
252
|
+
list-style: none;
|
|
253
|
+
box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.12);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
solidus-mcp-account-search [role="option"] {
|
|
257
|
+
padding: 0.5rem;
|
|
258
|
+
border-radius: 0.125rem;
|
|
259
|
+
overflow-wrap: anywhere;
|
|
260
|
+
cursor: pointer;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
solidus-mcp-account-search [role="option"]:hover,
|
|
264
|
+
solidus-mcp-account-search [aria-selected="true"] {
|
|
265
|
+
background: #f5f3f0;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
solidus-mcp-account-search small {
|
|
269
|
+
display: block;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
.solidus-mcp-admin solidus-mcp-account-search [role="status"] {
|
|
273
|
+
margin: 0.375rem 0 0;
|
|
274
|
+
font-size: 0.875rem;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
solidus-mcp-account-search [hidden] {
|
|
278
|
+
display: none;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
.solidus-mcp-admin .solidus-mcp-button:disabled {
|
|
282
|
+
cursor: default;
|
|
283
|
+
opacity: 0.5;
|
|
284
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SolidusMcp
|
|
4
|
+
module HttpAccess
|
|
5
|
+
extend ActiveSupport::Concern
|
|
6
|
+
|
|
7
|
+
included do
|
|
8
|
+
before_action :prepare_mcp_request
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def preflight
|
|
12
|
+
response.set_header("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS")
|
|
13
|
+
response.set_header("Access-Control-Allow-Headers", "Content-Type, Authorization, MCP-Protocol-Version, MCP-Session-Id")
|
|
14
|
+
head :no_content
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
private
|
|
18
|
+
|
|
19
|
+
def prepare_mcp_request
|
|
20
|
+
config = SolidusMcp.configuration
|
|
21
|
+
return head :not_found unless config.enabled?
|
|
22
|
+
config.validate!
|
|
23
|
+
response.set_header("Cache-Control", "no-store")
|
|
24
|
+
response.set_header("Vary", "Origin")
|
|
25
|
+
origin = request.headers["Origin"]
|
|
26
|
+
if origin.present?
|
|
27
|
+
return head :forbidden unless config.allowed_origins.include?(origin)
|
|
28
|
+
response.set_header("Access-Control-Allow-Origin", origin)
|
|
29
|
+
response.set_header("Access-Control-Expose-Headers", "WWW-Authenticate, MCP-Session-Id")
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SolidusAdmin
|
|
4
|
+
class McpAdministratorsController < BaseController
|
|
5
|
+
before_action :require_superadministrator
|
|
6
|
+
|
|
7
|
+
rescue_from SolidusMcp::AccessDenied do
|
|
8
|
+
head :forbidden
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def index
|
|
12
|
+
@superadmins = Spree.user_class.where(id: SolidusMcp::Administration.superadmin_user_ids)
|
|
13
|
+
@page = [params[:page].to_i, 1].max
|
|
14
|
+
entries = SolidusMcp::Administrator.order(:id).offset((@page - 1) * 25).limit(26).to_a
|
|
15
|
+
@more = entries.size > 25
|
|
16
|
+
@administrators = entries.first(25)
|
|
17
|
+
@users = Spree.user_class.where(id: @administrators.map(&:user_id)).index_by(&:id)
|
|
18
|
+
@changes = SolidusMcp::AdministratorChange.includes(:administrator).order(id: :desc).limit(20).to_a
|
|
19
|
+
audit_ids = @changes.flat_map { |change| [change.actor_id, change.administrator.user_id] }
|
|
20
|
+
@audit_users = Spree.user_class.where(id: audit_ids).index_by(&:id)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def search
|
|
24
|
+
response.set_header("Cache-Control", "no-store")
|
|
25
|
+
query = params.fetch(:query, "")
|
|
26
|
+
unless query.is_a?(String) && query.length <= 100
|
|
27
|
+
raise ActionController::BadRequest, "Invalid account search"
|
|
28
|
+
end
|
|
29
|
+
accounts = administrator_accounts
|
|
30
|
+
.where("LOWER(email) LIKE ?", "#{Spree.user_class.sanitize_sql_like(query.strip.downcase)}%")
|
|
31
|
+
.where.not(id: SolidusMcp::Administration.superadmin_user_ids)
|
|
32
|
+
.where.not(id: SolidusMcp::Administrator.where(enabled: true).select(:user_id))
|
|
33
|
+
.order(:email, :id).limit(21).to_a.select(&:admin?)
|
|
34
|
+
more = accounts.length > 20
|
|
35
|
+
accounts = accounts.first(20)
|
|
36
|
+
assignments = SolidusMcp::Administrator.where(user_id: accounts.map(&:id)).index_by(&:user_id)
|
|
37
|
+
render json: {items: accounts.map { |user|
|
|
38
|
+
{id: user.id.to_s, email: user.email, lock_version: assignments[user.id]&.lock_version || -1}
|
|
39
|
+
}, more: more}
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def create
|
|
43
|
+
submitted = params.require(:administrator).permit(:user_id, :lock_version)
|
|
44
|
+
version = submitted.fetch(:lock_version, "-1").to_s
|
|
45
|
+
raise ActionController::BadRequest, "Invalid assignment version" unless version.match?(/\A(?:-1|[0-9]+)\z/)
|
|
46
|
+
user = Spree.user_class.find(submitted.fetch(:user_id))
|
|
47
|
+
assignment = SolidusMcp::Administrator.find_or_initialize_by(user: user)
|
|
48
|
+
assignment.change!(actor: current_solidus_admin_user, enabled: true, lock_version: version.to_i)
|
|
49
|
+
redirect_to solidus_admin.mcp_administrators_path, notice: "MCP administrator assigned.", status: :see_other
|
|
50
|
+
rescue ActiveRecord::StaleObjectError, ActiveRecord::RecordNotUnique
|
|
51
|
+
redirect_to solidus_admin.mcp_administrators_path, alert: "This user already has an assignment. Use its current controls below.", status: :see_other
|
|
52
|
+
rescue ActiveRecord::RecordInvalid => error
|
|
53
|
+
redirect_to solidus_admin.mcp_administrators_path, alert: error.record.errors.full_messages.to_sentence, status: :see_other
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def update
|
|
57
|
+
submitted = params.require(:administrator).permit(:enabled, :lock_version)
|
|
58
|
+
version = submitted[:lock_version]
|
|
59
|
+
unless version.is_a?(String) && version.match?(/\A[0-9]+\z/) && %w[0 1].include?(submitted[:enabled])
|
|
60
|
+
raise ActionController::BadRequest, "Invalid administrator assignment"
|
|
61
|
+
end
|
|
62
|
+
assignment = SolidusMcp::Administrator.find(params[:id])
|
|
63
|
+
assignment.change!(actor: current_solidus_admin_user, enabled: submitted[:enabled] == "1", lock_version: version.to_i)
|
|
64
|
+
redirect_to solidus_admin.mcp_administrators_path, notice: "MCP administrator assignment updated.", status: :see_other
|
|
65
|
+
rescue ActiveRecord::StaleObjectError
|
|
66
|
+
redirect_to solidus_admin.mcp_administrators_path, alert: "Another superadministrator changed this assignment. Reload before trying again.", status: :see_other
|
|
67
|
+
rescue ActiveRecord::RecordInvalid => error
|
|
68
|
+
redirect_to solidus_admin.mcp_administrators_path, alert: error.record.errors.full_messages.to_sentence, status: :see_other
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
private
|
|
72
|
+
|
|
73
|
+
def administrator_accounts
|
|
74
|
+
users = Spree.user_class
|
|
75
|
+
if users.reflect_on_association(:spree_roles)
|
|
76
|
+
users.joins(:spree_roles).merge(Spree::Role.where(name: "admin")).distinct
|
|
77
|
+
else
|
|
78
|
+
# Custom user models may expose admin status as a boolean column.
|
|
79
|
+
users.where(admin: true)
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def authorization_subject
|
|
84
|
+
:mcp_administrators
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def require_superadministrator
|
|
88
|
+
head :forbidden unless SolidusMcp::Administration.superadmin?(current_solidus_admin_user)
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SolidusAdmin
|
|
4
|
+
class McpConnectionsController < BaseController
|
|
5
|
+
before_action :require_mcp_user
|
|
6
|
+
helper_method :mcp_administrator?, :mcp_superadministrator?, :manage_connection_permissions?, :can_manage_connection?
|
|
7
|
+
|
|
8
|
+
rescue_from SolidusMcp::AccessDenied do
|
|
9
|
+
head :forbidden
|
|
10
|
+
end
|
|
11
|
+
before_action :load_connection, only: [:edit, :update, :destroy]
|
|
12
|
+
before_action :require_permission_manager, only: [:update, :destroy]
|
|
13
|
+
|
|
14
|
+
def index
|
|
15
|
+
@show_deleted = params[:deleted] == "1"
|
|
16
|
+
@page = [params[:page].to_i, 1].max
|
|
17
|
+
connections = Doorkeeper::AccessToken.where(resource: SolidusMcp.configuration.endpoint_url).where.not(application_id: nil).where.not(resource_owner_id: nil)
|
|
18
|
+
connections = SolidusMcp::Administration.visible_connections(connections, current_solidus_admin_user)
|
|
19
|
+
@connections = connections.where("#{@show_deleted ? "" : "NOT "}EXISTS (SELECT 1 FROM solidus_mcp_connection_policies p WHERE p.application_id = oauth_access_tokens.application_id AND p.user_id = oauth_access_tokens.resource_owner_id AND p.deleted_at IS NOT NULL)")
|
|
20
|
+
.select(:application_id, :resource_owner_id).distinct.order(:application_id, :resource_owner_id)
|
|
21
|
+
.offset((@page - 1) * 25).limit(26).to_a
|
|
22
|
+
@more = @connections.size > 25
|
|
23
|
+
@connections = @connections.first(25)
|
|
24
|
+
@applications = Doorkeeper::Application.where(id: @connections.map(&:application_id)).index_by(&:id)
|
|
25
|
+
@users = Spree.user_class.where(id: @connections.map(&:resource_owner_id)).index_by(&:id)
|
|
26
|
+
@active_connections = Doorkeeper::AccessToken.where(resource: SolidusMcp.configuration.endpoint_url,
|
|
27
|
+
application_id: @applications.keys, resource_owner_id: @users.keys, revoked_at: nil)
|
|
28
|
+
.where("expires_in IS NULL OR created_at + expires_in * interval '1 second' > ?", Time.current)
|
|
29
|
+
.distinct.pluck(:application_id, :resource_owner_id)
|
|
30
|
+
@policies = SolidusMcp::ConnectionPolicy.where(application_id: @applications.keys, user_id: @users.keys)
|
|
31
|
+
.index_by { |policy| [policy.application_id, policy.user_id] }
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def edit
|
|
35
|
+
# GET forms in the admin navigation replace query parameters on submission.
|
|
36
|
+
# Keep the connection owner in the path before rendering those forms.
|
|
37
|
+
if !request.path_parameters.key?(:user_id) || request.path_parameters[:legacy_owner_path]
|
|
38
|
+
redirect_to solidus_admin.edit_mcp_connection_path(@application, user_id: @user.id), status: :see_other
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def update
|
|
43
|
+
submitted = params.require(:connection_policy).permit(:enabled, :lock_version, permissions: [])
|
|
44
|
+
version = submitted[:lock_version]
|
|
45
|
+
raise ActionController::BadRequest, "Missing policy version" unless version.is_a?(String) && version.match?(/\A(?:-1|[0-9]+)\z/)
|
|
46
|
+
raise ActionController::BadRequest, "Invalid connection status" unless %w[0 1].include?(submitted[:enabled])
|
|
47
|
+
|
|
48
|
+
@policy.change!(actor: current_solidus_admin_user,
|
|
49
|
+
permissions: submitted.fetch(:permissions, []).reject(&:blank?),
|
|
50
|
+
enabled: submitted[:enabled] == "1", lock_version: version.to_i)
|
|
51
|
+
needs_reconnect = @registry.tools.values.any? do |tool|
|
|
52
|
+
(tool.permissions - @policy.permissions).empty? && @scope_status.warning(tool)
|
|
53
|
+
end
|
|
54
|
+
notice = if @policy.enabled? && needs_reconnect
|
|
55
|
+
"Connection permissions saved. OAuth access is still missing for some tools; follow the reconnect instructions below."
|
|
56
|
+
else
|
|
57
|
+
"Connection permissions saved. They apply to the next request, within the token's OAuth scopes."
|
|
58
|
+
end
|
|
59
|
+
redirect_to solidus_admin.edit_mcp_connection_path(@application, user_id: @user.id), notice: notice, status: :see_other
|
|
60
|
+
rescue ActiveRecord::StaleObjectError, ActiveRecord::RecordNotUnique
|
|
61
|
+
@policy.reload if @policy.persisted?
|
|
62
|
+
flash.now[:error] = "Another administrator changed this connection. Reload the page before saving again."
|
|
63
|
+
render :edit, status: :conflict
|
|
64
|
+
rescue ActiveRecord::RecordInvalid => e
|
|
65
|
+
flash.now[:error] = e.record.errors.full_messages.to_sentence
|
|
66
|
+
render :edit, status: :unprocessable_entity
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def destroy
|
|
70
|
+
version = params.require(:connection_policy).permit(:lock_version)[:lock_version]
|
|
71
|
+
raise ActionController::BadRequest, "Missing policy version" unless version.is_a?(String) && version.match?(/\A(?:-1|[0-9]+)\z/)
|
|
72
|
+
@policy.delete_permissions!(actor: current_solidus_admin_user, lock_version: version.to_i)
|
|
73
|
+
redirect_to solidus_admin.mcp_connections_path,
|
|
74
|
+
notice: "Permission set deleted. Access is disabled; its audit history is retained under Deleted permission sets.", status: :see_other
|
|
75
|
+
rescue ActiveRecord::StaleObjectError, ActiveRecord::RecordNotUnique
|
|
76
|
+
@policy.reload if @policy.persisted?
|
|
77
|
+
flash.now[:error] = "Another administrator changed this connection. Reload the page before deleting it."
|
|
78
|
+
render :edit, status: :conflict
|
|
79
|
+
rescue ActiveRecord::RecordInvalid => e
|
|
80
|
+
flash.now[:error] = e.record.errors.full_messages.to_sentence
|
|
81
|
+
render :edit, status: :unprocessable_entity
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
private
|
|
85
|
+
|
|
86
|
+
def authorization_subject
|
|
87
|
+
:mcp_connections
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def require_mcp_user
|
|
91
|
+
head :forbidden unless SolidusMcp::Administration.user?(current_solidus_admin_user)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def require_permission_manager
|
|
95
|
+
head :forbidden unless manage_connection_permissions?
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def mcp_administrator?
|
|
99
|
+
@mcp_administrator ||= SolidusMcp::Administration.administrator?(current_solidus_admin_user)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def mcp_superadministrator?
|
|
103
|
+
SolidusMcp::Administration.superadmin?(current_solidus_admin_user)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def can_manage_connection?(owner)
|
|
107
|
+
SolidusMcp::Administration.can_manage?(current_solidus_admin_user, owner)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def manage_connection_permissions?
|
|
111
|
+
@can_manage_permissions
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def load_connection
|
|
115
|
+
@user = Spree.user_class.find(params.require(:user_id))
|
|
116
|
+
unless SolidusMcp::Administration.can_view?(current_solidus_admin_user, @user)
|
|
117
|
+
raise ActiveRecord::RecordNotFound
|
|
118
|
+
end
|
|
119
|
+
@application = Doorkeeper::Application.find(params[:id])
|
|
120
|
+
@can_manage_permissions = can_manage_connection?(@user)
|
|
121
|
+
tokens = Doorkeeper::AccessToken.where(application_id: @application.id, resource_owner_id: @user.id, resource: SolidusMcp.configuration.endpoint_url)
|
|
122
|
+
raise ActiveRecord::RecordNotFound unless tokens.exists?
|
|
123
|
+
scope_sets = tokens.where(revoked_at: nil).where("expires_in IS NULL OR created_at + expires_in * interval '1 second' > ?", Time.current).pluck(:scopes)
|
|
124
|
+
@scopes = scope_sets.flat_map { |scopes| scopes.to_s.split }.uniq.sort
|
|
125
|
+
@scope_status = SolidusMcp::ConnectionScopes.new(scope_sets)
|
|
126
|
+
@policy = SolidusMcp::ConnectionPolicy.find_or_initialize_by(application: @application, user: @user)
|
|
127
|
+
@policy.permissions = [] if @policy.new_record?
|
|
128
|
+
@registry = SolidusMcp.registry
|
|
129
|
+
@changes = @policy.persisted? ? @policy.policy_changes.order(id: :desc).limit(10) : []
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SolidusMcp
|
|
4
|
+
class AuthorizationsController < Doorkeeper::AuthorizationsController
|
|
5
|
+
layout false
|
|
6
|
+
before_action :validate_mcp_request
|
|
7
|
+
helper_method :scope_options
|
|
8
|
+
|
|
9
|
+
private
|
|
10
|
+
|
|
11
|
+
def scope_options
|
|
12
|
+
registered = @pre_auth.client.application.scopes.to_a
|
|
13
|
+
configured = Doorkeeper.config.default_scopes.to_a | Doorkeeper.config.optional_scopes.to_a
|
|
14
|
+
Credential::SCOPES & configured & (registered.empty? ? Credential::SCOPES : registered)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def validate_mcp_request
|
|
18
|
+
return head :not_found unless SolidusMcp.configuration.enabled?
|
|
19
|
+
return render json: {error: "invalid_target"}, status: :bad_request unless params[:resource] == SolidusMcp.configuration.endpoint_url
|
|
20
|
+
if params.key?(:requested_scopes) || params.key?(:review_scopes)
|
|
21
|
+
selected = params[:requested_scopes]
|
|
22
|
+
unless action_name == "new" && request.get? && selected.is_a?(Array) && selected.any? &&
|
|
23
|
+
selected.all? { |scope| scope.is_a?(String) } && (selected - Credential::SCOPES).empty?
|
|
24
|
+
return render json: {error: "invalid_scope"}, status: :bad_request
|
|
25
|
+
end
|
|
26
|
+
# Review prepares a new consent summary, never a grant. Doorkeeper
|
|
27
|
+
# still validates the client, callback and registered scope ceiling.
|
|
28
|
+
params[:scope] = selected.uniq.join(" ")
|
|
29
|
+
end
|
|
30
|
+
scopes = params[:scope].presence || "mcp:read"
|
|
31
|
+
unless scopes.is_a?(String) && scopes.split.any? && (scopes.split - Credential::SCOPES).empty?
|
|
32
|
+
return render json: {error: "invalid_scope"}, status: :bad_request
|
|
33
|
+
end
|
|
34
|
+
params[:scope] = scopes
|
|
35
|
+
unless params[:code_challenge_method] == "S256" && params[:code_challenge].to_s.match?(/\A[A-Za-z0-9_-]{43}\z/)
|
|
36
|
+
render json: {error: "invalid_request", error_description: "S256 PKCE is required"}, status: :bad_request
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SolidusMcp
|
|
4
|
+
class EndpointController < ActionController::API
|
|
5
|
+
MAX_REQUEST_BYTES = 1.megabyte
|
|
6
|
+
|
|
7
|
+
include HttpAccess
|
|
8
|
+
before_action :authenticate, except: :preflight
|
|
9
|
+
|
|
10
|
+
def handle
|
|
11
|
+
# A scope challenge belongs to HTTP, not an MCP tool error. Inspect only
|
|
12
|
+
# the tool identity; SDK parsing/validation still owns the protocol.
|
|
13
|
+
if request.post? && request.media_type == "application/json"
|
|
14
|
+
body = request.body.read(MAX_REQUEST_BYTES + 1)
|
|
15
|
+
request.body.rewind
|
|
16
|
+
return head :payload_too_large if body.bytesize > MAX_REQUEST_BYTES
|
|
17
|
+
payload = parse_payload(body)
|
|
18
|
+
if payload.is_a?(Hash) && payload["method"] == "tools/call" && payload["params"].is_a?(Hash)
|
|
19
|
+
tool = SolidusMcp.registry.tools[payload["params"]["name"]]
|
|
20
|
+
begin
|
|
21
|
+
Policy.new(@credential).authorize!(tool) if tool
|
|
22
|
+
rescue InsufficientScope => e
|
|
23
|
+
response.set_header("WWW-Authenticate", %(Bearer error="insufficient_scope", scope="#{e.scopes.join(" ")}", resource_metadata="#{SolidusMcp.configuration.endpoint_url}/metadata"))
|
|
24
|
+
return render json: {error: "insufficient_scope"}, status: :forbidden
|
|
25
|
+
rescue AccessDenied
|
|
26
|
+
# Normal tool permission failures remain protocol errors.
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
config = SolidusMcp.configuration
|
|
31
|
+
transport = MCP::Server::Transports::StreamableHTTPTransport.new(
|
|
32
|
+
Server.build(credential: @credential), stateless: true, serve_subscriptions_listen: false,
|
|
33
|
+
allowed_hosts: [URI(config.endpoint_url).host], allowed_origins: config.allowed_origins
|
|
34
|
+
)
|
|
35
|
+
status, headers, body = transport.handle_request(request)
|
|
36
|
+
self.status = status
|
|
37
|
+
headers.each { |key, value| response.set_header(key, value) }
|
|
38
|
+
self.response_body = body
|
|
39
|
+
response.set_header("Cache-Control", "no-store")
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
def parse_payload(body)
|
|
45
|
+
JSON.parse(body)
|
|
46
|
+
rescue JSON::ParserError
|
|
47
|
+
nil
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def authenticate
|
|
51
|
+
bearer = request.authorization&.match(/\ABearer ([^\s]+)\z/i)&.captures&.first
|
|
52
|
+
@credential = Credential.authenticate(bearer)
|
|
53
|
+
unless @credential
|
|
54
|
+
response.set_header("WWW-Authenticate", %(Bearer resource_metadata="#{SolidusMcp.configuration.endpoint_url}/metadata"))
|
|
55
|
+
return head :unauthorized
|
|
56
|
+
end
|
|
57
|
+
connection = ConnectionPolicy.find_by(application_id: @credential.token.application_id, user_id: @credential.user.id)
|
|
58
|
+
head :forbidden if connection && (!connection.enabled? || connection.deleted_at?)
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SolidusMcp
|
|
4
|
+
class MetadataController < ActionController::API
|
|
5
|
+
include HttpAccess
|
|
6
|
+
|
|
7
|
+
def resource
|
|
8
|
+
render json: {resource: SolidusMcp.configuration.endpoint_url,
|
|
9
|
+
authorization_servers: [SolidusMcp.configuration.issuer_url],
|
|
10
|
+
scopes_supported: Credential::SCOPES, bearer_methods_supported: ["header"]}
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def authorization_server
|
|
14
|
+
base = SolidusMcp.configuration.endpoint_url
|
|
15
|
+
render json: {issuer: SolidusMcp.configuration.issuer_url,
|
|
16
|
+
authorization_endpoint: "#{base}/oauth/authorize", token_endpoint: "#{base}/oauth/token",
|
|
17
|
+
revocation_endpoint: "#{base}/oauth/revoke", registration_endpoint: "#{base}/oauth/register",
|
|
18
|
+
response_types_supported: ["code"], grant_types_supported: %w[authorization_code refresh_token],
|
|
19
|
+
token_endpoint_auth_methods_supported: %w[client_secret_basic client_secret_post none],
|
|
20
|
+
code_challenge_methods_supported: ["S256"], scopes_supported: Credential::SCOPES}
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|