solidus_mcp 0.2.0.rc1 → 0.2.0.rc2

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9309d2faa1dc3fb43625241d6d56734bdf7ad99605a910714cb1241e6da551b7
4
- data.tar.gz: a2e9c1d6ac448bb64d97134652c353cb85becdd602c39e2370b6fc70bcd2624e
3
+ metadata.gz: f5922154aa4d65341aecb0312e8a1a72d20ef1e81a5e423f000eaa6f6239e4fd
4
+ data.tar.gz: 2ff83f035e9b9268c9df907b4f40004b1b3528af8c30ff033b8b3fe4493847b2
5
5
  SHA512:
6
- metadata.gz: b6d6e4a456c1415d09edd064c6bb35c884d867d75fddb82aa58fb077d972b9d68d538bdddaef97e8b16f8d293d461e30f1fde75ec85519ba5f19d59d4b538329
7
- data.tar.gz: 8f56da97fc37e7df7ac216bf9397a13df4219644759e37df50bccfab42b5c63110bd8173eb82f2f2adac964e8f7ccaec8c930206706040de8febe65af2c68214
6
+ metadata.gz: fd23eee0805800c0de1d3a939c1ea99358aef8ad711b3437f4c002c17a67b5da3388dd0fa37dfc7fa0bb48ceb0cf10b588e7b8e3e8f92db836f13ada990141ba
7
+ data.tar.gz: a8aba2d7d878b8993289046e915eba690d8dd70a7fd1fc9fe241337174bfd12b741fbcc305658b809cceab09002179533b8fc43f43fbb4f2bbc8765cd1eae4a5
data/CHANGELOG.md CHANGED
@@ -1,5 +1,13 @@
1
1
  # Changelog
2
2
 
3
+ ## Unreleased
4
+
5
+ - Add optional product and order serializer hooks for built-in read tools, with
6
+ a plain Ruby interface and an Alba adapter example. Preserve record authorization,
7
+ pagination, and tool-owned order line items; resolve class names across Rails reloads.
8
+ - Add `Policy#can?` so serializers can omit inaccessible optional related fields
9
+ while retaining strict authorization for required records.
10
+
3
11
  ## 0.2.0.rc1 (2026-11-08)
4
12
 
5
13
  First public release candidate. The earlier registry implementation was unpublished.
data/CONTRIBUTING.md CHANGED
@@ -25,7 +25,54 @@ The direct migration command is `bin/rails solidus_mcp:migrate` after host
25
25
  migrations. Keep migrations immutable and safe alongside the previous release.
26
26
  Do not run down migrations automatically during deployment rollback.
27
27
 
