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.
Files changed (63) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +52 -0
  3. data/CONTRIBUTING.md +31 -0
  4. data/LICENSE +27 -0
  5. data/README.md +355 -0
  6. data/app/assets/stylesheets/solidus_mcp/admin.css +284 -0
  7. data/app/controllers/concerns/solidus_mcp/http_access.rb +33 -0
  8. data/app/controllers/solidus_admin/mcp_administrators_controller.rb +91 -0
  9. data/app/controllers/solidus_admin/mcp_connections_controller.rb +132 -0
  10. data/app/controllers/solidus_mcp/authorizations_controller.rb +40 -0
  11. data/app/controllers/solidus_mcp/endpoint_controller.rb +61 -0
  12. data/app/controllers/solidus_mcp/metadata_controller.rb +23 -0
  13. data/app/controllers/solidus_mcp/registrations_controller.rb +48 -0
  14. data/app/controllers/solidus_mcp/tokens_controller.rb +23 -0
  15. data/app/models/solidus_mcp/administrator.rb +35 -0
  16. data/app/models/solidus_mcp/administrator_change.rb +8 -0
  17. data/app/models/solidus_mcp/connection_policy.rb +53 -0
  18. data/app/models/solidus_mcp/credential.rb +36 -0
  19. data/app/models/solidus_mcp/policy_change.rb +9 -0
  20. data/app/services/solidus_mcp/client_registration.rb +94 -0
  21. data/app/services/solidus_mcp/price_update.rb +42 -0
  22. data/app/services/solidus_mcp/serializers.rb +49 -0
  23. data/app/tools/solidus_mcp/tools/base.rb +37 -0
  24. data/app/tools/solidus_mcp/tools/bulk_update_variant_prices.rb +48 -0
  25. data/app/tools/solidus_mcp/tools/create_product.rb +42 -0
  26. data/app/tools/solidus_mcp/tools/get_inventory_levels.rb +26 -0
  27. data/app/tools/solidus_mcp/tools/get_order.rb +29 -0
  28. data/app/tools/solidus_mcp/tools/get_order_status.rb +21 -0
  29. data/app/tools/solidus_mcp/tools/get_product.rb +21 -0
  30. data/app/tools/solidus_mcp/tools/get_product_variants.rb +24 -0
  31. data/app/tools/solidus_mcp/tools/get_variant_prices.rb +25 -0
  32. data/app/tools/solidus_mcp/tools/search_inventory.rb +27 -0
  33. data/app/tools/solidus_mcp/tools/search_orders.rb +28 -0
  34. data/app/tools/solidus_mcp/tools/search_products.rb +27 -0
  35. data/app/tools/solidus_mcp/tools/update_product.rb +35 -0
  36. data/app/tools/solidus_mcp/tools/update_variant_price.rb +27 -0
  37. data/app/views/solidus_admin/mcp_administrators/_account_search.html.erb +161 -0
  38. data/app/views/solidus_admin/mcp_administrators/index.html.erb +67 -0
  39. data/app/views/solidus_admin/mcp_connections/_permission_selection.html.erb +51 -0
  40. data/app/views/solidus_admin/mcp_connections/_permissions.html.erb +54 -0
  41. data/app/views/solidus_admin/mcp_connections/edit.html.erb +59 -0
  42. data/app/views/solidus_admin/mcp_connections/index.html.erb +48 -0
  43. data/app/views/solidus_mcp/authorizations/new.html.erb +43 -0
  44. data/config/locales/en.yml +4 -0
  45. data/config/routes.rb +34 -0
  46. data/db/migrate/20261023000000_create_solidus_mcp_connection_policies.rb +26 -0
  47. data/db/migrate/20261024000000_add_solidus_mcp_oauth_attributes.rb +17 -0
  48. data/db/migrate/20261024010000_add_deleted_at_to_solidus_mcp_connection_policies.rb +7 -0
  49. data/db/migrate/20261108000000_create_solidus_mcp_administrators.rb +21 -0
  50. data/docs/administration.md +121 -0
  51. data/lib/solidus_mcp/administration.rb +90 -0
  52. data/lib/solidus_mcp/configuration.rb +36 -0
  53. data/lib/solidus_mcp/connection_scopes.rb +25 -0
  54. data/lib/solidus_mcp/engine.rb +27 -0
  55. data/lib/solidus_mcp/oauth.rb +44 -0
  56. data/lib/solidus_mcp/policy.rb +48 -0
  57. data/lib/solidus_mcp/registry.rb +69 -0
  58. data/lib/solidus_mcp/server.rb +28 -0
  59. data/lib/solidus_mcp/tool.rb +63 -0
  60. data/lib/solidus_mcp/version.rb +5 -0
  61. data/lib/solidus_mcp.rb +57 -0
  62. data/lib/tasks/solidus_mcp.rake +9 -0
  63. 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