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,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,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidusMcp
4
+ class AdministratorChange < ActiveRecord::Base
5
+ self.table_name = "solidus_mcp_administrator_changes"
6
+ belongs_to :administrator, class_name: "SolidusMcp::Administrator"
7
+ end
8
+ 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