28
- For release, run admin and core-only suites, both loading checks, style checks,
29
- and gem packaging locally. Review the package and changelog. Preserve immutable
30
- build inputs and verify the real host integration before production promotion.
31
- Publishing, pushing, tagging and deployment remain deliberate separate actions.
28
+ ## Releasing
29
+
30
+ Pushing a version tag triggers `.github/workflows/release.yml`. The workflow
31
+ requires the tag to match `SolidusMcp::VERSION` exactly (for example,
32
+ `v0.2.0.rc2`) and its commit to be on `main`. It runs the full Rails/admin CI
33
+ matrix before building and publishing the gem to RubyGems. Ordinary branch pushes
34
+ and pull requests do not publish. The built gem is retained as a workflow artifact.
35
+ Publishing does not update or deploy any host application.
36
+
37
+ ### One-time setup
38
+
39
+ 1. In this repository's GitHub **Settings > Environments**, create `rubygems`.
40
+ Restrict its deployment tags to `v*`; optionally require a release reviewer.
41
+ Allow version tags to be created only by release maintainers using a GitHub
42
+ tag ruleset. Tagged code executes in the publishing workflow, so access to
43
+ create release tags is release authority.
44
+ 2. On RubyGems.org, open the `solidus_mcp` gem's **Settings > Trusted Publishers**
45
+ and add a GitHub Actions publisher with these exact values:
46
+ - Repository owner: `michaelmichael`
47
+ - Repository name: `solidus_mcp`
48
+ - Workflow filename: `release.yml`
49
+ - Environment: `rubygems`
50
+
51
+ The workflow uses OIDC to obtain short-lived publishing credentials. Do not add
52
+ a `RUBYGEMS_API_KEY` or other long-lived publishing secret to GitHub. The
53
+ `id-token: write` permission is limited to the publishing job, after tests pass.
54
+
55
+ ### Each release
56
+
57
+ 1. Bump `lib/solidus_mcp/version.rb` and move the applicable `CHANGELOG.md`
58
+ entries into a section for that version.
59
+ 2. Run `bin/ci` (or `docker compose run --rm test`) and review the package and
60
+ changelog. Merge the version change into `main` and wait for CI to pass.
61
+ 3. From a clean checkout of the merged `main` commit, create and push the tag:
62
+
63
+ ```bash
64
+ git switch main
65
+ git pull --ff-only
66
+ test -z "$(git status --porcelain)"
67
+ version=$(ruby -r ./lib/solidus_mcp/version -e 'print SolidusMcp::VERSION')
68
+ git tag -a "v${version}" -m "Release ${version}"
69
+ git push origin "v${version}"
70
+ ```
71
+
72
+ 4. Watch the **Release gem** workflow, approve the `rubygems` deployment if
73
+ required, and verify the new version on RubyGems.org before updating hosts.
74
+
75
+ Do not move a published tag or try to replace a published gem. Fixes need a new
76
+ version. A failed run can be re-run if publishing never succeeded; if the gem is
77
+ already on RubyGems, do not re-run the publish job (RubyGems rejects duplicates).
78
+ Verify the real host integration before production promotion.
data/README.md CHANGED
@@ -230,6 +230,97 @@ The built-in price update tools use Solidus's standard pricing options. Stores w
230
230
 
231
231
  Product and price updates take effect immediately. There are currently no built-in order or inventory mutation tools. Add your own, as desired.
232
232
 
