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,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SolidusMcp
|
|
4
|
+
class RegistrationsController < ActionController::API
|
|
5
|
+
include HttpAccess
|
|
6
|
+
|
|
7
|
+
MAX_BODY_BYTES = 16.kilobytes
|
|
8
|
+
|
|
9
|
+
wrap_parameters false
|
|
10
|
+
before_action :prepare_registration
|
|
11
|
+
rate_limit to: 20, within: 1.hour, by: -> { "per-ip:#{request.remote_ip}" }, store: ActionController::Base.cache_store,
|
|
12
|
+
with: -> { registration_throttled }
|
|
13
|
+
rate_limit to: 200, within: 1.hour, by: -> { "global" }, store: ActionController::Base.cache_store,
|
|
14
|
+
with: -> { registration_throttled }
|
|
15
|
+
|
|
16
|
+
def create
|
|
17
|
+
return render_error("invalid_client_metadata", "Use application/json", :unsupported_media_type) unless request.media_type == "application/json"
|
|
18
|
+
|
|
19
|
+
body = request.body.read(MAX_BODY_BYTES + 1)
|
|
20
|
+
return render_error("invalid_client_metadata", "Registration metadata is too large", :payload_too_large) if body.bytesize > MAX_BODY_BYTES
|
|
21
|
+
|
|
22
|
+
render json: ClientRegistration.call(JSON.parse(body)), status: :created
|
|
23
|
+
rescue JSON::ParserError
|
|
24
|
+
render_error("invalid_client_metadata", "Expected a JSON object")
|
|
25
|
+
rescue ClientRegistration::InvalidMetadata => e
|
|
26
|
+
render_error(e.code, e.message)
|
|
27
|
+
rescue ActiveRecord::RecordInvalid
|
|
28
|
+
render_error("invalid_client_metadata", "Client metadata could not be registered")
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
def prepare_registration
|
|
34
|
+
response.set_header("Cache-Control", "no-store")
|
|
35
|
+
response.set_header("Pragma", "no-cache")
|
|
36
|
+
head :not_found unless SolidusMcp.configuration.enabled?
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def registration_throttled
|
|
40
|
+
response.set_header("Retry-After", "3600")
|
|
41
|
+
render_error("temporarily_unavailable", "Too many client registrations; try again later", :too_many_requests)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def render_error(code, description, status = :bad_request)
|
|
45
|
+
render json: {error: code, error_description: description}, status: status
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SolidusMcp
|
|
4
|
+
class TokensController < Doorkeeper::TokensController
|
|
5
|
+
include HttpAccess
|
|
6
|
+
before_action :validate_resource
|
|
7
|
+
|
|
8
|
+
private
|
|
9
|
+
|
|
10
|
+
def validate_resource
|
|
11
|
+
return if action_name == "revoke"
|
|
12
|
+
expected = SolidusMcp.configuration.endpoint_url
|
|
13
|
+
return render json: {error: "invalid_target"}, status: :bad_request unless params[:resource] == expected
|
|
14
|
+
source = case params[:grant_type]
|
|
15
|
+
when "authorization_code" then Doorkeeper::AccessGrant.by_token(params[:code])
|
|
16
|
+
when "refresh_token" then Doorkeeper::AccessToken.by_refresh_token(params[:refresh_token])
|
|
17
|
+
end
|
|
18
|
+
if source && source.resource != expected
|
|
19
|
+
render json: {error: "invalid_grant"}, status: :bad_request
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SolidusMcp
|
|
4
|
+
class Administrator < ActiveRecord::Base
|
|
5
|
+
self.table_name = "solidus_mcp_administrators"
|
|
6
|
+
attr_readonly :user_id
|
|
7
|
+
belongs_to :user, class_name: Spree.user_class.name
|
|
8
|
+
has_many :role_changes, class_name: "SolidusMcp::AdministratorChange", dependent: :restrict_with_exception
|
|
9
|
+
|
|
10
|
+
validates :user_id, uniqueness: true
|
|
11
|
+
validates :enabled, inclusion: {in: [true, false]}
|
|
12
|
+
|
|
13
|
+
def change!(actor:, enabled:, lock_version:)
|
|
14
|
+
transaction do
|
|
15
|
+
Administration.lock_users!(actor, user)
|
|
16
|
+
Administration.authorize_assignment!(actor, user)
|
|
17
|
+
unless [true, false].include?(enabled)
|
|
18
|
+
raise ArgumentError, "enabled must be true or false"
|
|
19
|
+
end
|
|
20
|
+
if enabled && !user.admin?
|
|
21
|
+
errors.add(:user, "must be a Solidus administrator")
|
|
22
|
+
raise ActiveRecord::RecordInvalid.new(self)
|
|
23
|
+
end
|
|
24
|
+
if new_record? != (lock_version == -1)
|
|
25
|
+
raise ActiveRecord::StaleObjectError.new(self, "update")
|
|
26
|
+
end
|
|
27
|
+
previous = persisted? ? enabled_in_database : false
|
|
28
|
+
self.lock_version = new_record? ? 0 : lock_version
|
|
29
|
+
self.enabled = enabled
|
|
30
|
+
save!
|
|
31
|
+
role_changes.create!(actor_id: actor.id, previous_enabled: previous, enabled: enabled) if previous != enabled
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SolidusMcp
|
|
4
|
+
class ConnectionPolicy < ActiveRecord::Base
|
|
5
|
+
self.table_name = "solidus_mcp_connection_policies"
|
|
6
|
+
belongs_to :application, class_name: "Doorkeeper::Application"
|
|
7
|
+
belongs_to :user, class_name: Spree.user_class.name
|
|
8
|
+
has_many :policy_changes, class_name: "SolidusMcp::PolicyChange", dependent: :restrict_with_exception
|
|
9
|
+
|
|
10
|
+
validates :user_id, uniqueness: {scope: :application_id}
|
|
11
|
+
validates :enabled, inclusion: {in: [true, false]}
|
|
12
|
+
validates :application_uid, presence: true
|
|
13
|
+
validate :known_permissions
|
|
14
|
+
before_validation :retain_application_uid, on: :create
|
|
15
|
+
|
|
16
|
+
# Management writes and their audit must commit together. lock_version
|
|
17
|
+
# prevents an older form from restoring permissions another admin removed.
|
|
18
|
+
def change!(actor:, permissions:, enabled:, lock_version:, deleted_at: nil)
|
|
19
|
+
transaction do
|
|
20
|
+
Administration.lock_users!(actor, user)
|
|
21
|
+
Administration.authorize_management!(actor, owner: user)
|
|
22
|
+
# -1 identifies the unsaved default policy. Version 0 must not let a
|
|
23
|
+
# default-policy form overwrite someone else's first explicit policy.
|
|
24
|
+
if new_record? != (lock_version == -1)
|
|
25
|
+
raise ActiveRecord::StaleObjectError.new(self, "update")
|
|
26
|
+
end
|
|
27
|
+
self.lock_version = new_record? ? 0 : lock_version
|
|
28
|
+
previous = persisted? ? attributes.slice("permissions", "enabled", "deleted_at") :
|
|
29
|
+
{"permissions" => [], "enabled" => true, "deleted_at" => nil}
|
|
30
|
+
assign_attributes(permissions: permissions.uniq.sort, enabled: enabled, deleted_at: deleted_at)
|
|
31
|
+
save!
|
|
32
|
+
current = attributes.slice("permissions", "enabled", "deleted_at")
|
|
33
|
+
policy_changes.create!(actor_id: actor.id, previous_values: previous, new_values: current) if previous != current
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def delete_permissions!(actor:, lock_version:)
|
|
38
|
+
Administration.authorize_management!(actor, owner: user, action: :destroy)
|
|
39
|
+
change!(actor: actor, permissions: [], enabled: false, lock_version: lock_version, deleted_at: Time.current)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
def retain_application_uid
|
|
45
|
+
self.application_uid = application&.uid
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def known_permissions
|
|
49
|
+
valid = permissions.is_a?(Array) && permissions.all? { |key| key.is_a?(String) && (SolidusMcp.registry.permissions.include?(key) || Array(permissions_in_database).include?(key)) }
|
|
50
|
+
errors.add(:permissions, "contain an unknown operation") unless valid
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SolidusMcp
|
|
4
|
+
class Credential
|
|
5
|
+
SCOPES = %w[mcp:read mcp:write].freeze
|
|
6
|
+
attr_reader :token, :user
|
|
7
|
+
|
|
8
|
+
def self.authenticate(bearer)
|
|
9
|
+
return if bearer.blank?
|
|
10
|
+
token = Doorkeeper::AccessToken.by_token(bearer)
|
|
11
|
+
return unless token
|
|
12
|
+
user = Spree.user_class.find_by(id: token.resource_owner_id)
|
|
13
|
+
credential = new(token, user)
|
|
14
|
+
credential if credential.active?
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def initialize(token, user)
|
|
18
|
+
@token = token
|
|
19
|
+
@user = user
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def active?
|
|
23
|
+
token.reload
|
|
24
|
+
user&.reload
|
|
25
|
+
token.accessible? && token.application.present? && Administration.user?(user) &&
|
|
26
|
+
token.resource == SolidusMcp.configuration.endpoint_url &&
|
|
27
|
+
(token.scopes.to_a & SCOPES).any?
|
|
28
|
+
rescue ActiveRecord::RecordNotFound
|
|
29
|
+
false
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def permits?(scope)
|
|
33
|
+
token.scopes.include?(scope)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SolidusMcp
|
|
4
|
+
class PolicyChange < ActiveRecord::Base
|
|
5
|
+
self.table_name = "solidus_mcp_policy_changes"
|
|
6
|
+
belongs_to :connection_policy, class_name: "SolidusMcp::ConnectionPolicy"
|
|
7
|
+
belongs_to :actor, class_name: Spree.user_class.name
|
|
8
|
+
end
|
|
9
|
+
end
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SolidusMcp
|
|
4
|
+
# RFC 7591 registration creates a client, never a grant or an access token.
|
|
5
|
+
# Doorkeeper still handles admin consent, PKCE and all token operations.
|
|
6
|
+
class ClientRegistration
|
|
7
|
+
class InvalidMetadata < StandardError
|
|
8
|
+
attr_reader :code
|
|
9
|
+
|
|
10
|
+
def initialize(message, code: "invalid_client_metadata")
|
|
11
|
+
@code = code
|
|
12
|
+
super(message)
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def self.call(metadata)
|
|
17
|
+
new(metadata).call
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def initialize(metadata)
|
|
21
|
+
@metadata = metadata
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def call
|
|
25
|
+
invalid!("Expected a JSON object") unless @metadata.is_a?(Hash)
|
|
26
|
+
redirects = redirect_uris
|
|
27
|
+
name = @metadata.fetch("client_name", "MCP client")
|
|
28
|
+
unless name.is_a?(String) && name.strip.present? && name.length <= 128 && !name.match?(/[[:cntrl:]]/)
|
|
29
|
+
invalid!("client_name must be a nonempty string of at most 128 characters")
|
|
30
|
+
end
|
|
31
|
+
auth_method = @metadata.fetch("token_endpoint_auth_method", "client_secret_basic")
|
|
32
|
+
unless %w[none client_secret_basic client_secret_post].include?(auth_method)
|
|
33
|
+
invalid!("Unsupported token_endpoint_auth_method")
|
|
34
|
+
end
|
|
35
|
+
grants = @metadata.fetch("grant_types", ["authorization_code"])
|
|
36
|
+
unless grants.is_a?(Array) && grants.include?("authorization_code") && (grants - %w[authorization_code refresh_token]).empty?
|
|
37
|
+
invalid!("Only authorization_code and refresh_token grants are supported")
|
|
38
|
+
end
|
|
39
|
+
invalid!("Only the code response type is supported") unless @metadata.fetch("response_types", ["code"]) == ["code"]
|
|
40
|
+
application_type = @metadata["application_type"]
|
|
41
|
+
if @metadata.key?("application_type") && !%w[native web].include?(application_type)
|
|
42
|
+
invalid!("application_type must be native or web")
|
|
43
|
+
end
|
|
44
|
+
if application_type == "web" && redirects.any? { |value| URI(value).scheme != "https" }
|
|
45
|
+
invalid!("Web clients require HTTPS callbacks", code: "invalid_redirect_uri")
|
|
46
|
+
end
|
|
47
|
+
scope = @metadata.fetch("scope", Credential::SCOPES.join(" "))
|
|
48
|
+
unless scope.is_a?(String) && scope.split.present? && (scope.split - Credential::SCOPES).empty?
|
|
49
|
+
invalid!("scope must contain only supported store scopes")
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Select fields explicitly. Caller-supplied IDs, secrets and privileged
|
|
53
|
+
# model attributes are never assigned, and metadata URLs are never fetched.
|
|
54
|
+
client = Doorkeeper::Application.create!(name: name.strip, redirect_uri: redirects,
|
|
55
|
+
confidential: auth_method != "none", scopes: scope.split.uniq.join(" "))
|
|
56
|
+
result = {client_id: client.uid, client_id_issued_at: client.created_at.to_i,
|
|
57
|
+
client_name: client.name, redirect_uris: redirects,
|
|
58
|
+
token_endpoint_auth_method: auth_method, grant_types: grants.uniq,
|
|
59
|
+
response_types: ["code"], scope: client.scopes.to_s}
|
|
60
|
+
result[:application_type] = application_type if application_type
|
|
61
|
+
if client.confidential?
|
|
62
|
+
result[:client_secret] = client.plaintext_secret
|
|
63
|
+
result[:client_secret_expires_at] = 0
|
|
64
|
+
end
|
|
65
|
+
result
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
private
|
|
69
|
+
|
|
70
|
+
def redirect_uris
|
|
71
|
+
values = @metadata["redirect_uris"]
|
|
72
|
+
unless values.is_a?(Array) && values.length.between?(1, 10) && values.all? { |value| valid_redirect?(value) }
|
|
73
|
+
invalid!("Provide 1 to 10 HTTPS or literal loopback HTTP callback URLs without credentials, fragments or wildcards",
|
|
74
|
+
code: "invalid_redirect_uri")
|
|
75
|
+
end
|
|
76
|
+
values.uniq
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def valid_redirect?(value)
|
|
80
|
+
return false unless value.is_a?(String) && value.bytesize.between?(1, 2048)
|
|
81
|
+
return false if value.match?(/[[:space:][:cntrl:]*\\]/)
|
|
82
|
+
|
|
83
|
+
uri = URI.parse(value)
|
|
84
|
+
uri.host.present? && uri.userinfo.nil? && uri.fragment.nil? &&
|
|
85
|
+
(uri.scheme == "https" || (uri.scheme == "http" && %w[127.0.0.1 ::1].include?(uri.hostname)))
|
|
86
|
+
rescue URI::InvalidURIError
|
|
87
|
+
false
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def invalid!(message, code: "invalid_client_metadata")
|
|
91
|
+
raise InvalidMetadata.new(message, code: code)
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SolidusMcp
|
|
4
|
+
# Shared only by the two pricing tools. Updates an exact existing context;
|
|
5
|
+
# it never guesses a price, creates a row, or bypasses Solidus validations.
|
|
6
|
+
class PriceUpdate
|
|
7
|
+
def initialize(policy)
|
|
8
|
+
@policy = policy
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def call(variant_id:, currency:, country_iso:, amount:)
|
|
12
|
+
valid_context = currency.match?(/\A[A-Z]{3}\z/) && (country_iso.nil? || country_iso.match?(/\A[A-Z]{2}\z/))
|
|
13
|
+
raise ToolError, "Invalid currency or country context" unless valid_context
|
|
14
|
+
unless amount.is_a?(String) && amount.match?(/\A(?:0|[1-9]\d{0,7})(?:\.\d{1,2})?\z/)
|
|
15
|
+
raise ToolError, "Amount must be a nonnegative decimal string with at most two decimal places"
|
|
16
|
+
end
|
|
17
|
+
unless Spree::Config.pricing_options_class == Spree::Variant::PricingOptions
|
|
18
|
+
raise ToolError, "Custom pricing options require a store-specific pricing tool"
|
|
19
|
+
end
|
|
20
|
+
variant = @policy.scope(Spree::Variant, :update)
|
|
21
|
+
.where(product_id: @policy.scope(Spree::Product, :update).select(:id)).find(variant_id)
|
|
22
|
+
prices = @policy.scope(Spree::Price, :update).where(variant_id: variant.id,
|
|
23
|
+
currency: currency, country_iso: country_iso).limit(2).to_a
|
|
24
|
+
raise ToolError, "Price context is unavailable or ambiguous" unless prices.one?
|
|
25
|
+
price = prices.first
|
|
26
|
+
price.with_lock do
|
|
27
|
+
yield
|
|
28
|
+
@policy.authorize_record!(:update, variant.product)
|
|
29
|
+
@policy.authorize_record!(:update, variant)
|
|
30
|
+
@policy.authorize_record!(:update, price)
|
|
31
|
+
unless price.variant_id == variant_id && price.currency == currency && price.country_iso == country_iso
|
|
32
|
+
raise ToolError, "Price context changed; retrieve prices again"
|
|
33
|
+
end
|
|
34
|
+
price.assign_attributes(amount: BigDecimal(amount))
|
|
35
|
+
@policy.authorize_record!(:update, price)
|
|
36
|
+
price.save!
|
|
37
|
+
end
|
|
38
|
+
{price_id: price.id, variant_id: variant.id, currency: price.currency,
|
|
39
|
+
country_iso: price.country_iso, amount: price.amount.to_s("F")}
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SolidusMcp
|
|
4
|
+
# Fixed public representations. Related records are checked separately.
|
|
5
|
+
class Serializers
|
|
6
|
+
def initialize(policy)
|
|
7
|
+
@policy = policy
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def product(record)
|
|
11
|
+
@policy.authorize_record!(:read, record)
|
|
12
|
+
record.attributes.slice("id", "name", "slug", "description", "meta_title", "meta_description", "meta_keywords")
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def variant(record)
|
|
16
|
+
@policy.authorize_record!(:read, record.product)
|
|
17
|
+
@policy.authorize_record!(:read, record)
|
|
18
|
+
record.attributes.slice("id", "product_id", "sku", "is_master")
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def price(record)
|
|
22
|
+
variant(record.variant)
|
|
23
|
+
@policy.authorize_record!(:read, record)
|
|
24
|
+
{id: record.id, variant_id: record.variant_id, currency: record.currency,
|
|
25
|
+
country_iso: record.country_iso, amount: record.amount&.to_s("F")}
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def inventory(record)
|
|
29
|
+
variant(record.variant)
|
|
30
|
+
@policy.authorize_record!(:read, record.stock_location)
|
|
31
|
+
@policy.authorize_record!(:read, record)
|
|
32
|
+
{id: record.id, variant_id: record.variant_id, sku: record.variant.sku,
|
|
33
|
+
stock_location_id: record.stock_location_id, count_on_hand: record.count_on_hand,
|
|
34
|
+
backorderable: record.backorderable}
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def order(record)
|
|
38
|
+
@policy.authorize_record!(:read, record)
|
|
39
|
+
status(record).merge(currency: record.currency, total: record.total.to_s("F"),
|
|
40
|
+
item_total: record.item_total.to_s("F"), completed_at: record.completed_at&.iso8601)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def status(record)
|
|
44
|
+
@policy.authorize_record!(:read, record)
|
|
45
|
+
{id: record.id, number: record.number, state: record.state,
|
|
46
|
+
payment_state: record.payment_state, shipment_state: record.shipment_state}
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SolidusMcp
|
|
4
|
+
module Tools
|
|
5
|
+
class Base < Tool
|
|
6
|
+
class << self
|
|
7
|
+
def pagination
|
|
8
|
+
input :after_id, type: "integer", minimum: 0
|
|
9
|
+
input :limit, type: "integer", minimum: 1, maximum: 100
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
protected
|
|
14
|
+
|
|
15
|
+
def serializers
|
|
16
|
+
@serializers ||= Serializers.new(policy)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def page(relation, after_id:, limit:)
|
|
20
|
+
table = relation.klass.arel_table
|
|
21
|
+
records = relation.where(table[:id].gt(after_id)).reorder(table[:id].asc).limit(limit + 1).to_a
|
|
22
|
+
more = records.length > limit
|
|
23
|
+
records = records.first(limit)
|
|
24
|
+
{items: records.map { |record| yield record }, next_after_id: more ? records.last.id : nil}
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def variants
|
|
28
|
+
policy.scope(Spree::Variant).where(product_id: policy.scope(Spree::Product).select(:id))
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def stock_items
|
|
32
|
+
policy.scope(Spree::StockItem).where(variant_id: variants.select(:id),
|
|
33
|
+
stock_location_id: policy.scope(Spree::StockLocation).select(:id))
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SolidusMcp
|
|
4
|
+
module Tools
|
|
5
|
+
class BulkUpdateVariantPrices < Base
|
|
6
|
+
tool_name :bulk_update_variant_prices
|
|
7
|
+
description "Update up to 100 SKU prices independently. Each entry specifies amount, currency and country_iso (null for general-country pricing). Returns a result for every entry."
|
|
8
|
+
category "Pricing"
|
|
9
|
+
permission "bulk_update_variant_prices"
|
|
10
|
+
read_only false
|
|
11
|
+
ability :update, "Spree::Product"
|
|
12
|
+
ability :update, "Spree::Variant"
|
|
13
|
+
ability :update, "Spree::Price"
|
|
14
|
+
input :entries, type: "array", minItems: 1, maxItems: 100, required: true,
|
|
15
|
+
items: {type: "object", additionalProperties: false,
|
|
16
|
+
required: %w[sku amount currency country_iso],
|
|
17
|
+
properties: {sku: {type: "string", minLength: 1, maxLength: 255},
|
|
18
|
+
amount: {type: "string"}, currency: {type: "string"},
|
|
19
|
+
country_iso: {type: ["string", "null"]}}}
|
|
20
|
+
|
|
21
|
+
protected
|
|
22
|
+
|
|
23
|
+
def call(entries:)
|
|
24
|
+
entries = entries.map(&:stringify_keys)
|
|
25
|
+
keys = entries.map { |entry| entry.values_at("sku", "currency", "country_iso") }
|
|
26
|
+
duplicates = keys.tally.select { |_, count| count > 1 }.keys
|
|
27
|
+
results = entries.each_with_index.map do |entry, index|
|
|
28
|
+
authorize!
|
|
29
|
+
raise ToolError, "Duplicate pricing target" if duplicates.include?(keys[index])
|
|
30
|
+
candidates = policy.scope(Spree::Variant, :update).where(sku: entry.fetch("sku")).limit(2).to_a
|
|
31
|
+
raise ToolError, "SKU is unavailable or ambiguous" unless candidates.one?
|
|
32
|
+
result = PriceUpdate.new(policy).call(variant_id: candidates.first.id,
|
|
33
|
+
**entry.except("sku").symbolize_keys) { authorize! }
|
|
34
|
+
{index: index, sku: entry["sku"], success: true, price: result}
|
|
35
|
+
rescue AccessDenied, ToolError => e
|
|
36
|
+
{index: index, sku: entry["sku"], success: false, error: e.message}
|
|
37
|
+
rescue ActiveRecord::RecordNotUnique, ActiveRecord::Deadlocked, ActiveRecord::LockWaitTimeout
|
|
38
|
+
{index: index, sku: entry["sku"], success: false, error: "Concurrent price change; retry this entry"}
|
|
39
|
+
rescue ActiveRecord::RecordNotFound
|
|
40
|
+
{index: index, sku: entry["sku"], success: false, error: "Price is unavailable"}
|
|
41
|
+
rescue ActiveRecord::RecordInvalid => e
|
|
42
|
+
{index: index, sku: entry["sku"], success: false, error: e.record.errors.full_messages.to_sentence}
|
|
43
|
+
end
|
|
44
|
+
{results: results}
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SolidusMcp
|
|
4
|
+
module Tools
|
|
5
|
+
class CreateProduct < Base
|
|
6
|
+
tool_name :create_product
|
|
7
|
+
description "Create a draft product."
|
|
8
|
+
category "Products"
|
|
9
|
+
permission "create_product"
|
|
10
|
+
read_only false
|
|
11
|
+
ability :create, "Spree::Product"
|
|
12
|
+
input :name, type: "string", minLength: 1, maxLength: 255, required: true
|
|
13
|
+
input :price, type: "number", minimum: 0, required: true
|
|
14
|
+
input :shipping_category_id, type: "integer", minimum: 1
|
|
15
|
+
|
|
16
|
+
protected
|
|
17
|
+
|
|
18
|
+
def call(name:, price:, shipping_category_id: nil)
|
|
19
|
+
default_shipping_category_id = SolidusMcp.configuration.default_shipping_category_id
|
|
20
|
+
specified_shipping_category_id = shipping_category_id || default_shipping_category_id
|
|
21
|
+
|
|
22
|
+
if specified_shipping_category_id.nil?
|
|
23
|
+
raise SolidusMcp::ToolError, "Provide shipping_category_id. No default shipping category is configured."
|
|
24
|
+
elsif Spree::ShippingCategory.find_by(id: specified_shipping_category_id).nil?
|
|
25
|
+
raise SolidusMcp::ToolError, "The provided shipping category ID could not be found."
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
product = Spree::Product.new(
|
|
29
|
+
name: name,
|
|
30
|
+
price: price,
|
|
31
|
+
shipping_category_id: specified_shipping_category_id
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
authorize!
|
|
35
|
+
policy.authorize_record!(:create, product)
|
|
36
|
+
product.save!
|
|
37
|
+
|
|
38
|
+
{product_id: product.id, created: true}
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SolidusMcp
|
|
4
|
+
module Tools
|
|
5
|
+
class GetInventoryLevels < Base
|
|
6
|
+
tool_name :get_inventory_levels
|
|
7
|
+
description "Read stock counts and backorderability by location for one variant. Does not reserve or adjust stock."
|
|
8
|
+
category "Inventory"
|
|
9
|
+
permission "get_inventory_levels"
|
|
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 :variant_id, type: "integer", minimum: 1, required: true
|
|
16
|
+
pagination
|
|
17
|
+
|
|
18
|
+
protected
|
|
19
|
+
|
|
20
|
+
def call(variant_id:, after_id: 0, limit: 25)
|
|
21
|
+
serializers.variant(variants.find(variant_id))
|
|
22
|
+
page(stock_items.where(variant_id: variant_id), after_id: after_id, limit: limit) { |item| serializers.inventory(item) }
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SolidusMcp
|
|
4
|
+
module Tools
|
|
5
|
+
class GetOrder < Base
|
|
6
|
+
tool_name :get_order
|
|
7
|
+
description "Retrieve order totals, status and up to 100 authorized line items. Includes a truncation indicator."
|
|
8
|
+
category "Orders"
|
|
9
|
+
permission "get_order"
|
|
10
|
+
read_only true
|
|
11
|
+
ability :read, "Spree::Order"
|
|
12
|
+
ability :read, "Spree::LineItem"
|
|
13
|
+
input :order_id, type: "integer", minimum: 1, required: true
|
|
14
|
+
|
|
15
|
+
protected
|
|
16
|
+
|
|
17
|
+
def call(order_id:)
|
|
18
|
+
order = policy.scope(Spree::Order).find(order_id)
|
|
19
|
+
data = serializers.order(order)
|
|
20
|
+
data[:line_items] = policy.scope(Spree::LineItem).where(order_id: order.id).order(:id).limit(100).map do |item|
|
|
21
|
+
policy.authorize_record!(:read, item)
|
|
22
|
+
{id: item.id, variant_id: item.variant_id, quantity: item.quantity, price: item.price.to_s("F")}
|
|
23
|
+
end
|
|
24
|
+
data[:line_items_truncated] = policy.scope(Spree::LineItem).where(order_id: order.id).count > 100
|
|
25
|
+
data
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SolidusMcp
|
|
4
|
+
module Tools
|
|
5
|
+
class GetOrderStatus < Base
|
|
6
|
+
tool_name :get_order_status
|
|
7
|
+
description "Retrieve the order, payment and shipment states for an order ID."
|
|
8
|
+
category "Orders"
|
|
9
|
+
permission "get_order_status"
|
|
10
|
+
read_only true
|
|
11
|
+
ability :read, "Spree::Order"
|
|
12
|
+
input :order_id, type: "integer", minimum: 1, required: true
|
|
13
|
+
|
|
14
|
+
protected
|
|
15
|
+
|
|
16
|
+
def call(order_id:)
|
|
17
|
+
serializers.status(policy.scope(Spree::Order).find(order_id))
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SolidusMcp
|
|
4
|
+
module Tools
|
|
5
|
+
class GetProduct < Base
|
|
6
|
+
tool_name :get_product
|
|
7
|
+
description "Retrieve product descriptions and supported product attributes by ID."
|
|
8
|
+
category "Products"
|
|
9
|
+
permission "get_product"
|
|
10
|
+
read_only true
|
|
11
|
+
ability :read, "Spree::Product"
|
|
12
|
+
input :product_id, type: "integer", minimum: 1, required: true
|
|
13
|
+
|
|
14
|
+
protected
|
|
15
|
+
|
|
16
|
+
def call(product_id:)
|
|
17
|
+
serializers.product(policy.scope(Spree::Product).find(product_id))
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SolidusMcp
|
|
4
|
+
module Tools
|
|
5
|
+
class GetProductVariants < Base
|
|
6
|
+
tool_name :get_product_variants
|
|
7
|
+
description "List authorized product variants, including the master variant, with IDs and SKUs."
|
|
8
|
+
category "Products"
|
|
9
|
+
permission "get_product_variants"
|
|
10
|
+
read_only true
|
|
11
|
+
ability :read, "Spree::Product"
|
|
12
|
+
ability :read, "Spree::Variant"
|
|
13
|
+
input :product_id, type: "integer", minimum: 1, required: true
|
|
14
|
+
pagination
|
|
15
|
+
|
|
16
|
+
protected
|
|
17
|
+
|
|
18
|
+
def call(product_id:, after_id: 0, limit: 25)
|
|
19
|
+
policy.authorize_record!(:read, policy.scope(Spree::Product).find(product_id))
|
|
20
|
+
page(variants.where(product_id: product_id), after_id: after_id, limit: limit) { |variant| serializers.variant(variant) }
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|