spree_mcp 6.0.0.rc4

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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: c756a35e627693b5ce74551d146f4d1e5cff4715d88332bfda3a609cb3c394d3
4
+ data.tar.gz: eb657c2993cef7ea93dd3c04583b08eb3a8251395c1f825f4432cb063a3d3013
5
+ SHA512:
6
+ metadata.gz: a539ac84fd659c13e33512311a1d16676500ab831b20e32cb08763d5429709cd2685ae2474786431c45b6e9f559d358db31888ca5dce7cc0e33f12aae92b13ac
7
+ data.tar.gz: 1d2c0340424e86042bd677476bfb0a26308eab303b1952eba25c1bbfbb017694e910bc269da4ae20c2294179aa1b276e0dde5bd7711ea904bdf0b18e5fde7923
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026-present, Vendo Sp. z o.o., Vendo Connect Inc.
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,24 @@
1
+ # Spree MCP Server
2
+
3
+ A [Model Context Protocol](https://modelcontextprotocol.io) server for the
4
+ Spree back office. Any MCP client — Claude Code, Cursor, an in-house agent —
5
+ can merchandise the catalog, answer reporting questions and manage store setup
6
+ in plain language, under the same tenancy and permission rules as the Admin
7
+ API.
8
+
9
+ ```bash
10
+ spree add mcp # or add gem 'spree_mcp' to the Gemfile
11
+ ```
12
+
13
+ The endpoint is `POST /api/v3/admin/mcp`, authenticated with a Spree secret API
14
+ key sent as `X-Spree-API-Key: sk_…` or `Authorization: Bearer sk_…`. The key's
15
+ scopes decide which tools the model is offered, so an agent is never told about
16
+ a capability its credential does not carry.
17
+
18
+ This gem is a protocol adapter and nothing more. The tools, their schemas and
19
+ the permissions gating them live in `spree_core` as `Spree.admin_agent_tools`, which
20
+ means an extension registers a tool without depending on this gem, and the
21
+ dashboard assistant offers the same tools over a different transport.
22
+
23
+ See the [documentation](https://spreecommerce.org/docs/developer/mcp/overview)
24
+ and `docs/plans/6.0-mcp-server.md`.
data/Rakefile ADDED
@@ -0,0 +1,15 @@
1
+ require 'rubygems'
2
+ require 'rake'
3
+ require 'rake/testtask'
4
+ require 'rspec/core/rake_task'
5
+ require 'spree/testing_support/common_rake'
6
+
7
+ RSpec::Core::RakeTask.new
8
+
9
+ task default: :spec
10
+
11
+ desc "Generates a dummy app for testing"
12
+ task :test_app do
13
+ ENV['LIB_NAME'] = 'spree_mcp'
14
+ Rake::Task['common:test_app'].invoke
15
+ end
@@ -0,0 +1,195 @@
1
+ module Spree
2
+ module Api
3
+ module V3
4
+ module Admin
5
+ # The MCP endpoint: `POST /api/v3/admin/mcp`.
6
+ #
7
+ # An ordinary admin API controller rather than the gem's Rack
8
+ # transport, which takes one pre-built server for every request and so
9
+ # cannot carry a per-request principal. Here the key authenticates
10
+ # through the concern every other admin endpoint uses, selects the
11
+ # store the same way, and gets a server built for it alone.
12
+ #
13
+ # Stateless: one JSON-RPC message per request, one JSON response, no
14
+ # session and no server-to-client stream, so any Puma worker answers
15
+ # any request.
16
+ #
17
+ # See docs/plans/6.0-mcp-server.md.
18
+ class McpController < Spree::Api::V3::Admin::BaseController
19
+ # The scope gate is per tool, not per request: a call carries the
20
+ # tool it wants inside the JSON-RPC body, and every tool already
21
+ # declares the permission it needs. Checking one grant's scopes for
22
+ # the whole endpoint would mean either refusing a read it covers or
23
+ # waving through a write it does not.
24
+ skip_scope_check!
25
+
26
+ # Prepended so it answers before the inherited authentication does.
27
+ # The generic "Authentication required" is useless to an agent: this
28
+ # text is what both the model and the person actually read when a
29
+ # client is misconfigured.
30
+ prepend_before_action :require_oauth_token!
31
+
32
+ def create
33
+ render json: server.handle_json(request.body.read), content_type: 'application/json'
34
+ end
35
+
36
+ protected
37
+
38
+ # The only credential this endpoint takes. An agent acts for a
39
+ # person, so it carries that person's grant: the token names who
40
+ # approved it and what they allowed, both of which a standing key
41
+ # cannot express. A secret key authenticates the rest of the Admin
42
+ # API and the `spree api` CLI; it is deliberately not accepted
43
+ # here.
44
+ #
45
+ # The grant already bound the token to a real admin user and the
46
+ # audience check confirmed it was issued for this endpoint, which
47
+ # is the assurance `require_store_membership!` gives a JWT.
48
+ def authenticate_admin!
49
+ owner = oauth_resource_owner
50
+ # A `before_action` halts on a render, not on a falsy return, so
51
+ # refusing has to render. The grant's owner is an admin in every
52
+ # path that exists today; a customer-owned token would otherwise
53
+ # reach the action and die as a confusing 422 further in.
54
+ return refuse_unauthenticated if owner.nil?
55
+
56
+ @current_api_key = nil
57
+ @current_user = owner
58
+ true
59
+ end
60
+
61
+ private
62
+
63
+ # Split rather than matched: a regex with `\s+(.+)` backtracks on a
64
+ # header of many spaces, and this one is attacker-supplied on an
65
+ # unauthenticated request.
66
+ def bearer_value
67
+ scheme, value = request.headers['Authorization'].to_s.split(' ', 2)
68
+ return unless scheme&.casecmp?('Bearer')
69
+
70
+ value.to_s.strip.presence
71
+ end
72
+
73
+ def server
74
+ Spree::Mcp::Server.for(agent_context)
75
+ end
76
+
77
+ # The caller's authority is the admin user's own permissions,
78
+ # narrowed by what they consented to — resolved on every request
79
+ # rather than frozen when the token was issued, so narrowing a role
80
+ # takes effect immediately.
81
+ def agent_context
82
+ Spree::AgentTools::Context.new(
83
+ store: current_store,
84
+ user: oauth_resource_owner,
85
+ granted_scopes: current_oauth_token.scopes.to_a,
86
+ # What a dispatched tool call authenticates with: this request's
87
+ # own credential, repeated. The grant is what bounds the agent,
88
+ # so the inner request must be the same grant — not a wider one
89
+ # minted on its behalf.
90
+ request_headers: request.env.slice(*Spree::AgentTools::ApiDispatch::FORWARDED_HEADERS)
91
+ )
92
+ end
93
+
94
+ # RFC 6750: the challenge names where to discover the authorization
95
+ # server, which is how a consumer client starts its own sign-in.
96
+ #
97
+ # Both URLs are built from the origin this request arrived on, so
98
+ # they match what the client used and each other. A client compares
99
+ # them, and a different scheme or host is enough for it to decide
100
+ # the document describes another resource and stop.
101
+ #
102
+ # No `scope`: it is a SHOULD, and listing every grantable key makes
103
+ # a header several kilobytes long. A client reads the real list from
104
+ # the metadata document.
105
+ def oauth_challenge
106
+ format(
107
+ 'Bearer realm="%<realm>s", resource_metadata="%<metadata>s"',
108
+ realm: Spree::Api::Oauth.resource_identifier(:admin, request.base_url),
109
+ metadata: "#{request.base_url}/api/v3/oauth/protected-resource/admin"
110
+ )
111
+ end
112
+
113
+ # A token issued for another resource must not work here, whatever
114
+ # its scopes say. Doorkeeper binds the audience at issue; this is
115
+ # the check that it matches.
116
+ # A token selects its own store, the way a secret key does.
117
+ #
118
+ # An MCP client sends a URL and a bearer token and nothing else — it
119
+ # has no way to set the store header the Admin API otherwise uses —
120
+ # so without this a token granted for any store but the default one
121
+ # would be measured against the default store's resource identifier
122
+ # and always fail its audience check.
123
+ def resolve_admin_store
124
+ bearer_access_token&.application&.store || super
125
+ end
126
+
127
+ # The token the bearer header names, before any check of whether it
128
+ # may be used here. Resolving the store needs it, and the audience
129
+ # check needs the store, so the lookup has to come first — and it is
130
+ # memoized because three callers want the same row.
131
+ def bearer_access_token
132
+ return @bearer_access_token if defined?(@bearer_access_token)
133
+
134
+ raw = bearer_value
135
+ @bearer_access_token = raw.present? ? Spree::OauthAccessToken.by_token(raw) : nil
136
+ end
137
+
138
+ # The token this request may actually act on: live, and issued for
139
+ # the Admin API. Named for the hook ScopedAuthorization reads, so a
140
+ # grant narrows authority through the same path every admin
141
+ # endpoint uses rather than only here.
142
+ #
143
+ # This endpoint shares the Admin API's audience rather than holding
144
+ # one of its own, because a tool call reaches data by dispatching
145
+ # into the Admin API carrying this very token. Two audiences on one
146
+ # credential could never both be satisfied.
147
+ def current_oauth_token
148
+ return @current_oauth_token if defined?(@current_oauth_token)
149
+
150
+ token = bearer_access_token
151
+ @current_oauth_token = token if token&.accessible? && admin_audience_matches?(token)
152
+ end
153
+
154
+ def oauth_resource_owner
155
+ owner = current_oauth_token.resource_owner
156
+ owner if owner.is_a?(Spree.admin_user_class)
157
+ end
158
+
159
+ # The error text is what both the model and the person read, so it
160
+ # says which header to set and where the key comes from rather than
161
+ # only that something was missing.
162
+ # Two ways in: a secret key a developer configured, or an OAuth
163
+ # token a merchant approved. A 401 carries the OAuth challenge, so
164
+ # a consumer client that arrives with nothing discovers how to sign
165
+ # in rather than simply failing.
166
+ # A 401 carrying the discovery header, which is how a client that
167
+ # arrived with nothing learns where to sign in rather than simply
168
+ # failing.
169
+ def require_oauth_token!
170
+ return if current_oauth_token.present?
171
+
172
+ refuse_unauthenticated
173
+ end
174
+
175
+ def refuse_unauthenticated
176
+ response.headers['WWW-Authenticate'] = oauth_challenge
177
+
178
+ render json: {
179
+ jsonrpc: '2.0',
180
+ id: nil,
181
+ error: {
182
+ code: -32_001,
183
+ message: 'Sign in to this store to continue. Your client will offer that when ' \
184
+ 'it reads the challenge on this response. A Spree API key is not ' \
185
+ 'accepted here — an agent acts for a person, so it carries that ' \
186
+ "person's own permissions. Use the `spree api` CLI for scripted " \
187
+ 'access that cannot sign in.'
188
+ }
189
+ }, status: :unauthorized
190
+ end
191
+ end
192
+ end
193
+ end
194
+ end
195
+ end
data/config/routes.rb ADDED
@@ -0,0 +1,12 @@
1
+ Spree::Core::Engine.add_routes do
2
+ namespace :api, defaults: { format: 'json' } do
3
+ namespace :v3 do
4
+ namespace :admin do
5
+ # One JSON-RPC message per request. Drawn inside the admin namespace so
6
+ # the endpoint inherits the Admin API's authentication, rate limiting
7
+ # and security headers rather than reimplementing them.
8
+ post 'mcp', to: 'mcp#create'
9
+ end
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,9 @@
1
+ module Spree
2
+ module Mcp
3
+ class Engine < ::Rails::Engine
4
+ isolate_namespace Spree
5
+ engine_name 'spree_mcp'
6
+
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,122 @@
1
+ module Spree
2
+ module Mcp
3
+ # Finished exports, offered as MCP resources.
4
+ #
5
+ # A tool result has to fit the model's context; an export does not. A
6
+ # catalog of ten thousand products is megabytes of CSV, which is why
7
+ # `create_export` hands back an id rather than a file. Resources are the
8
+ # protocol's answer to that: the client sees what exists, and fetches a
9
+ # body only when it decides to.
10
+ #
11
+ # Reading one costs the same permission as reading the records it holds —
12
+ # a customers export needs what a customer needs — so the file cannot
13
+ # become a way around a scope the merchant withheld.
14
+ module Exports
15
+ URI_SCHEME = 'spree+export'.freeze
16
+
17
+ # Set by what a model can read, not by what storage can hold. A CSV is
18
+ # text, so this is roughly 50,000 tokens — a large but survivable share
19
+ # of a context window. Beyond it the client is told the size and given
20
+ # the dashboard link.
21
+ MAX_INLINE_BYTES = 200_000
22
+
23
+ # Exports whose rows carry a bearer instrument — a code that spends
24
+ # money for whoever reads it. The merchant downloads these from the
25
+ # dashboard; handing one to a model puts live codes in a third party's
26
+ # chat history, which cannot be undone.
27
+ #
28
+ # `Spree::GiftCard` is already kept out of the resource map and out of
29
+ # any serialized order for the same reason. An export must not be the
30
+ # way around that. The list lives with the tool that creates them, so
31
+ # refusing to make one and refusing to read one cannot disagree.
32
+
33
+ class << self
34
+ # @param context [Spree::AgentTools::Context]
35
+ # @return [Array<Hash>] MCP resource descriptors
36
+ def list(context)
37
+ response = dispatch(context, :get, '/api/v3/admin/exports', params: { limit: 50 })
38
+ return [] unless response.success?
39
+
40
+ Array(response.data).
41
+ select { |export| export['done'] && export['byte_size'].to_i.positive? }.
42
+ map { |export| descriptor(export) }
43
+ end
44
+
45
+ # @param context [Spree::AgentTools::Context]
46
+ # @param uri [String]
47
+ # @return [Array<Hash>, nil] contents, or nil when there is no such
48
+ # export this caller may read
49
+ def read(context, uri)
50
+ id = uri.to_s.delete_prefix("#{URI_SCHEME}://")
51
+ return if id.blank?
52
+
53
+ # The endpoint answers 404 for an export of another store and for
54
+ # one this credential's scopes do not cover, so there is no second
55
+ # permission check here.
56
+ export = dispatch(context, :get, "/api/v3/admin/exports/#{id}")
57
+ return unless export.success?
58
+
59
+ record = export.data.to_h
60
+ return unless record['done']
61
+
62
+ return [withheld(uri)] if bearer_export?(record['type'])
63
+
64
+ size = record['byte_size'].to_i
65
+ return [too_large(uri, size)] if size > MAX_INLINE_BYTES
66
+
67
+ contents(context, uri, id)
68
+ end
69
+
70
+ private
71
+
72
+ def contents(context, uri, id)
73
+ file = dispatch(context, :get, "/api/v3/admin/exports/#{id}/download")
74
+ return [{ uri: uri, mimeType: 'text/plain', text: file.error_message }] unless file.success?
75
+
76
+ [{ uri: uri, mimeType: 'text/csv', text: file.content }]
77
+ end
78
+
79
+ def dispatch(context, method, path, params: {})
80
+ Spree::AgentTools::ApiDispatch.new(context).call(method: method, path: path, params: params)
81
+ end
82
+
83
+ def descriptor(export)
84
+ {
85
+ uri: "#{URI_SCHEME}://#{export['id']}",
86
+ name: export['filename'].to_s,
87
+ title: "#{export['type'].to_s.tr('_', ' ').humanize} export".strip,
88
+ description: "Generated #{Date.parse(export['created_at'].to_s).to_fs(:long)}",
89
+ mimeType: 'text/csv',
90
+ size: export['byte_size']
91
+ }
92
+ rescue Date::Error
93
+ { uri: "#{URI_SCHEME}://#{export['id']}", name: export['filename'].to_s,
94
+ mimeType: 'text/csv', size: export['byte_size'] }
95
+ end
96
+
97
+ def bearer_export?(type)
98
+ Spree::AgentTools::CreateExport::BEARER_EXPORTS.include?(type.to_s)
99
+ end
100
+
101
+ def withheld(uri)
102
+ {
103
+ uri: uri,
104
+ mimeType: 'text/plain',
105
+ text: 'This export contains codes that spend money for whoever reads them, so it is ' \
106
+ 'not readable here. Download it from the dashboard instead.'
107
+ }
108
+ end
109
+
110
+ def too_large(uri, byte_size)
111
+ {
112
+ uri: uri,
113
+ mimeType: 'text/plain',
114
+ text: "This export is #{ActiveSupport::NumberHelper.number_to_human_size(byte_size)}, " \
115
+ 'too large to read here. Download it from the dashboard, or narrow the export with ' \
116
+ 'filters and create a smaller one.'
117
+ }
118
+ end
119
+ end
120
+ end
121
+ end
122
+ end
@@ -0,0 +1,116 @@
1
+ module Spree
2
+ module Mcp
3
+ # Builds an `MCP::Server` for one caller.
4
+ #
5
+ # Per request, not per process. A server carries the tools its caller may
6
+ # use and nothing else — a key minted to read orders is never told a
7
+ # refund tool exists — so the tool list cannot be shared between
8
+ # principals, and the gem's own Rack transport (one server for every
9
+ # request) is not what mounts this. An ordinary admin API controller does,
10
+ # and hands the JSON-RPC message to {MCP::Server#handle_json}.
11
+ module Server
12
+ # What the model is told about using this server at discovery, so it
13
+ # reaches for the discovery tools before guessing at names and filters.
14
+ INSTRUCTIONS = <<~TEXT.freeze
15
+ This server manages one Spree store's back office.
16
+
17
+ Start with describe_resource to learn which resources exist and which
18
+ fields each can be filtered and sorted by, then search_resources to find
19
+ records and get_resource to read one in full. For numbers — revenue,
20
+ orders, units — use describe_reporting and then query_report rather than
21
+ counting records yourself.
22
+
23
+ Which products a category, collection, catalog or price list holds is
24
+ curate_products — merchandising is mostly membership, and that one tool
25
+ covers every one of those parents.
26
+
27
+ Writes are named after what they do: a tool per operation, each running
28
+ the same workflow the dashboard runs, so validations and notifications
29
+ behave identically. Record arguments are prefixed ids as they appear in
30
+ search results. You are only offered the tools this credential permits,
31
+ so a tool you cannot see is one this store has not granted.
32
+
33
+ A file the merchant gives you goes the other way: upload_file stores it
34
+ and answers with an id, which you then pass to whatever uses it —
35
+ media_create to put an image on a product, create_import to start a
36
+ CSV import. Storing one changes nothing by itself.
37
+
38
+ Files are MCP resources, not tool results. A finished export is one:
39
+ after create_export, read its contents from resources rather than
40
+ reporting a download path — the path is for the merchant, the
41
+ resource is for you. So are the documents attached to records — a
42
+ buyer's purchase order, a shipping label — which come back as the
43
+ file itself for you to read.
44
+ TEXT
45
+
46
+ class << self
47
+ # @param context [Spree::AgentTools::Context] who is asking, and where
48
+ # @return [MCP::Server]
49
+ def for(context)
50
+ server = ::MCP::Server.new(
51
+ name: 'spree-admin',
52
+ title: "#{context.store.name} (Spree admin)",
53
+ version: Spree.version,
54
+ instructions: INSTRUCTIONS,
55
+ tools: tools_for(context),
56
+ server_context: { store_id: context.store.id }
57
+ )
58
+
59
+ register_resources(server, context)
60
+ server
61
+ end
62
+
63
+ # The files a client may fetch: finished exports, and the documents
64
+ # attached to records — a buyer's purchase order, a shipping label.
65
+ #
66
+ # A tool result has to fit the model's context; a file does not, so
67
+ # `create_export` hands back an id rather than a file. Resources are
68
+ # the protocol's answer: the client sees what exists and fetches a
69
+ # body only when it decides to.
70
+ #
71
+ # Handlers rather than a fixed list, because what exists depends on
72
+ # the store and on what this grant may read — both known only per
73
+ # request. The closure carries the caller, so a resource one
74
+ # credential can see is never offered to another.
75
+ #
76
+ # The protocol allows one handler of each kind, so the sources are
77
+ # composed here: each owns which of its records the caller may see,
78
+ # and each answers only for its own URI scheme.
79
+ #
80
+ # Exports are the only files a client fetches. A product's media, an
81
+ # order's purchase order and an import's own file are things an agent
82
+ # puts there, not things it reads back — and they are already visible
83
+ # on the records that carry them.
84
+ SOURCES = [Exports].freeze
85
+
86
+ def register_resources(server, context)
87
+ server.resources_list_handler { SOURCES.flat_map { |source| source.list(context) } }
88
+
89
+ server.resources_read_handler do |params|
90
+ uri = params[:uri] || params['uri']
91
+ contents = SOURCES.lazy.filter_map { |source| source.read(context, uri) }.first
92
+ # A file another store owns, or one this grant may not read, is
93
+ # indistinguishable from one that does not exist — the same answer
94
+ # the tools give, so a probe learns nothing either way.
95
+ raise ::MCP::Server::ResourceNotFoundError.new(uri, params) if contents.nil?
96
+
97
+ contents
98
+ end
99
+ end
100
+
101
+ # The tools this caller may use, as MCP definitions.
102
+ #
103
+ # Filtering happens here rather than at call time, so a tool the
104
+ # credential cannot use is never named to the model — and a call to
105
+ # something it was not offered is "unknown tool", not "forbidden",
106
+ # which says less about what else exists.
107
+ #
108
+ # @param context [Spree::AgentTools::Context]
109
+ # @return [Array<Class<MCP::Tool>>]
110
+ def tools_for(context)
111
+ Spree.admin_agent_tools.available_for(context).map { |tool| ToolAdapter.build(tool) }
112
+ end
113
+ end
114
+ end
115
+ end
116
+ end
@@ -0,0 +1,144 @@
1
+ module Spree
2
+ module Mcp
3
+ # Turns a {Spree::AgentTool} into an `MCP::Tool` the protocol can offer.
4
+ #
5
+ # The whole adapter: a name, a description, the tool's parameter schema as
6
+ # JSON Schema, and a handler that runs the tool and shapes what comes back.
7
+ # Nothing about a tool is written twice — a tool registered by an extension
8
+ # is offered over MCP with no MCP code anywhere near it.
9
+ module ToolAdapter
10
+ class << self
11
+ # @param tool [Spree::AgentTool] instantiated for the caller's context
12
+ # @return [Class<MCP::Tool>]
13
+ def build(tool)
14
+ # The block is instance_exec'd on the generated tool class, so it
15
+ # cannot call back into this module by name — `adapter` closes over
16
+ # it instead.
17
+ adapter = self
18
+
19
+ ::MCP::Tool.define(
20
+ name: tool.tool_name,
21
+ description: tool.description,
22
+ input_schema: input_schema(tool),
23
+ annotations: annotations(tool)
24
+ ) { |**arguments| adapter.respond(tool, arguments) }
25
+ end
26
+
27
+ # The tool's declared params as JSON Schema.
28
+ #
29
+ # @param tool [Spree::AgentTool]
30
+ # @return [Hash]
31
+ def input_schema(tool)
32
+ properties = tool.params.to_h do |name, options|
33
+ property = { type: json_type(options[:type]) }
34
+ property[:description] = options[:description] if options[:description].present?
35
+ # An array must say what it holds: a client validating arguments
36
+ # against the schema refuses the call otherwise.
37
+ property[:items] = { type: json_type(options[:items] || :string) } if property[:type] == 'array'
38
+
39
+ [name.to_s, property]
40
+ end
41
+
42
+ {
43
+ type: 'object',
44
+ properties: properties,
45
+ required: tool.params.select { |_name, options| options[:required] }.keys.map(&:to_s)
46
+ }
47
+ end
48
+
49
+ # Clients that gate confirmation on annotations rather than on a
50
+ # server's word need both hints set explicitly: the gem defaults
51
+ # `destructiveHint` to true, so a read tool that says nothing would be
52
+ # confirmed like a deletion.
53
+ #
54
+ # @return [Hash]
55
+ def annotations(tool)
56
+ { read_only_hint: !tool.mutating?, destructive_hint: tool.mutating?, idempotent_hint: !tool.mutating? }
57
+ end
58
+
59
+ # A tool answers with a plain hash. A hash carrying `:error` is the
60
+ # tool refusing — the model should read it and correct itself, so it
61
+ # comes back as a tool error rather than a protocol error, which the
62
+ # model never sees.
63
+ #
64
+ # A refusal carries more than its message: the resources that do
65
+ # exist, the fields that can be filtered, the import's own column
66
+ # names. Forwarding only `:error` threw all of that away, so a model
67
+ # told "Unknown filter(s): notreal_eq" had to guess again instead of
68
+ # reading the list the tool had already computed.
69
+ #
70
+ # Public because the definition block calls it through a closure.
71
+ #
72
+ # @param tool [Spree::AgentTool]
73
+ # @param arguments [Hash]
74
+ # @return [MCP::Tool::Response]
75
+ def respond(tool, arguments)
76
+ result = tool.call(**arguments.symbolize_keys.except(:server_context))
77
+
78
+ if result.is_a?(Hash) && result[:error].present?
79
+ ::MCP::Tool::Response.new([{ type: 'text', text: refusal_text(result) }], error: true)
80
+ else
81
+ ::MCP::Tool::Response.new([{ type: 'text', text: text_for(result) }], structured_content: result)
82
+ end
83
+ rescue ArgumentError => e
84
+ # A missing or unknown keyword means the model filled the schema in
85
+ # wrongly; naming the parameter lets it retry correctly.
86
+ ::MCP::Tool::Response.new([{ type: 'text', text: e.message }], error: true)
87
+ end
88
+
89
+ private
90
+
91
+ # A tool declares Ruby-ish types; JSON Schema wants its own names, and
92
+ # anything unrecognised is a string, which every client can render.
93
+ def json_type(type)
94
+ case type&.to_sym
95
+ when :boolean then 'boolean'
96
+ when :integer then 'integer'
97
+ when :number, :float, :decimal then 'number'
98
+ when :object, :hash then 'object'
99
+ when :array then 'array'
100
+ else 'string'
101
+ end
102
+ end
103
+
104
+ # A line the client can show beside its confirmation prompt, and the
105
+ # model can read without parsing the structured payload.
106
+ # What the model reads.
107
+ #
108
+ # A client may render the structured half, the text half, or both —
109
+ # the protocol does not say — so the text has to carry the answer
110
+ # rather than point at it. Claude reads the text, so a payload that
111
+ # travels only as structured content reaches the model as nothing:
112
+ # a search answered "Found 1 match." with the match itself stripped,
113
+ # and the model could not name what it had just found.
114
+ #
115
+ # A count is a description of an answer, never the answer. The only
116
+ # text that may stand in for a payload is one the tool wrote itself.
117
+ # The message, then whatever the refusal offered to correct it with.
118
+ #
119
+ # @param result [Hash]
120
+ # @return [String]
121
+ def refusal_text(result)
122
+ hints = result.except(:error).compact_blank
123
+ return result[:error].to_s if hints.empty?
124
+
125
+ "#{result[:error]}\n#{JSON.generate(hints)}"
126
+ end
127
+
128
+ def text_for(result)
129
+ return result.to_s unless result.is_a?(Hash)
130
+
131
+ summary = result[:summary].presence
132
+ return JSON.generate(result) if summary.nil?
133
+ # The summary alone says what was done but not what it did it to,
134
+ # so a write answered "Orders cancel R1002" and the model had to
135
+ # fetch the order again to report the new status. Every other key
136
+ # travels with it.
137
+ return summary if result.keys == [:summary]
138
+
139
+ "#{summary}\n#{JSON.generate(result)}"
140
+ end
141
+ end
142
+ end
143
+ end
144
+ end
data/lib/spree_mcp.rb ADDED
@@ -0,0 +1,21 @@
1
+ require 'spree_core'
2
+ require 'spree_api'
3
+ require 'mcp'
4
+
5
+ require 'spree/mcp/exports'
6
+ require 'spree/mcp/tool_adapter'
7
+ require 'spree/mcp/server'
8
+ require 'spree/mcp/engine'
9
+
10
+ module Spree
11
+ # A Model Context Protocol server for the Spree back office.
12
+ #
13
+ # A thin protocol adapter over {Spree.admin_agent_tools}: the tools, their schemas
14
+ # and the permissions gating them all live in `spree_core`, so this gem adds
15
+ # a transport and nothing else. Optional — an installation that does not
16
+ # want an MCP endpoint simply does not load it.
17
+ #
18
+ # See docs/plans/6.0-mcp-server.md.
19
+ module Mcp
20
+ end
21
+ end
metadata ADDED
@@ -0,0 +1,101 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: spree_mcp
3
+ version: !ruby/object:Gem::Version
4
+ version: 6.0.0.rc4
5
+ platform: ruby
6
+ authors:
7
+ - Vendo Connect Inc.
8
+ - Vendo Sp. z o.o.
9
+ autorequire:
10
+ bindir: bin
11
+ cert_chain: []
12
+ date: 2026-10-19 00:00:00.000000000 Z
13
+ dependencies:
14
+ - !ruby/object:Gem::Dependency
15
+ name: spree_core
16
+ requirement: !ruby/object:Gem::Requirement
17
+ requirements:
18
+ - - '='
19
+ - !ruby/object:Gem::Version
20
+ version: 6.0.0.rc4
21
+ type: :runtime
22
+ prerelease: false
23
+ version_requirements: !ruby/object:Gem::Requirement
24
+ requirements:
25
+ - - '='
26
+ - !ruby/object:Gem::Version
27
+ version: 6.0.0.rc4
28
+ - !ruby/object:Gem::Dependency
29
+ name: spree_api
30
+ requirement: !ruby/object:Gem::Requirement
31
+ requirements:
32
+ - - '='
33
+ - !ruby/object:Gem::Version
34
+ version: 6.0.0.rc4
35
+ type: :runtime
36
+ prerelease: false
37
+ version_requirements: !ruby/object:Gem::Requirement
38
+ requirements:
39
+ - - '='
40
+ - !ruby/object:Gem::Version
41
+ version: 6.0.0.rc4
42
+ - !ruby/object:Gem::Dependency
43
+ name: mcp
44
+ requirement: !ruby/object:Gem::Requirement
45
+ requirements:
46
+ - - "~>"
47
+ - !ruby/object:Gem::Version
48
+ version: '1.5'
49
+ type: :runtime
50
+ prerelease: false
51
+ version_requirements: !ruby/object:Gem::Requirement
52
+ requirements:
53
+ - - "~>"
54
+ - !ruby/object:Gem::Version
55
+ version: '1.5'
56
+ description: Mounts an MCP endpoint on the Spree Admin API so any MCP client can merchandise
57
+ the catalog, query reports and manage store setup in plain language, under the same
58
+ tenancy and permission rules as the API
59
+ email: hello@spreecommerce.org
60
+ executables: []
61
+ extensions: []
62
+ extra_rdoc_files: []
63
+ files:
64
+ - LICENSE
65
+ - README.md
66
+ - Rakefile
67
+ - app/controllers/spree/api/v3/admin/mcp_controller.rb
68
+ - config/routes.rb
69
+ - lib/spree/mcp/engine.rb
70
+ - lib/spree/mcp/exports.rb
71
+ - lib/spree/mcp/server.rb
72
+ - lib/spree/mcp/tool_adapter.rb
73
+ - lib/spree_mcp.rb
74
+ homepage: https://spreecommerce.org
75
+ licenses:
76
+ - MIT
77
+ metadata:
78
+ bug_tracker_uri: https://github.com/spree/spree/issues
79
+ changelog_uri: https://github.com/spree/spree/releases/tag/v6.0.0.rc4
80
+ documentation_uri: https://docs.spreecommerce.org/
81
+ source_code_uri: https://github.com/spree/spree/tree/v6.0.0.rc4
82
+ post_install_message:
83
+ rdoc_options: []
84
+ require_paths:
85
+ - lib
86
+ required_ruby_version: !ruby/object:Gem::Requirement
87
+ requirements:
88
+ - - ">="
89
+ - !ruby/object:Gem::Version
90
+ version: '3.2'
91
+ required_rubygems_version: !ruby/object:Gem::Requirement
92
+ requirements:
93
+ - - ">="
94
+ - !ruby/object:Gem::Version
95
+ version: '0'
96
+ requirements: []
97
+ rubygems_version: 3.5.22
98
+ signing_key:
99
+ specification_version: 4
100
+ summary: Model Context Protocol server for the Spree Admin API
101
+ test_files: []