233
+ ## Custom serializers
234
+
235
+ You can change the product and order fields returned by the built-in tools without replacing those tools. Configure a serializer class in your initializer:
236
+
237
+ ```ruby
238
+ config.product_serializer = 'Mcp::ProductSerializer'
239
+ # config.order_serializer = 'Mcp::OrderSerializer'
240
+ ```
241
+
242
+ Both settings are optional. Leave one unset to keep that record's default representation. Use class-name strings so Rails can reload your serializers in development.
243
+
244
+ Each class implements `.call(record, context:)` and returns a JSON-compatible Ruby hash. For example, in `app/serializers/mcp/product_serializer.rb`:
245
+
246
+ ```ruby
247
+ module Mcp
248
+ class ProductSerializer
249
+ def self.call(product, context:)
250
+ {
251
+ id: product.id,
252
+ name: product.name,
253
+ display_name: "#{product.name} (#{product.slug})"
254
+ }
255
+ end
256
+ end
257
+ end
258
+ ```
259
+
260
+ The returned hash replaces the default fields; it isn't merged with them. You choose which fields to include. The same interface works for orders.
261
+
262
+ | Setting | Used by |
263
+ | --- | --- |
264
+ | `product_serializer` | `search_products` and `get_product` |
265
+ | `order_serializer` | `search_orders` and `get_order` |
266
+
267
+ Pagination stays under the tool's control. `get_order` still adds its authorized, bounded line items and truncation indicator, so order serializers cannot return `line_items` or `line_items_truncated`. Variants, prices, inventory, order status, and write responses keep their existing representations.
268
+
269
+ ### Using Alba or another library
270
+
271
+ The hook accepts plain Ruby classes or adapters for your preferred serialization library. Add that library to your app's Gemfile; it is not a runtime dependency of Solidus MCP.
272
+
273
+ For Alba, define `app/serializers/mcp/product_resource.rb`:
274
+
275
+ ```ruby
276
+ module Mcp
277
+ class ProductResource
278
+ include Alba::Resource
279
+
280
+ attributes :id, :name, :slug
281
+ end
282
+ end
283
+ ```
284
+
285
+ Then use this adapter in `app/serializers/mcp/product_serializer.rb`:
286
+
287
+ ```ruby
288
+ module Mcp
289
+ class ProductSerializer
290
+ def self.call(product, context:)
291
+ ProductResource.new(product, params: {policy: context}).to_h
292
+ end
293
+ end
294
+ end
295
+ ```
296
+
297
+ Return a hash, not an encoded JSON string. For another library, adapt its output to the same interface.
298
+
299
+ ### Authorization and field access
300
+
301
+ The gem checks that the connected user can read the product or order before calling your serializer. If you include information from related records, your serializer must check access to those records as well. For example, a store may allow someone to read an order's totals while restricting access to its shipping address.
302
+
303
+ For optional information, use `context.can?` to check access and omit the fields when the record is missing or access is denied:
304
+
305
+ ```ruby
306
+ def self.call(order, context:)
307
+ result = {number: order.number}
308
+ address = order.ship_address
309
+ result[:shipping_city] = address.city if context.can?(:read, address)
310
+ result
311
+ end
312
+ ```
313
+
314
+ The order still comes back if the shipping address is inaccessible; it simply has no `shipping_city` field. Clients should not assume an omitted field means no related record exists. The check uses the store's existing Solidus abilities and returns `false` for a missing record.
315
+
316
+ This does not create a separate MCP permission for addresses. An account with broad Solidus access will pass these record checks. Your store controls which fields its serializers expose.
317
+
318
+ If a related record is essential to the response, use `context.authorize_record!(:read, record)` instead. It raises an access-denied error and fails the call when the record is missing or access is denied. Access to the primary product or order is always required.
319
+
320
+ For related collections, use `context.scope(Model)` to return only records the connected user can read. These helpers do not automatically restrict the queries or associations you access elsewhere in the serializer.
321
+
322
+ These hooks do not add field-level permissions. Serializer changes apply to existing connections with access to the affected tools. To expose sensitive information under a separate grant, use a custom tool with its own permission key. Serializers are trusted application code, just like custom tools.
323
+
233
324
  ## Custom tools
234
325
 
235
326
  To create your own custom MCP tools, add a named subclass of `SolidusMcp::Tool` under `app/tools`. For example, `app/tools/count_products.rb`:
@@ -1,7 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module SolidusMcp
4
- # Fixed public representations. Related records are checked separately.
4
+ # Record authorization stays outside host serialization. Custom serializers are
5
+ # trusted application code and must authorize any related records they expose.
5
6
  class Serializers
6
7
  def initialize(policy)
7
8
  @policy = policy
@@ -9,7 +10,9 @@ module SolidusMcp
9
10
 
10
11
  def product(record)
11
12
  @policy.authorize_record!(:read, record)
12
- record.attributes.slice("id", "name", "slug", "description", "meta_title", "meta_description", "meta_keywords")
13
+ serialize(:product, record) do
14
+ record.attributes.slice("id", "name", "slug", "description", "meta_title", "meta_description", "meta_keywords")
15
+ end
13
16
  end
14
17
 
15
18
  def variant(record)
@@ -36,8 +39,10 @@ module SolidusMcp
36
39
 
37
40
  def order(record)
38
41
  @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)
42
+ serialize(:order, record) do
43
+ status(record).merge(currency: record.currency, total: record.total.to_s("F"),
44
+ item_total: record.item_total.to_s("F"), completed_at: record.completed_at&.iso8601)
45
+ end
41
46
  end
42
47
 
43
48
  def status(record)
@@ -45,5 +50,24 @@ module SolidusMcp
45
50
  {id: record.id, number: record.number, state: record.state,
46
51
  payment_state: record.payment_state, shipment_state: record.shipment_state}
