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 +7 -0
- data/LICENSE +21 -0
- data/README.md +24 -0
- data/Rakefile +15 -0
- data/app/controllers/spree/api/v3/admin/mcp_controller.rb +195 -0
- data/config/routes.rb +12 -0
- data/lib/spree/mcp/engine.rb +9 -0
- data/lib/spree/mcp/exports.rb +122 -0
- data/lib/spree/mcp/server.rb +116 -0
- data/lib/spree/mcp/tool_adapter.rb +144 -0
- data/lib/spree_mcp.rb +21 -0
- metadata +101 -0
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,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: []
|