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,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>
|