47
52
  end
53
+
54
+ private
55
+
56
+ def serialize(kind, record)
57
+ serializer = SolidusMcp.configuration.serializer_class(kind)
58
+ return yield unless serializer
59
+
60
+ result = serializer.call(record, context: @policy)
61
+ valid = result.is_a?(Hash) && result.keys.all? { |key| key.is_a?(String) || key.is_a?(Symbol) }
62
+ unless valid
63
+ raise ToolError, "#{serializer.name} must return a Hash with string or symbol keys, not encoded JSON"
64
+ end
65
+ keys = result.keys.map(&:to_s)
66
+ raise ToolError, "#{serializer.name} returned duplicate field names" unless keys.uniq == keys
67
+ if kind == :order && (keys & %w[line_items line_items_truncated]).any?
68
+ raise ToolError, "Order serializers cannot replace line_items or line_items_truncated"
69
+ end
70
+ result.dup
71
+ end
48
72
  end
49
73
  end
@@ -5,7 +5,8 @@ require "uri"
5
5
  module SolidusMcp
6
6
  class Configuration
7
7
  attr_accessor :custom_tools, :endpoint_url, :issuer_url, :allowed_origins,
8
- :server_name, :instructions, :resource_owner_authenticator, :default_shipping_category_id, :superadmin_emails
8
+ :server_name, :instructions, :resource_owner_authenticator, :default_shipping_category_id, :superadmin_emails,
9
+ :product_serializer, :order_serializer
9
10
 
10
11
  def initialize
11
12
  @superadmin_emails = ENV.fetch("SOLIDUS_MCP_SUPERADMIN_EMAILS", "").split(",").map { |email| email.strip.downcase }.uniq
@@ -23,6 +24,23 @@ module SolidusMcp
23
24
  endpoint_url.present? && issuer_url.present?
24
25
  end
25
26
 
27
+ def serializer_class(kind)
28
+ name = public_send(:"#{kind}_serializer")
29
+ return if name.nil?
30
+ unless name.is_a?(String) && name.present?
31
+ raise ArgumentError, "#{kind}_serializer must be a class-name string or nil"
32
+ end
33
+ serializer = name.constantize
34
+ unless serializer.is_a?(Class) && serializer.respond_to?(:call)
35
+ raise ArgumentError, "#{name} must implement .call(record, context:)"
36
+ end
37
+ serializer
38
+ end
39
+
40
+ def validate_serializers!
41
+ %i[product order].each { |kind| serializer_class(kind) }
42
+ end
43
+
26
44
  def validate!
27
45
  [endpoint_url, issuer_url].each do |value|
28
46
  uri = URI.parse(value.to_s)
@@ -5,6 +5,7 @@ module SolidusMcp
5
5
  engine_name "solidus_mcp"
6
6
 
7
7
  config.to_prepare do
8
+ SolidusMcp.configuration.validate_serializers!
8
9
  SolidusMcp.rebuild_registry!
9
10
  end
10
11
 
@@ -31,10 +31,14 @@ module SolidusMcp
31
31
  end
32
32
 
33
33
  def authorize_record!(action, record)
34
- raise AccessDenied, "Record is unavailable" unless ability.can?(action, record)
34
+ raise AccessDenied, "Record is unavailable" unless can?(action, record)
35
35
  record
36
36
  end
37
37
 
38
+ def can?(action, record)
39
+ !record.nil? && ability.can?(action, record)
40
+ end
41
+
38
42
  def scope(model, action = :read)
39
43
  model.accessible_by(ability, action)
40
44
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module SolidusMcp
4
- VERSION = "0.2.0.rc1"
4
+ VERSION = "0.2.0.rc2"
5
5
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: solidus_mcp
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0.rc1
4
+ version: 0.2.0.rc2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Michael Caviness
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-11-08 00:00:00.000000000 Z
11
+ date: 2026-11-09 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: solidus_core