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,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidusMcp
4
+ module Tools
5
+ class GetVariantPrices < Base
6
+ tool_name :get_variant_prices
7
+ description "List exact price rows for a variant, including currency and country context."
8
+ category "Pricing"
9
+ permission "get_variant_prices"
10
+ read_only true
11
+ ability :read, "Spree::Product"
12
+ ability :read, "Spree::Variant"
13
+ ability :read, "Spree::Price"
14
+ input :variant_id, type: "integer", minimum: 1, required: true
15
+ pagination
16
+
17
+ protected
18
+
19
+ def call(variant_id:, after_id: 0, limit: 25)
20
+ serializers.variant(variants.find(variant_id))
21
+ page(policy.scope(Spree::Price).where(variant_id: variant_id), after_id: after_id, limit: limit) { |price| serializers.price(price) }
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidusMcp
4
+ module Tools
5
+ class SearchInventory < Base
6
+ tool_name :search_inventory
7
+ description "Search stock levels by variant SKU or product name, with keyset pagination."
8
+ category "Inventory"
9
+ permission "search_inventory"
10
+ read_only true
11
+ ability :read, "Spree::Product"
12
+ ability :read, "Spree::Variant"
13
+ ability :read, "Spree::StockItem"
14
+ ability :read, "Spree::StockLocation"
15
+ input :query, type: "string", minLength: 1, maxLength: 100, required: true
16
+ pagination
17
+
18
+ protected
19
+
20
+ def call(query:, after_id: 0, limit: 25)
21
+ pattern = "%#{ActiveRecord::Base.sanitize_sql_like(query)}%"
22
+ matching = variants.joins(:product).where("spree_variants.sku ILIKE ? OR spree_products.name ILIKE ?", pattern, pattern)
23
+ page(stock_items.where(variant_id: matching.select(:id)), after_id: after_id, limit: limit) { |item| serializers.inventory(item) }
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidusMcp
4
+ module Tools
5
+ class SearchOrders < Base
6
+ tool_name :search_orders
7
+ description "Search orders by exact number, state or store ID. Returns bounded order totals and statuses without customer contact information."
8
+ category "Orders"
9
+ permission "search_orders"
10
+ read_only true
11
+ ability :read, "Spree::Order"
12
+ input :number, type: "string", minLength: 1, maxLength: 100
13
+ input :state, type: "string", enum: %w[cart address delivery payment confirm complete canceled resumed]
14
+ input :store_id, type: "integer", minimum: 1
15
+ pagination
16
+
17
+ protected
18
+
19
+ def call(number: nil, state: nil, store_id: nil, after_id: 0, limit: 25)
20
+ orders = policy.scope(Spree::Order)
21
+ orders = orders.where(number: number) if number
22
+ orders = orders.where(state: state) if state
23
+ orders = orders.where(store_id: store_id) if store_id
24
+ page(orders, after_id: after_id, limit: limit) { |order| serializers.order(order) }
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidusMcp
4
+ module Tools
5
+ class SearchProducts < Base
6
+ tool_name :search_products
7
+ description "Search authorized products by product name or variant SKU, with keyset pagination."
8
+ category "Products"
9
+ permission "search_products"
10
+ read_only true
11
+ ability :read, "Spree::Product"
12
+ ability :read, "Spree::Variant"
13
+ input :query, type: "string", minLength: 1, maxLength: 100, required: true
14
+ pagination
15
+
16
+ protected
17
+
18
+ def call(query:, after_id: 0, limit: 25)
19
+ pattern = "%#{ActiveRecord::Base.sanitize_sql_like(query)}%"
20
+ sku_ids = variants.where("spree_variants.sku ILIKE ?", pattern).select(:product_id)
21
+ products = policy.scope(Spree::Product)
22
+ matching = products.where("spree_products.name ILIKE ?", pattern).or(products.where(id: sku_ids))
23
+ page(matching, after_id: after_id, limit: limit) { |product| serializers.product(product) }
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidusMcp
4
+ module Tools
5
+ class UpdateProduct < Base
6
+ tool_name :update_product
7
+ description "Update explicitly supported product attributes using Solidus validation. Changes take effect immediately."
8
+ category "Products"
9
+ permission "update_product"
10
+ read_only false
11
+ ability :update, "Spree::Product"
12
+ input :product_id, type: "integer", minimum: 1, required: true
13
+ input :changes, type: "object", minProperties: 1, additionalProperties: false, required: true,
14
+ properties: {name: {type: "string", minLength: 1, maxLength: 255},
15
+ description: {type: "string", maxLength: 50000},
16
+ meta_title: {type: "string", maxLength: 255},
17
+ meta_description: {type: "string", maxLength: 1000},
18
+ meta_keywords: {type: "string", maxLength: 1000}}
19
+
20
+ protected
21
+
22
+ def call(product_id:, changes:)
23
+ product = policy.scope(Spree::Product, :update).find(product_id)
24
+ product.with_lock do
25
+ authorize!
26
+ policy.authorize_record!(:update, product)
27
+ product.assign_attributes(changes)
28
+ policy.authorize_record!(:update, product)
29
+ product.save!
30
+ end
31
+ {product_id: product.id, updated: true}
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidusMcp
4
+ module Tools
5
+ class UpdateVariantPrice < Base
6
+ tool_name :update_variant_price
7
+ description "Update one existing variant price in an explicit currency and country context. Decimal amounts are strings; country_iso null selects the general-country price."
8
+ category "Pricing"
9
+ permission "update_variant_price"
10
+ read_only false
11
+ ability :update, "Spree::Product"
12
+ ability :update, "Spree::Variant"
13
+ ability :update, "Spree::Price"
14
+ input :variant_id, type: "integer", minimum: 1, required: true
15
+ input :currency, type: "string", pattern: "^[A-Z]{3}$", required: true
16
+ input :country_iso, type: ["string", "null"], pattern: "^[A-Z]{2}$", required: true
17
+ input :amount, type: "string", required: true
18
+
19
+ protected
20
+
21
+ def call(variant_id:, currency:, country_iso:, amount:)
22
+ PriceUpdate.new(policy).call(variant_id: variant_id, currency: currency,
23
+ country_iso: country_iso, amount: amount) { authorize! }
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,161 @@
1
+ <%= javascript_tag nonce: true do %>
2
+ if (!customElements.get("solidus-mcp-account-search")) {
3
+ customElements.define("solidus-mcp-account-search", class extends HTMLElement {
4
+ connectedCallback() {
5
+ this.query = this.querySelector("[data-account-query]");
6
+ this.results = this.querySelector("[data-account-results]");
7
+ this.status = this.querySelector("[data-account-status]");
8
+ this.accountId = this.querySelector("[data-account-id]");
9
+ this.version = this.querySelector("[data-account-version]");
10
+ this.submit = this.closest("form").querySelector("[data-assign-account]");
11
+ this.items = [];
12
+ this.active = -1;
13
+ this.onFocus ||= () => this.search();
14
+ this.onBlur ||= () => this.close();
15
+ this.onInput ||= () => {
16
+ this.accountId.value = "";
17
+ this.version.value = "-1";
18
+ this.submit.disabled = true;
19
+ this.submit.value = "Assign MCP administrator";
20
+ this.query.setCustomValidity("Choose an account from the search results.");
21
+ this.items = [];
22
+ this.close();
23
+ this.status.textContent = "Searching accounts…";
24
+ this.timer = setTimeout(() => this.search(), 200);
25
+ };
26
+ this.onKeydown ||= event => this.handleKeydown(event);
27
+ this.onPointerdown ||= event => {
28
+ if (event.target.closest("[role=option]")) event.preventDefault();
29
+ };
30
+ this.onClick ||= event => {
31
+ const option = event.target.closest("[role=option]");
32
+ if (option) this.choose(Number(option.dataset.index));
33
+ };
34
+ this.query.addEventListener("focus", this.onFocus);
35
+ this.query.addEventListener("blur", this.onBlur);
36
+ this.query.addEventListener("input", this.onInput);
37
+ this.query.addEventListener("keydown", this.onKeydown);
38
+ this.results.addEventListener("pointerdown", this.onPointerdown);
39
+ this.results.addEventListener("click", this.onClick);
40
+ this.submit.disabled = !this.accountId.value;
41
+ this.query.setCustomValidity(this.accountId.value ? "" : "Choose an account from the search results.");
42
+ }
43
+
44
+ disconnectedCallback() {
45
+ this.close();
46
+ this.query.removeEventListener("focus", this.onFocus);
47
+ this.query.removeEventListener("blur", this.onBlur);
48
+ this.query.removeEventListener("input", this.onInput);
49
+ this.query.removeEventListener("keydown", this.onKeydown);
50
+ this.results.removeEventListener("pointerdown", this.onPointerdown);
51
+ this.results.removeEventListener("click", this.onClick);
52
+ }
53
+
54
+ async search() {
55
+ clearTimeout(this.timer);
56
+ this.request?.abort();
57
+ const request = this.request = new AbortController();
58
+ this.status.textContent = "Searching accounts…";
59
+ try {
60
+ const url = new URL(this.dataset.searchUrl, window.location.href);
61
+ url.searchParams.set("query", this.query.value);
62
+ const response = await fetch(url, {
63
+ headers: {Accept: "application/json"}, credentials: "same-origin", signal: request.signal
64
+ });
65
+ if (!response.ok) throw new Error("Search unavailable");
66
+ const result = await response.json();
67
+ if (request.signal.aborted || !this.isConnected) return;
68
+ this.items = result.items;
69
+ this.active = -1;
70
+ this.query.removeAttribute("aria-activedescendant");
71
+ this.results.replaceChildren();
72
+ this.items.forEach((item, index) => {
73
+ const option = document.createElement("li");
74
+ option.id = this.results.id + "-" + index;
75
+ option.setAttribute("role", "option");
76
+ option.setAttribute("aria-selected", "false");
77
+ option.dataset.index = index;
78
+ option.textContent = item.email;
79
+ if (item.lock_version >= 0) {
80
+ const note = document.createElement("small");
81
+ note.textContent = "Previously assigned — select to restore";
82
+ option.append(note);
83
+ }
84
+ this.results.append(option);
85
+ });
86
+ this.results.hidden = this.items.length === 0;
87
+ this.query.setAttribute("aria-expanded", String(this.items.length > 0));
88
+ this.status.textContent = !this.items.length ? "No eligible accounts found."
89
+ : result.more ? "Showing 20 matches. Keep typing to narrow the search."
90
+ : this.items.length + " matching account(s). Choose one to continue.";
91
+ if (this.openDirection && this.items.length) {
92
+ this.highlight(this.openDirection > 0 ? 0 : this.items.length - 1);
93
+ }
94
+ this.openDirection = null;
95
+ } catch (error) {
96
+ if (error.name !== "AbortError") {
97
+ this.results.hidden = true;
98
+ this.query.setAttribute("aria-expanded", "false");
99
+ this.status.textContent = "Account search is unavailable. Try again.";
100
+ }
101
+ }
102
+ }
103
+
104
+ handleKeydown(event) {
105
+ if (["ArrowDown", "ArrowUp"].includes(event.key)) {
106
+ event.preventDefault();
107
+ const direction = event.key === "ArrowDown" ? 1 : -1;
108
+ if (!this.items.length) {
109
+ this.openDirection = direction;
110
+ this.search();
111
+ } else {
112
+ this.results.hidden = false;
113
+ this.query.setAttribute("aria-expanded", "true");
114
+ const index = this.active < 0 ? (direction > 0 ? 0 : this.items.length - 1)
115
+ : (this.active + direction + this.items.length) % this.items.length;
116
+ this.highlight(index);
117
+ }
118
+ } else if (event.key === "Enter" && !this.results.hidden && this.active >= 0) {
119
+ event.preventDefault();
120
+ this.choose(this.active);
121
+ } else if (event.key === "Escape") {
122
+ event.preventDefault();
123
+ this.close();
124
+ }
125
+ }
126
+
127
+ highlight(index) {
128
+ this.active = index;
129
+ Array.from(this.results.children).forEach((option, position) => {
130
+ option.setAttribute("aria-selected", String(position === index));
131
+ });
132
+ const option = this.results.children[index];
133
+ this.query.setAttribute("aria-activedescendant", option.id);
134
+ option.scrollIntoView({block: "nearest"});
135
+ }
136
+
137
+ choose(index) {
138
+ const item = this.items[index];
139
+ if (!item) return;
140
+ this.query.value = item.email;
141
+ this.accountId.value = item.id;
142
+ this.version.value = item.lock_version;
143
+ this.submit.disabled = false;
144
+ this.submit.value = item.lock_version >= 0 ? "Restore MCP administrator" : "Assign MCP administrator";
145
+ this.query.setCustomValidity("");
146
+ this.status.textContent = item.email + " selected.";
147
+ this.close();
148
+ }
149
+
150
+ close() {
151
+ clearTimeout(this.timer);
152
+ this.request?.abort();
153
+ this.results.hidden = true;
154
+ this.query.setAttribute("aria-expanded", "false");
155
+ this.query.removeAttribute("aria-activedescendant");
156
+ this.active = -1;
157
+ this.openDirection = null;
158
+ }
159
+ });
160
+ }
161
+ <% end %>
@@ -0,0 +1,67 @@
1
+ <%= stylesheet_link_tag "solidus_mcp/admin", "data-turbo-track": "reload" %>
2
+ <div class="solidus-mcp-admin">
3
+ <%= link_to "AI connections", solidus_admin.mcp_connections_path %>
4
+ <h1 class="solidus-mcp-connection-title">MCP administrators</h1>
5
+ <p>Superadministrators assign MCP administrators. MCP administrators can manage ordinary MCP users' connections; only superadministrators can change privileged connections.</p>
6
+ <h2 class="solidus-mcp-audit-heading">Superadministrators</h2>
7
+ <p>These accounts are designated in server configuration and cannot be changed here. Solidus administrator status is still required.</p>
8
+ <ul>
9
+ <% @superadmins.each do |user| %>
10
+ <li><%= user.email %><%= " (not currently a Solidus administrator)" unless user.admin? %></li>
11
+ <% end %>
12
+ </ul>
13
+
14
+ <h2 class="solidus-mcp-audit-heading">Assign an MCP administrator</h2>
15
+ <%= form_with url: solidus_admin.mcp_administrators_path, method: :post, local: true, class: "solidus-mcp-role-form" do %>
16
+ <solidus-mcp-account-search data-search-url="<%= solidus_admin.search_mcp_administrators_path %>">
17
+ <%= label_tag "administrator_account_email", "Account login email" %>
18
+ <div class="solidus-mcp-account-field">
19
+ <%= search_field_tag nil, nil, id: "administrator_account_email", required: true,
20
+ autocomplete: "off", maxlength: 100, placeholder: "Search by email…", role: "combobox",
21
+ aria: {autocomplete: "list", expanded: "false", controls: "administrator_account_results"}, data: {account_query: true} %>
22
+ <ul id="administrator_account_results" role="listbox" aria-label="Matching administrator accounts" hidden data-account-results></ul>
23
+ </div>
24
+ <%= hidden_field_tag "administrator[user_id]", "", id: nil, data: {account_id: true} %>
25
+ <%= hidden_field_tag "administrator[lock_version]", "-1", id: nil, data: {account_version: true} %>
26
+ <p role="status" aria-live="polite" data-account-status>Search for an existing Solidus administrator, then choose an account.</p>
27
+ </solidus-mcp-account-search>
28
+ <%= submit_tag "Assign MCP administrator", disabled: true, data: {assign_account: true}, class: "solidus-mcp-button solidus-mcp-button-primary" %>
29
+ <%= render "solidus_admin/mcp_administrators/account_search" %>
30
+ <% end %>
31
+ <p>The account must already be a Solidus administrator. This assignment does not enable tools or expand OAuth consent.</p>
32
+
33
+ <h2 class="solidus-mcp-audit-heading">Assignments</h2>
34
+ <div class="solidus-mcp-table-scroll">
35
+ <table class="solidus-mcp-table">
36
+ <thead><tr><th scope="col">User</th><th scope="col">MCP access</th><th scope="col">Change role</th></tr></thead>
37
+ <tbody>
38
+ <% @administrators.each do |assignment| %>
39
+ <% user = @users[assignment.user_id] %>
40
+ <% configured_superadmin = SolidusMcp::Administration.superadmin_user_ids.include?(assignment.user_id.to_s) %>
41
+ <tr>
42
+ <td><%= user&.email || "Deleted account" %></td>
43
+ <td><%= configured_superadmin ? "Superadministrator (server configuration)" : (assignment.enabled? && user&.admin? ? "MCP administrator" : "MCP user or inactive account") %></td>
44
+ <td>
45
+ <% unless configured_superadmin %>
46
+ <%= form_with url: solidus_admin.mcp_administrator_path(assignment), method: :patch, local: true do %>
47
+ <%= hidden_field_tag "administrator[lock_version]", assignment.lock_version, id: nil %>
48
+ <%= hidden_field_tag "administrator[enabled]", assignment.enabled? ? "0" : "1", id: nil %>
49
+ <%= submit_tag(assignment.enabled? ? "Remove MCP administrator" : "Restore MCP administrator", class: "solidus-mcp-button") %>
50
+ <% end %>
51
+ <% end %>
52
+ </td>
53
+ </tr>
54
+ <% end %>
55
+ </tbody>
56
+ </table>
57
+ </div>
58
+ <% if @administrators.empty? %><p>No MCP administrators have been assigned.</p><% end %>
59
+ <nav class="solidus-mcp-pagination" aria-label="Administrator assignment pages">
60
+ <% if @page > 1 %><%= link_to "Previous", solidus_admin.mcp_administrators_path(page: @page - 1) %><% end %>
61
+ <% if @more %><%= link_to "Next", solidus_admin.mcp_administrators_path(page: @page + 1) %><% end %>
62
+ </nav>
63
+ <h2 class="solidus-mcp-audit-heading">Recent role changes</h2>
64
+ <% @changes.each do |change| %>
65
+ <p><%= change.created_at.iso8601 %> — superadministrator <%= @audit_users[change.actor_id]&.email || "Deleted account" %> <%= change.enabled? ? "assigned" : "removed" %> MCP administrator access for <%= @audit_users[change.administrator.user_id]&.email || "Deleted account" %>.</p>
66
+ <% end %>
67
+ </div>
@@ -0,0 +1,51 @@
1
+ <%= javascript_tag nonce: true do %>
2
+ if (!customElements.get("solidus-mcp-permissions")) {
3
+ customElements.define("solidus-mcp-permissions", class extends HTMLElement {
4
+ connectedCallback() {
5
+ this.onChange ||= this.handleChange.bind(this);
6
+ this.addEventListener("change", this.onChange);
7
+ this.querySelectorAll("[data-selection-control]").forEach(control => {
8
+ control.hidden = false;
9
+ });
10
+ this.syncSelection();
11
+ }
12
+
13
+ disconnectedCallback() {
14
+ this.removeEventListener("change", this.onChange);
15
+ }
16
+
17
+ permissions(container = this) {
18
+ return Array.from(container.querySelectorAll("input[data-permission]:not(:disabled)"));
19
+ }
20
+
21
+ handleChange(event) {
22
+ const control = event.target;
23
+ if (control.matches("[data-select-all], [data-select-group]")) {
24
+ const container = control.matches("[data-select-group]")
25
+ ? control.closest("[data-permission-group]")
26
+ : this;
27
+ this.permissions(container).forEach(permission => {
28
+ permission.checked = control.checked;
29
+ });
30
+ }
31
+ this.syncSelection();
32
+ }
33
+
34
+ syncSelection() {
35
+ this.syncControl(this.querySelector("[data-select-all]"), this);
36
+ this.querySelectorAll("[data-permission-group]").forEach(group => {
37
+ this.syncControl(group.querySelector("[data-select-group]"), group);
38
+ });
39
+ }
40
+
41
+ syncControl(control, container) {
42
+ if (!control) return;
43
+ const permissions = this.permissions(container);
44
+ const selected = permissions.filter(permission => permission.checked).length;
45
+ control.checked = permissions.length > 0 && selected === permissions.length;
46
+ control.indeterminate = selected > 0 && selected < permissions.length;
47
+ control.disabled = permissions.length === 0;
48
+ }
49
+ });
50
+ }
51
+ <% end %>
@@ -0,0 +1,54 @@
1
+ <solidus-mcp-permissions>
2
+ <div class="solidus-mcp-permissions-heading">
3
+ <h2 id="tool-permissions-heading">Tool permissions</h2>
4
+ <% if manage_connection_permissions? %>
5
+ <label data-selection-control hidden>
6
+ <input type="checkbox" data-select-all>
7
+ Select all permissions
8
+ </label>
9
+ <% end %>
10
+ </div>
11
+ <div class="solidus-mcp-permissions-scroll">
12
+ <table class="solidus-mcp-table" aria-labelledby="tool-permissions-heading">
13
+ <thead>
14
+ <tr>
15
+ <th scope="col">Permission</th>
16
+ <th scope="col">Description</th>
17
+ <th scope="col">Access</th>
18
+ <th scope="col">OAuth authorization</th>
19
+ </tr>
20
+ </thead>
21
+ <% @registry.tools.values.group_by(&:category).each do |category, tools| %>
22
+ <tbody data-permission-group>
23
+ <tr class="solidus-mcp-permissions-category">
24
+ <th scope="rowgroup" colspan="4">
25
+ <div class="solidus-mcp-permissions-group-heading">
26
+ <span><%= category %></span>
27
+ <% if manage_connection_permissions? %>
28
+ <label data-selection-control hidden>
29
+ <input type="checkbox" data-select-group aria-label="Select all <%= category %> permissions">
30
+ Select all
31
+ </label>
32
+ <% end %>
33
+ </div>
34
+ </th>
35
+ </tr>
36
+ <% tools.each do |tool| %>
37
+ <tr>
38
+ <th scope="row">
39
+ <label>
40
+ <%= check_box_tag "connection_policy[permissions][]", tool.permission, @policy.permissions.include?(tool.permission), id: "permission_#{tool.name}", data: {permission: true}, disabled: !manage_connection_permissions? %>
41
+ <span><%= tool.name.humanize %></span>
42
+ </label>
43
+ </th>
44
+ <td><%= tool.description %></td>
45
+ <td><%= tool.read_only ? "Read" : "Write" %></td>
46
+ <td><%= @scope_status.warning(tool) || "Authorized by active tokens" %></td>
47
+ </tr>
48
+ <% end %>
49
+ </tbody>
50
+ <% end %>
51
+ </table>
52
+ </div>
53
+ </solidus-mcp-permissions>
54
+ <%= render "solidus_admin/mcp_connections/permission_selection" if manage_connection_permissions? %>
@@ -0,0 +1,59 @@
1
+ <%= stylesheet_link_tag "solidus_mcp/admin", "data-turbo-track": "reload" %>
2
+ <div class="solidus-mcp-admin">
3
+ <%= link_to "All AI connections", solidus_admin.mcp_connections_path, class: "solidus-mcp-link" %>
4
+ <h1 class="solidus-mcp-connection-title"><%= @application.name %></h1>
5
+ <p class="solidus-mcp-connected-by">Connected by <strong><%= @user.email %></strong></p>
6
+ <p class="solidus-mcp-notice">Changes apply to this person's connection, including refreshed tokens. An unchecked permission blocks every tool that needs it.</p>
7
+ <p class="solidus-mcp-intro">Scopes on active tokens (tokens may differ): <%= @scopes.presence&.join(", ") || "No unexpired access tokens. Reconnect or refresh to use this connection." %>. Enabling a permission here does not add scopes to an existing token.</p>
8
+
9
+ <% if @policy.deleted_at? %>
10
+ <p class="solidus-mcp-notice">This permission set was deleted and access is disabled. <%= manage_connection_permissions? ? "To restore it, choose permissions, enable the connection, and select Restore permission set." : "Ask an MCP administrator to restore it." %> OAuth scope limits still apply.</p>
11
+ <% end %>
12
+
13
+ <% if manage_connection_permissions? %>
14
+ <%= form_with url: solidus_admin.mcp_connection_path(@application, user_id: @user.id), method: :patch, local: true do %>
15
+ <%= hidden_field_tag "connection_policy[lock_version]", @policy.persisted? ? @policy.lock_version : -1 %>
16
+ <%= hidden_field_tag "connection_policy[enabled]", "0", id: nil %>
17
+ <label class="solidus-mcp-connection-toggle">
18
+ <%= check_box_tag "connection_policy[enabled]", "1", @policy.enabled? %>
19
+ Connection enabled
20
+ </label>
21
+ <%= hidden_field_tag "connection_policy[permissions][]", "", id: nil %>
22
+ <%= render "solidus_admin/mcp_connections/permissions" %>
23
+ <%= submit_tag(@policy.deleted_at? ? "Restore permission set" : "Save permissions", class: "solidus-mcp-button solidus-mcp-button-primary") %>
24
+ <% end %>
25
+ <% else %>
26
+ <p class="solidus-mcp-notice"><%= mcp_administrator? ? "This is a privileged connection. An MCP superadministrator manages its permissions, including your own grants." : "You are an MCP user. An MCP administrator manages your tool permissions and connection access." %></p>
27
+ <p><strong>Connection status:</strong> <%= @policy.deleted_at? ? "Deleted" : (@policy.enabled? ? "Enabled" : "Disabled") %></p>
28
+ <%= render "solidus_admin/mcp_connections/permissions" %>
29
+ <% end %>
30
+
31
+ <% if manage_connection_permissions? && !@policy.deleted_at? %>
32
+ <section class="solidus-mcp-section" aria-labelledby="delete-permissions-heading">
33
+ <h2 id="delete-permissions-heading">Delete permission set</h2>
34
+ <p>Remove all tool permissions and disable this connection. The permission set moves to Deleted permission sets; its audit history is retained. Other users and OAuth client registrations are unaffected.</p>
35
+ <%= form_with url: solidus_admin.mcp_connection_path(@application, user_id: @user.id), method: :delete, local: true, data: {turbo_confirm: "Delete this permission set and disable its access?"} do %>
36
+ <%= hidden_field_tag "connection_policy[lock_version]", @policy.persisted? ? @policy.lock_version : -1, id: nil %>
37
+ <%= submit_tag "Delete permission set", class: "solidus-mcp-button" %>
38
+ <% end %>
39
+ </section>
40
+ <% end %>
41
+
42
+ <section class="solidus-mcp-section" aria-labelledby="reconnect-heading">
43
+ <h2 id="reconnect-heading">Reconnect to authorize additional access</h2>
44
+ <p>Saving permissions here does not expand OAuth consent. The person who connected this application must start Reconnect or Authorize again in their MCP client, request the required scopes, and approve them on the store's consent screen. Then refresh the client's tool list.</p>
45
+ <p>Refreshing an existing token cannot add scopes. An expired token can be refreshed only with the access previously authorized. Reconnecting does not automatically enable any store permissions.</p>
46
+ </section>
47
+
48
+ <h2 class="solidus-mcp-audit-heading">Recent permission changes</h2>
49
+ <% if @changes.empty? %><p>No permission changes recorded.</p><% end %>
50
+ <% @changes.each do |change| %>
51
+ <details class="solidus-mcp-audit-entry">
52
+ <summary><%= change.created_at.iso8601 %> — administrator #<%= change.actor_id %></summary>
53
+ <% if change.new_values["deleted_at"].present? %><p>Permission set deleted.</p><% elsif change.previous_values["deleted_at"].present? %><p>Permission set restored.</p><% end %>
54
+ <p>Enabled: <%= change.previous_values["enabled"] %> → <%= change.new_values["enabled"] %></p>
55
+ <p>Removed: <%= (change.previous_values["permissions"] - change.new_values["permissions"]).join(", ").presence || "None" %></p>
56
+ <p>Added: <%= (change.new_values["permissions"] - change.previous_values["permissions"]).join(", ").presence || "None" %></p>
57
+ </details>
58
+ <% end %>
59
+ </div>
@@ -0,0 +1,48 @@
1
+ <%= stylesheet_link_tag "solidus_mcp/admin", "data-turbo-track": "reload" %>
2
+ <div class="solidus-mcp-admin">
3
+ <h1 class="solidus-mcp-page-title"><%= @show_deleted ? "Deleted permission sets" : "AI connections" %></h1>
4
+ <p class="solidus-mcp-intro"><%= if mcp_superadministrator?
5
+ "You are an MCP superadministrator. Manage all connections and assign MCP administrators."
6
+ elsif mcp_administrator?
7
+ "You are an MCP administrator. Manage MCP users' connections; your own permissions require a superadministrator."
8
+ else
9
+ "You are an MCP user. Only your own connections are shown; an MCP administrator manages their permissions."
10
+ end %> Permissions also remain limited by the connection owner's store access and OAuth consent.</p>
11
+
12
+ <% if mcp_superadministrator? %>
13
+ <p><%= link_to "Manage MCP administrators", solidus_admin.mcp_administrators_path %></p>
14
+ <% end %>
15
+
16
+ <p class="solidus-mcp-notice">
17
+ <% if @show_deleted %>
18
+ <%= link_to "Active connections", solidus_admin.mcp_connections_path, class: "solidus-mcp-link" %>
19
+ <% else %>
20
+ <%= link_to "Deleted permission sets", solidus_admin.mcp_connections_path(deleted: "1"), class: "solidus-mcp-link" %>
21
+ <% end %>
22
+ </p>
23
+
24
+ <div class="solidus-mcp-table-scroll">
25
+ <table class="solidus-mcp-table solidus-mcp-connections-table">
26
+ <thead><tr><th scope="col">Application</th><th scope="col">Connected by</th><th scope="col">Authorization</th><th scope="col">Permissions</th><th scope="col"><%= mcp_administrator? ? "Manage" : "View" %></th></tr></thead>
27
+ <tbody>
28
+ <% @connections.each do |connection| %>
29
+ <% application = @applications[connection.application_id]; user = @users[connection.resource_owner_id] %>
30
+ <% next unless application && user %>
31
+ <% policy = @policies[[application.id, user.id]] %>
32
+ <tr>
33
+ <td><%= application.name %></td>
34
+ <td><%= user.email %></td>
35
+ <td><%= @active_connections.include?([application.id, user.id]) ? "Active token" : "No active token" %></td>
36
+ <td><%= policy ? (policy.deleted_at? ? "Deleted" : (policy.enabled? ? "Custom permissions" : "Disabled")) : "No tools enabled" %></td>
37
+ <td><%= link_to(can_manage_connection?(user) ? (@show_deleted ? "Review or restore" : "Edit permissions") : "View permissions", solidus_admin.edit_mcp_connection_path(application, user_id: user.id), class: "solidus-mcp-link") %></td>
38
+ </tr>
39
+ <% end %>
40
+ </tbody>
41
+ </table>
42
+ </div>
43
+ <% if @connections.empty? %><p class="solidus-mcp-empty-state"><%= @show_deleted ? "No deleted permission sets." : "No active connections. Connect an MCP client or review deleted permission sets." %></p><% end %>
44
+ <nav class="solidus-mcp-pagination" aria-label="Connections pages">
45
+ <% if @page > 1 %><%= link_to "Previous", solidus_admin.mcp_connections_path(page: @page - 1, deleted: @show_deleted ? "1" : nil) %><% end %>
46
+ <% if @more %><%= link_to "Next", solidus_admin.mcp_connections_path(page: @page + 1, deleted: @show_deleted ? "1" : nil) %><% end %>
47
+ </nav>
48
+ </div>