mcp in cart
This commit is contained in:
+20
-12
@@ -15,6 +15,7 @@ import (
|
|||||||
"git.k6n.net/mats/go-cart-actor/pkg/actor"
|
"git.k6n.net/mats/go-cart-actor/pkg/actor"
|
||||||
"git.k6n.net/mats/go-cart-actor/pkg/cart"
|
"git.k6n.net/mats/go-cart-actor/pkg/cart"
|
||||||
"git.k6n.net/mats/go-cart-actor/pkg/promotions"
|
"git.k6n.net/mats/go-cart-actor/pkg/promotions"
|
||||||
|
promotionmcp "git.k6n.net/mats/go-cart-actor/pkg/promotions/mcp"
|
||||||
"git.k6n.net/mats/go-cart-actor/pkg/proxy"
|
"git.k6n.net/mats/go-cart-actor/pkg/proxy"
|
||||||
"git.k6n.net/mats/go-cart-actor/pkg/voucher"
|
"git.k6n.net/mats/go-cart-actor/pkg/voucher"
|
||||||
"git.k6n.net/mats/go-redis-inventory/pkg/inventory"
|
"git.k6n.net/mats/go-redis-inventory/pkg/inventory"
|
||||||
@@ -111,16 +112,18 @@ func main() {
|
|||||||
|
|
||||||
controlPlaneConfig := actor.DefaultServerConfig()
|
controlPlaneConfig := actor.DefaultServerConfig()
|
||||||
|
|
||||||
promotionData, err := promotions.LoadStateFile("data/promotions.json")
|
promotionStore, err := promotions.NewStore("data/promotions.json")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("Error loading promotions: %v\n", err)
|
log.Fatalf("Error loading promotions: %v\n", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Printf("loaded %d promotions", len(promotionData.State.Promotions))
|
log.Printf("loaded %d promotions", len(promotionStore.List()))
|
||||||
|
|
||||||
|
promotionService := promotions.NewPromotionService(nil)
|
||||||
|
promotionMCP := promotionmcp.New(promotionStore, promotionService)
|
||||||
|
|
||||||
//inventoryPubSub := actor.NewPubSub[inventory.InventoryChange]()
|
//inventoryPubSub := actor.NewPubSub[inventory.InventoryChange]()
|
||||||
|
|
||||||
// promotionService := promotions.NewPromotionService(nil)
|
|
||||||
// rdb := redis.NewClient(&redis.Options{
|
// rdb := redis.NewClient(&redis.Options{
|
||||||
// Addr: redisAddress,
|
// Addr: redisAddress,
|
||||||
// Password: redisPassword,
|
// Password: redisPassword,
|
||||||
@@ -142,15 +145,13 @@ func main() {
|
|||||||
_, span := tracer.Start(ctx, "Totals and promotions")
|
_, span := tracer.Start(ctx, "Totals and promotions")
|
||||||
defer span.End()
|
defer span.End()
|
||||||
g.UpdateTotals()
|
g.UpdateTotals()
|
||||||
|
// Evaluate active promotions against the freshly-totalled cart and apply
|
||||||
|
// any matched actions (e.g. the Volymrabatt volume discount), which adjust
|
||||||
|
// TotalPrice/TotalDiscount on top of line items and vouchers.
|
||||||
|
promotionCtx := promotions.NewContextFromCart(g, promotions.WithNow(time.Now()))
|
||||||
|
_, actions := promotionService.EvaluateAll(promotionStore.Snapshot(), promotionCtx)
|
||||||
|
promotions.ApplyActions(g, actions)
|
||||||
g.Version++
|
g.Version++
|
||||||
// promotionCtx := promotions.NewContextFromCart(g, promotions.WithNow(time.Now()), promotions.WithCustomerSegment("vip"))
|
|
||||||
// _, actions := promotionService.EvaluateAll(promotionData.State.Promotions, promotionCtx)
|
|
||||||
// for _, action := range actions {
|
|
||||||
|
|
||||||
// log.Printf("apply: %+v", action)
|
|
||||||
|
|
||||||
// g.UpdateTotals()
|
|
||||||
// }
|
|
||||||
return nil
|
return nil
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
@@ -267,6 +268,13 @@ func main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
syncedServer.Serve(mux)
|
syncedServer.Serve(mux)
|
||||||
|
|
||||||
|
// Promotion MCP edge: list/get/upsert/status/delete promotions and preview
|
||||||
|
// discounts. Mutations persist to data/promotions.json and are picked up by
|
||||||
|
// the cart's totals processor on the next mutation (it reads a fresh snapshot).
|
||||||
|
mux.Handle("/mcp", promotionMCP.Handler())
|
||||||
|
mux.Handle("/mcp/", promotionMCP.Handler())
|
||||||
|
|
||||||
// only for local
|
// only for local
|
||||||
mux.HandleFunc("GET /add/remote/{host}", func(w http.ResponseWriter, r *http.Request) {
|
mux.HandleFunc("GET /add/remote/{host}", func(w http.ResponseWriter, r *http.Request) {
|
||||||
pool.AddRemote(r.PathValue("host"))
|
pool.AddRemote(r.PathValue("host"))
|
||||||
|
|||||||
+45
-1
@@ -1 +1,45 @@
|
|||||||
{}
|
{
|
||||||
|
"version": 1,
|
||||||
|
"state": {
|
||||||
|
"promotions": [
|
||||||
|
{
|
||||||
|
"id": "volymrabatt",
|
||||||
|
"name": "Volymrabatt",
|
||||||
|
"description": "Automatisk volymrabatt baserad på varukorgens totalsumma (inkl. moms). Belopp i öre: 20 000–40 000 kr → 5%, 40 000–60 000 kr → 9%, 60 000–90 000 kr → 14%.",
|
||||||
|
"status": "active",
|
||||||
|
"priority": 100,
|
||||||
|
"startDate": "2024-01-01",
|
||||||
|
"endDate": null,
|
||||||
|
"conditions": [
|
||||||
|
{
|
||||||
|
"id": "min-cart-total",
|
||||||
|
"type": "cart_total",
|
||||||
|
"operator": ">=",
|
||||||
|
"value": 2000000,
|
||||||
|
"label": "Minst 20 000 kr i varukorgen"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"actions": [
|
||||||
|
{
|
||||||
|
"id": "volymrabatt-tiers",
|
||||||
|
"type": "tiered_discount",
|
||||||
|
"value": 0,
|
||||||
|
"config": {
|
||||||
|
"tiers": [
|
||||||
|
{ "minTotal": 2000000, "maxTotal": 4000000, "percent": 5 },
|
||||||
|
{ "minTotal": 4000000, "maxTotal": 6000000, "percent": 9 },
|
||||||
|
{ "minTotal": 6000000, "maxTotal": 9000000, "percent": 14 }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"label": "Volymrabatt 5–14%"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"usageCount": 0,
|
||||||
|
"createdAt": "2024-01-01T00:00:00Z",
|
||||||
|
"updatedAt": "2024-01-01T00:00:00Z",
|
||||||
|
"createdBy": "mats.tornberg@gmail.com",
|
||||||
|
"tags": ["volume", "volymrabatt", "test"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,149 @@
|
|||||||
|
package promotions
|
||||||
|
|
||||||
|
import (
|
||||||
|
"math"
|
||||||
|
|
||||||
|
"git.k6n.net/mats/go-cart-actor/pkg/cart"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ----------------------------
|
||||||
|
// Action application
|
||||||
|
// ----------------------------
|
||||||
|
//
|
||||||
|
// The evaluation engine (eval.go) decides *which* actions apply to a cart but
|
||||||
|
// deliberately does not mutate the cart. Applying an action — turning a
|
||||||
|
// "14% tiered discount" into actual öre off the total — happens here, because
|
||||||
|
// it needs to touch cart.Price math. main.go calls ApplyActions after
|
||||||
|
// UpdateTotals so the base totals (line items + vouchers) are settled before
|
||||||
|
// promotions reduce them.
|
||||||
|
|
||||||
|
// DiscountTier describes one band of a tiered (volume) discount. Bounds are the
|
||||||
|
// cart total *including VAT*, in öre (the cart's native unit), matching
|
||||||
|
// PromotionEvalContext.CartTotalIncVat. MaxTotal == 0 means "no upper bound".
|
||||||
|
//
|
||||||
|
// A tier matches when MinTotal <= cartTotal && (MaxTotal == 0 || cartTotal <= MaxTotal).
|
||||||
|
// Bounds are allowed to touch (e.g. 40000 ends one tier and begins the next);
|
||||||
|
// when a total lands exactly on a shared boundary the higher tier wins, since
|
||||||
|
// selectTier keeps the last match while scanning in order.
|
||||||
|
type DiscountTier struct {
|
||||||
|
MinTotal int64
|
||||||
|
MaxTotal int64
|
||||||
|
Percent float64
|
||||||
|
}
|
||||||
|
|
||||||
|
// ApplyActions applies the matched promotion actions to the cart grain,
|
||||||
|
// reducing TotalPrice and growing TotalDiscount. It is safe to call with a nil
|
||||||
|
// or empty action slice. Must be called after g.UpdateTotals().
|
||||||
|
func ApplyActions(g *cart.CartGrain, actions []Action) {
|
||||||
|
if g == nil || g.TotalPrice == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, a := range actions {
|
||||||
|
switch a.Type {
|
||||||
|
case ActionTieredDiscount:
|
||||||
|
applyTieredDiscount(g, a)
|
||||||
|
case ActionPercentageDiscount:
|
||||||
|
applyPercentageDiscount(g, percentFromValue(a.Value))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyTieredDiscount(g *cart.CartGrain, a Action) {
|
||||||
|
tiers := parseTiers(a.Config)
|
||||||
|
if len(tiers) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pct, ok := selectTier(tiers, g.TotalPrice.IncVat)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
applyPercentageDiscount(g, pct)
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyPercentageDiscount removes pct% of the current cart total, preserving the
|
||||||
|
// VAT-rate breakdown proportionally, and records it in TotalDiscount.
|
||||||
|
func applyPercentageDiscount(g *cart.CartGrain, pct float64) {
|
||||||
|
if pct <= 0 || g.TotalPrice.IncVat <= 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
discount := scalePrice(g.TotalPrice, pct)
|
||||||
|
if discount.IncVat <= 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Never discount below zero.
|
||||||
|
if discount.IncVat > g.TotalPrice.IncVat {
|
||||||
|
discount = g.TotalPrice
|
||||||
|
}
|
||||||
|
g.TotalDiscount.Add(*discount)
|
||||||
|
g.TotalPrice.Subtract(*discount)
|
||||||
|
}
|
||||||
|
|
||||||
|
// selectTier returns the discount percent for the band the total falls into.
|
||||||
|
// Scans in declared order and keeps the last match so shared boundaries favour
|
||||||
|
// the higher tier.
|
||||||
|
func selectTier(tiers []DiscountTier, total int64) (float64, bool) {
|
||||||
|
pct := 0.0
|
||||||
|
found := false
|
||||||
|
for _, t := range tiers {
|
||||||
|
if total >= t.MinTotal && (t.MaxTotal == 0 || total <= t.MaxTotal) {
|
||||||
|
pct = t.Percent
|
||||||
|
found = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return pct, found
|
||||||
|
}
|
||||||
|
|
||||||
|
// scalePrice returns pct% of p, rounding each amount and the VAT breakdown
|
||||||
|
// independently so the parts stay consistent with the reported total.
|
||||||
|
func scalePrice(p *cart.Price, pct float64) *cart.Price {
|
||||||
|
out := cart.NewPrice()
|
||||||
|
out.IncVat = scale(p.IncVat, pct)
|
||||||
|
for rate, amount := range p.VatRates {
|
||||||
|
out.VatRates[rate] = scale(amount, pct)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func scale(v int64, pct float64) int64 {
|
||||||
|
return int64(math.Round(float64(v) * pct / 100.0))
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseTiers reads the "tiers" array out of an Action.Config. Because configs
|
||||||
|
// are decoded via encoding/json into map[string]interface{}, every element is a
|
||||||
|
// map[string]interface{} and every number is a float64. Accepts both
|
||||||
|
// minTotal/maxTotal/percent and the snake_case min_total/max_total/discount_percent.
|
||||||
|
func parseTiers(config map[string]interface{}) []DiscountTier {
|
||||||
|
raw, ok := config["tiers"].([]interface{})
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
tiers := make([]DiscountTier, 0, len(raw))
|
||||||
|
for _, r := range raw {
|
||||||
|
m, ok := r.(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
tiers = append(tiers, DiscountTier{
|
||||||
|
MinTotal: int64(firstNum(m, "minTotal", "min_total")),
|
||||||
|
MaxTotal: int64(firstNum(m, "maxTotal", "max_total")),
|
||||||
|
Percent: firstNum(m, "percent", "discount_percent"),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return tiers
|
||||||
|
}
|
||||||
|
|
||||||
|
func percentFromValue(v interface{}) float64 {
|
||||||
|
if f, ok := v.(float64); ok {
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstNum(m map[string]interface{}, keys ...string) float64 {
|
||||||
|
for _, k := range keys {
|
||||||
|
if f, ok := m[k].(float64); ok {
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
package promotions
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.k6n.net/mats/go-cart-actor/pkg/cart"
|
||||||
|
)
|
||||||
|
|
||||||
|
func timeZero() time.Time { return time.Date(2024, 6, 10, 12, 0, 0, 0, time.UTC) }
|
||||||
|
|
||||||
|
// volymrabattRule mirrors the seeded data/promotions.json rule so the test
|
||||||
|
// exercises the same tier configuration shipped to the cart service.
|
||||||
|
func volymrabattRule() PromotionRule {
|
||||||
|
return PromotionRule{
|
||||||
|
ID: "volymrabatt",
|
||||||
|
Name: "Volymrabatt",
|
||||||
|
Status: StatusActive,
|
||||||
|
Priority: 100,
|
||||||
|
Conditions: Conditions{
|
||||||
|
BaseCondition{ID: "min", Type: CondCartTotal, Operator: OpGreaterOrEqual, Value: cvNum(2000000)},
|
||||||
|
},
|
||||||
|
Actions: []Action{
|
||||||
|
{
|
||||||
|
ID: "tiers",
|
||||||
|
Type: ActionTieredDiscount,
|
||||||
|
Config: map[string]interface{}{
|
||||||
|
"tiers": []interface{}{
|
||||||
|
map[string]interface{}{"minTotal": 2000000.0, "maxTotal": 4000000.0, "percent": 5.0},
|
||||||
|
map[string]interface{}{"minTotal": 4000000.0, "maxTotal": 6000000.0, "percent": 9.0},
|
||||||
|
map[string]interface{}{"minTotal": 6000000.0, "maxTotal": 9000000.0, "percent": 14.0},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// cartWithTotal builds a grain whose single line item produces the requested
|
||||||
|
// gross (incl. 25% VAT) total in öre, then runs UpdateTotals.
|
||||||
|
func cartWithTotal(incVat int64) *cart.CartGrain {
|
||||||
|
g := cart.NewCartGrain(1, timeZero())
|
||||||
|
g.Items = []*cart.CartItem{
|
||||||
|
{Id: 1, Sku: "SKU", Quantity: 1, Price: *cart.NewPriceFromIncVat(incVat, 25)},
|
||||||
|
}
|
||||||
|
g.UpdateTotals()
|
||||||
|
return g
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVolymrabattTiers(t *testing.T) {
|
||||||
|
svc := NewPromotionService(nil)
|
||||||
|
rules := []PromotionRule{volymrabattRule()}
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
total int64
|
||||||
|
wantDiscount int64
|
||||||
|
wantNet int64
|
||||||
|
}{
|
||||||
|
{"below threshold (19 999 kr)", 1999900, 0, 1999900},
|
||||||
|
{"low tier 5% (20 000 kr)", 2000000, 100000, 1900000},
|
||||||
|
{"low tier boundary just under 40 000", 3999900, 199995, 3799905},
|
||||||
|
{"mid tier 9% at 40 000 boundary", 4000000, 360000, 3640000},
|
||||||
|
{"mid tier 9% (50 000 kr)", 5000000, 450000, 4550000},
|
||||||
|
{"high tier 14% at 60 000 boundary", 6000000, 840000, 5160000},
|
||||||
|
{"high tier 14% (90 000 kr)", 9000000, 1260000, 7740000},
|
||||||
|
{"above top tier (100 000 kr) -> no discount", 10000000, 0, 10000000},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, c := range cases {
|
||||||
|
t.Run(c.name, func(t *testing.T) {
|
||||||
|
g := cartWithTotal(c.total)
|
||||||
|
_, actions := svc.EvaluateAll(rules, NewContextFromCart(g, WithNow(timeZero())))
|
||||||
|
ApplyActions(g, actions)
|
||||||
|
|
||||||
|
if got := g.TotalDiscount.IncVat; got != c.wantDiscount {
|
||||||
|
t.Errorf("discount = %d, want %d", got, c.wantDiscount)
|
||||||
|
}
|
||||||
|
if got := g.TotalPrice.IncVat; got != c.wantNet {
|
||||||
|
t.Errorf("net total = %d, want %d", got, c.wantNet)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
package mcp
|
||||||
|
|
||||||
|
import "encoding/json"
|
||||||
|
|
||||||
|
// JSON-RPC 2.0 envelope types (the wire format MCP rides on). Notifications are
|
||||||
|
// requests with no id and must not receive a response.
|
||||||
|
|
||||||
|
type rpcRequest struct {
|
||||||
|
JSONRPC string `json:"jsonrpc"`
|
||||||
|
ID json.RawMessage `json:"id,omitempty"`
|
||||||
|
Method string `json:"method"`
|
||||||
|
Params json.RawMessage `json:"params,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *rpcRequest) isNotification() bool { return len(r.ID) == 0 }
|
||||||
|
|
||||||
|
type rpcResponse struct {
|
||||||
|
JSONRPC string `json:"jsonrpc"`
|
||||||
|
ID json.RawMessage `json:"id,omitempty"`
|
||||||
|
Result any `json:"result,omitempty"`
|
||||||
|
Error *rpcError `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type rpcError struct {
|
||||||
|
Code int `json:"code"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
Data any `json:"data,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
codeParseError = -32700
|
||||||
|
codeInvalidRequest = -32600
|
||||||
|
codeMethodNotFound = -32601
|
||||||
|
codeInvalidParams = -32602
|
||||||
|
codeInternalError = -32603
|
||||||
|
)
|
||||||
|
|
||||||
|
func result(id json.RawMessage, v any) *rpcResponse {
|
||||||
|
return &rpcResponse{JSONRPC: "2.0", ID: id, Result: v}
|
||||||
|
}
|
||||||
|
|
||||||
|
func errorResponse(id json.RawMessage, code int, msg string) *rpcResponse {
|
||||||
|
return &rpcResponse{JSONRPC: "2.0", ID: id, Error: &rpcError{Code: code, Message: msg}}
|
||||||
|
}
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
// Package mcp is the MCP edge for the cart's promotion engine: a Model Context
|
||||||
|
// Protocol server exposing the promotion Store and evaluator as tools so an
|
||||||
|
// agent can list, create, update, status-toggle and delete the promotions the
|
||||||
|
// cart actually applies (e.g. Volymrabatt), plus preview the discount a cart
|
||||||
|
// total would receive.
|
||||||
|
//
|
||||||
|
// Transport is streamable HTTP (JSON-RPC 2.0 over POST). It is mounted by
|
||||||
|
// cmd/cart under /mcp on the same HTTP server as the cart API. Tools mutate the
|
||||||
|
// shared promotions.Store and persist to data/promotions.json; the cart's
|
||||||
|
// mutation processor reads a fresh Store snapshot on every recompute, so edits
|
||||||
|
// take effect on the next cart change without a restart.
|
||||||
|
package mcp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"git.k6n.net/mats/go-cart-actor/pkg/promotions"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
serverName = "cart-promotions"
|
||||||
|
serverVersion = "0.1.0"
|
||||||
|
protocolVersion = "2025-06-18"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Server is the MCP edge over the shared promotion Store and evaluator.
|
||||||
|
type Server struct {
|
||||||
|
store *promotions.Store
|
||||||
|
eval *promotions.PromotionService
|
||||||
|
tools []tool
|
||||||
|
}
|
||||||
|
|
||||||
|
// New builds an MCP server exposing the promotion store as tools.
|
||||||
|
func New(store *promotions.Store, eval *promotions.PromotionService) *Server {
|
||||||
|
if eval == nil {
|
||||||
|
eval = promotions.NewPromotionService(nil)
|
||||||
|
}
|
||||||
|
s := &Server{store: store, eval: eval}
|
||||||
|
s.tools = s.buildTools()
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handler returns the streamable-HTTP handler to mount (e.g. at /mcp).
|
||||||
|
func (s *Server) Handler() http.Handler {
|
||||||
|
return http.HandlerFunc(s.serveHTTP)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) serveHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
w.Header().Set("Allow", "POST")
|
||||||
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
body, err := io.ReadAll(io.LimitReader(r.Body, 4<<20))
|
||||||
|
if err != nil {
|
||||||
|
writeRPC(w, errorResponse(nil, codeParseError, "read error"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if isBatch(body) {
|
||||||
|
var reqs []rpcRequest
|
||||||
|
if err := json.Unmarshal(body, &reqs); err != nil {
|
||||||
|
writeRPC(w, errorResponse(nil, codeParseError, "invalid JSON"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var out []*rpcResponse
|
||||||
|
for i := range reqs {
|
||||||
|
if resp := s.dispatch(&reqs[i]); resp != nil {
|
||||||
|
out = append(out, resp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(out) == 0 {
|
||||||
|
w.WriteHeader(http.StatusAccepted)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeRPC(w, out)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req rpcRequest
|
||||||
|
if err := json.Unmarshal(body, &req); err != nil {
|
||||||
|
writeRPC(w, errorResponse(nil, codeParseError, "invalid JSON"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
resp := s.dispatch(&req)
|
||||||
|
if resp == nil {
|
||||||
|
w.WriteHeader(http.StatusAccepted)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeRPC(w, resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) dispatch(req *rpcRequest) *rpcResponse {
|
||||||
|
if req.JSONRPC != "2.0" {
|
||||||
|
if req.isNotification() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return errorResponse(req.ID, codeInvalidRequest, "jsonrpc must be \"2.0\"")
|
||||||
|
}
|
||||||
|
switch req.Method {
|
||||||
|
case "initialize":
|
||||||
|
return result(req.ID, s.initialize(req.Params))
|
||||||
|
case "ping":
|
||||||
|
return result(req.ID, struct{}{})
|
||||||
|
case "tools/list":
|
||||||
|
return result(req.ID, map[string]any{"tools": s.tools})
|
||||||
|
case "tools/call":
|
||||||
|
return s.callTool(req)
|
||||||
|
case "notifications/initialized", "notifications/cancelled":
|
||||||
|
return nil
|
||||||
|
default:
|
||||||
|
if req.isNotification() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return errorResponse(req.ID, codeMethodNotFound, "unknown method: "+req.Method)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) initialize(params json.RawMessage) map[string]any {
|
||||||
|
pv := protocolVersion
|
||||||
|
if len(params) > 0 {
|
||||||
|
var p struct {
|
||||||
|
ProtocolVersion string `json:"protocolVersion"`
|
||||||
|
}
|
||||||
|
if json.Unmarshal(params, &p) == nil && p.ProtocolVersion != "" {
|
||||||
|
pv = p.ProtocolVersion
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map[string]any{
|
||||||
|
"protocolVersion": pv,
|
||||||
|
"capabilities": map[string]any{"tools": map[string]any{}},
|
||||||
|
"serverInfo": map[string]any{"name": serverName, "version": serverVersion},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func isBatch(body []byte) bool {
|
||||||
|
for _, b := range body {
|
||||||
|
switch b {
|
||||||
|
case ' ', '\t', '\r', '\n':
|
||||||
|
continue
|
||||||
|
case '[':
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeRPC(w http.ResponseWriter, v any) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
_ = json.NewEncoder(w).Encode(v)
|
||||||
|
}
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
package mcp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.k6n.net/mats/go-cart-actor/pkg/promotions"
|
||||||
|
)
|
||||||
|
|
||||||
|
// call issues a tools/call against the handler and returns the decoded text
|
||||||
|
// payload (the JSON the tool returned), failing on a tool/transport error.
|
||||||
|
func call(t *testing.T, h http.Handler, name string, args map[string]any) map[string]any {
|
||||||
|
t.Helper()
|
||||||
|
body, _ := json.Marshal(map[string]any{
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"id": 1,
|
||||||
|
"method": "tools/call",
|
||||||
|
"params": map[string]any{"name": name, "arguments": args},
|
||||||
|
})
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/mcp", bytes.NewReader(body))
|
||||||
|
h.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("%s: status %d", name, rec.Code)
|
||||||
|
}
|
||||||
|
var resp struct {
|
||||||
|
Result struct {
|
||||||
|
IsError bool `json:"isError"`
|
||||||
|
Content []struct {
|
||||||
|
Text string `json:"text"`
|
||||||
|
} `json:"content"`
|
||||||
|
} `json:"result"`
|
||||||
|
Error *struct {
|
||||||
|
Message string `json:"message"`
|
||||||
|
} `json:"error"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||||
|
t.Fatalf("%s: decode response: %v (%s)", name, err, rec.Body.String())
|
||||||
|
}
|
||||||
|
if resp.Error != nil {
|
||||||
|
t.Fatalf("%s: rpc error: %s", name, resp.Error.Message)
|
||||||
|
}
|
||||||
|
if resp.Result.IsError {
|
||||||
|
t.Fatalf("%s: tool error: %s", name, resp.Result.Content[0].Text)
|
||||||
|
}
|
||||||
|
var out map[string]any
|
||||||
|
if len(resp.Result.Content) > 0 {
|
||||||
|
_ = json.Unmarshal([]byte(resp.Result.Content[0].Text), &out)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPromotionMCPRoundTrip(t *testing.T) {
|
||||||
|
path := filepath.Join(t.TempDir(), "promotions.json")
|
||||||
|
store, err := promotions.NewStore(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
h := New(store, nil).Handler()
|
||||||
|
|
||||||
|
rule := map[string]any{
|
||||||
|
"id": "volymrabatt",
|
||||||
|
"name": "Volymrabatt",
|
||||||
|
"status": "active",
|
||||||
|
"priority": 100,
|
||||||
|
"conditions": []any{
|
||||||
|
map[string]any{"id": "min", "type": "cart_total", "operator": ">=", "value": 2000000},
|
||||||
|
},
|
||||||
|
"actions": []any{
|
||||||
|
map[string]any{
|
||||||
|
"id": "tiers", "type": "tiered_discount", "value": 0,
|
||||||
|
"config": map[string]any{
|
||||||
|
"tiers": []any{
|
||||||
|
map[string]any{"minTotal": 2000000, "maxTotal": 4000000, "percent": 5},
|
||||||
|
map[string]any{"minTotal": 4000000, "maxTotal": 6000000, "percent": 9},
|
||||||
|
map[string]any{"minTotal": 6000000, "maxTotal": 9000000, "percent": 14},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upsert (create).
|
||||||
|
got := call(t, h, "upsert_promotion", map[string]any{"promotion": rule})
|
||||||
|
if got["replaced"] != false {
|
||||||
|
t.Errorf("first upsert should create, got replaced=%v", got["replaced"])
|
||||||
|
}
|
||||||
|
|
||||||
|
// It persisted to disk and a fresh store sees it.
|
||||||
|
reloaded, err := promotions.NewStore(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(reloaded.List()) != 1 {
|
||||||
|
t.Fatalf("expected 1 persisted promotion, got %d", len(reloaded.List()))
|
||||||
|
}
|
||||||
|
|
||||||
|
// List.
|
||||||
|
listed := call(t, h, "list_promotions", map[string]any{"status": "active"})
|
||||||
|
if c, _ := listed["count"].(float64); c != 1 {
|
||||||
|
t.Errorf("list count = %v, want 1", listed["count"])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Preview the 9% mid tier at 50 000 kr.
|
||||||
|
preview := call(t, h, "preview_cart_discount", map[string]any{"cartTotalIncVat": 5000000})
|
||||||
|
if d, _ := preview["discountIncVat"].(float64); int64(d) != 450000 {
|
||||||
|
t.Errorf("discount = %v, want 450000", preview["discountIncVat"])
|
||||||
|
}
|
||||||
|
if n, _ := preview["netIncVat"].(float64); int64(n) != 4550000 {
|
||||||
|
t.Errorf("net = %v, want 4550000", preview["netIncVat"])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Toggle off → preview yields no discount.
|
||||||
|
call(t, h, "set_promotion_status", map[string]any{"id": "volymrabatt", "status": "inactive"})
|
||||||
|
off := call(t, h, "preview_cart_discount", map[string]any{"cartTotalIncVat": 5000000})
|
||||||
|
if d, _ := off["discountIncVat"].(float64); d != 0 {
|
||||||
|
t.Errorf("inactive promo should give 0 discount, got %v", off["discountIncVat"])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete.
|
||||||
|
call(t, h, "delete_promotion", map[string]any{"id": "volymrabatt"})
|
||||||
|
final := call(t, h, "list_promotions", nil)
|
||||||
|
if c, _ := final["count"].(float64); c != 0 {
|
||||||
|
t.Errorf("after delete count = %v, want 0", final["count"])
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,289 @@
|
|||||||
|
package mcp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.k6n.net/mats/go-cart-actor/pkg/cart"
|
||||||
|
"git.k6n.net/mats/go-cart-actor/pkg/promotions"
|
||||||
|
)
|
||||||
|
|
||||||
|
// tool is one MCP tool: a name, a description, a JSON-Schema for its arguments,
|
||||||
|
// and the handler that runs it.
|
||||||
|
type tool struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
InputSchema json.RawMessage `json:"inputSchema"`
|
||||||
|
invoke func(args json.RawMessage) (any, error) `json:"-"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) callTool(req *rpcRequest) *rpcResponse {
|
||||||
|
var p struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Arguments json.RawMessage `json:"arguments"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(req.Params, &p); err != nil {
|
||||||
|
return errorResponse(req.ID, codeInvalidParams, "invalid params")
|
||||||
|
}
|
||||||
|
var t *tool
|
||||||
|
for i := range s.tools {
|
||||||
|
if s.tools[i].Name == p.Name {
|
||||||
|
t = &s.tools[i]
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if t == nil {
|
||||||
|
return errorResponse(req.ID, codeInvalidParams, "unknown tool: "+p.Name)
|
||||||
|
}
|
||||||
|
out, err := t.invoke(p.Arguments)
|
||||||
|
if err != nil {
|
||||||
|
return result(req.ID, toolError(err))
|
||||||
|
}
|
||||||
|
return result(req.ID, toolText(out))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) buildTools() []tool {
|
||||||
|
return []tool{
|
||||||
|
{
|
||||||
|
Name: "list_promotions",
|
||||||
|
Description: "List all promotion rules the cart engine evaluates, optionally filtered by status (active|inactive|scheduled|expired).",
|
||||||
|
InputSchema: object(props{
|
||||||
|
"status": str("optional status filter: active|inactive|scheduled|expired"),
|
||||||
|
}, nil),
|
||||||
|
invoke: func(args json.RawMessage) (any, error) {
|
||||||
|
var a struct {
|
||||||
|
Status string `json:"status"`
|
||||||
|
}
|
||||||
|
if err := decode(args, &a); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
rules := s.store.List()
|
||||||
|
if a.Status != "" {
|
||||||
|
filtered := rules[:0:0]
|
||||||
|
for _, r := range rules {
|
||||||
|
if string(r.Status) == a.Status {
|
||||||
|
filtered = append(filtered, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rules = filtered
|
||||||
|
}
|
||||||
|
return map[string]any{"promotions": rules, "count": len(rules)}, nil
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "get_promotion",
|
||||||
|
Description: "Fetch a single promotion rule by id.",
|
||||||
|
InputSchema: object(props{"id": str("the promotion id")}, []string{"id"}),
|
||||||
|
invoke: func(args json.RawMessage) (any, error) {
|
||||||
|
var a struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
}
|
||||||
|
if err := decode(args, &a); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
r, ok := s.store.Get(a.ID)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("promotion %q not found", a.ID)
|
||||||
|
}
|
||||||
|
return r, nil
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "upsert_promotion",
|
||||||
|
Description: "Create a promotion or replace the existing one with the same id. " +
|
||||||
|
"`promotion` is a full promotion-rule object (id, name, status, priority, conditions, actions). " +
|
||||||
|
"For a tiered volume discount (Volymrabatt) use a cart_total condition plus a tiered_discount action " +
|
||||||
|
"whose config.tiers each have minTotal/maxTotal (öre incl. VAT) and percent.",
|
||||||
|
InputSchema: object(props{
|
||||||
|
"promotion": obj("the full promotion rule object"),
|
||||||
|
}, []string{"promotion"}),
|
||||||
|
invoke: func(args json.RawMessage) (any, error) {
|
||||||
|
var a struct {
|
||||||
|
Promotion json.RawMessage `json:"promotion"`
|
||||||
|
}
|
||||||
|
if err := decode(args, &a); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var rule promotions.PromotionRule
|
||||||
|
if err := json.Unmarshal(a.Promotion, &rule); err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid promotion: %w", err)
|
||||||
|
}
|
||||||
|
if rule.ID == "" {
|
||||||
|
return nil, fmt.Errorf("promotion.id is required")
|
||||||
|
}
|
||||||
|
if rule.Status == "" {
|
||||||
|
rule.Status = promotions.StatusActive
|
||||||
|
}
|
||||||
|
replaced, err := s.store.Upsert(rule)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return map[string]any{"id": rule.ID, "replaced": replaced}, nil
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "set_promotion_status",
|
||||||
|
Description: "Set a promotion's status (active|inactive|scheduled|expired) to toggle it on or off.",
|
||||||
|
InputSchema: object(props{
|
||||||
|
"id": str("the promotion id"),
|
||||||
|
"status": str("new status: active|inactive|scheduled|expired"),
|
||||||
|
}, []string{"id", "status"}),
|
||||||
|
invoke: func(args json.RawMessage) (any, error) {
|
||||||
|
var a struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
}
|
||||||
|
if err := decode(args, &a); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !validStatus(a.Status) {
|
||||||
|
return nil, fmt.Errorf("invalid status %q", a.Status)
|
||||||
|
}
|
||||||
|
found, err := s.store.SetStatus(a.ID, promotions.PromotionStatus(a.Status))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
return nil, fmt.Errorf("promotion %q not found", a.ID)
|
||||||
|
}
|
||||||
|
return map[string]any{"id": a.ID, "status": a.Status}, nil
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "delete_promotion",
|
||||||
|
Description: "Delete a promotion rule by id.",
|
||||||
|
InputSchema: object(props{"id": str("the promotion id")}, []string{"id"}),
|
||||||
|
invoke: func(args json.RawMessage) (any, error) {
|
||||||
|
var a struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
}
|
||||||
|
if err := decode(args, &a); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
found, err := s.store.Delete(a.ID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
return nil, fmt.Errorf("promotion %q not found", a.ID)
|
||||||
|
}
|
||||||
|
return map[string]any{"id": a.ID, "deleted": true}, nil
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "preview_cart_discount",
|
||||||
|
Description: "Evaluate the current active promotions against a hypothetical cart total and report the " +
|
||||||
|
"matched discount. cartTotalIncVat is in öre incl. VAT (e.g. 5 000 000 = 50 000 kr). Useful for " +
|
||||||
|
"verifying Volymrabatt tiers without touching a real cart.",
|
||||||
|
InputSchema: object(props{
|
||||||
|
"cartTotalIncVat": integer("cart total incl. VAT, in öre"),
|
||||||
|
"itemQuantity": integer("optional total item quantity"),
|
||||||
|
"customerSegment": str("optional customer segment"),
|
||||||
|
}, []string{"cartTotalIncVat"}),
|
||||||
|
invoke: func(args json.RawMessage) (any, error) {
|
||||||
|
var a struct {
|
||||||
|
CartTotalIncVat int64 `json:"cartTotalIncVat"`
|
||||||
|
ItemQuantity int `json:"itemQuantity"`
|
||||||
|
CustomerSegment string `json:"customerSegment"`
|
||||||
|
}
|
||||||
|
if err := decode(args, &a); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
g := syntheticCart(a.CartTotalIncVat, a.ItemQuantity)
|
||||||
|
opts := []promotions.ContextOption{promotions.WithNow(time.Now())}
|
||||||
|
if a.CustomerSegment != "" {
|
||||||
|
opts = append(opts, promotions.WithCustomerSegment(a.CustomerSegment))
|
||||||
|
}
|
||||||
|
ctx := promotions.NewContextFromCart(g, opts...)
|
||||||
|
_, actions := s.eval.EvaluateAll(s.store.Snapshot(), ctx)
|
||||||
|
promotions.ApplyActions(g, actions)
|
||||||
|
return map[string]any{
|
||||||
|
"subtotalIncVat": a.CartTotalIncVat,
|
||||||
|
"discountIncVat": g.TotalDiscount.IncVat,
|
||||||
|
"netIncVat": g.TotalPrice.IncVat,
|
||||||
|
"matchedActions": actions,
|
||||||
|
}, nil
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// syntheticCart builds a one-line cart whose gross total is incVat öre (25% VAT
|
||||||
|
// assumed) so the promotion engine can be evaluated against an arbitrary total.
|
||||||
|
func syntheticCart(incVat int64, qty int) *cart.CartGrain {
|
||||||
|
if qty <= 0 {
|
||||||
|
qty = 1
|
||||||
|
}
|
||||||
|
g := cart.NewCartGrain(0, time.Now())
|
||||||
|
g.Items = []*cart.CartItem{
|
||||||
|
{Id: 1, Sku: "PREVIEW", Quantity: uint16(qty), Price: *cart.NewPriceFromIncVat(incVat, 25)},
|
||||||
|
}
|
||||||
|
// Quantity > 1 would multiply the row total; keep the gross equal to incVat by
|
||||||
|
// pricing a single unit at the full total.
|
||||||
|
g.Items[0].Quantity = 1
|
||||||
|
g.UpdateTotals()
|
||||||
|
g.Items[0].Quantity = uint16(qty)
|
||||||
|
return g
|
||||||
|
}
|
||||||
|
|
||||||
|
func validStatus(s string) bool {
|
||||||
|
switch promotions.PromotionStatus(s) {
|
||||||
|
case promotions.StatusActive, promotions.StatusInactive, promotions.StatusScheduled, promotions.StatusExpired:
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- result + schema helpers (mirrors the graph-cms MCP edge) -------------
|
||||||
|
|
||||||
|
func toolText(v any) map[string]any {
|
||||||
|
b, err := json.Marshal(v)
|
||||||
|
if err != nil {
|
||||||
|
return toolError(err)
|
||||||
|
}
|
||||||
|
return map[string]any{
|
||||||
|
"content": []map[string]any{{"type": "text", "text": string(b)}},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func toolError(err error) map[string]any {
|
||||||
|
return map[string]any{
|
||||||
|
"isError": true,
|
||||||
|
"content": []map[string]any{{"type": "text", "text": err.Error()}},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// decode unmarshals tool arguments, tolerating empty/absent arguments.
|
||||||
|
func decode(args json.RawMessage, v any) error {
|
||||||
|
if len(args) == 0 || string(args) == "null" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(args, v); err != nil {
|
||||||
|
return fmt.Errorf("invalid arguments: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type props map[string]json.RawMessage
|
||||||
|
|
||||||
|
func object(p props, required []string) json.RawMessage {
|
||||||
|
m := map[string]any{
|
||||||
|
"type": "object",
|
||||||
|
"properties": p,
|
||||||
|
}
|
||||||
|
if len(required) > 0 {
|
||||||
|
m["required"] = required
|
||||||
|
}
|
||||||
|
b, _ := json.Marshal(m)
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
func str(desc string) json.RawMessage { return scalar("string", desc) }
|
||||||
|
func obj(desc string) json.RawMessage { return scalar("object", desc) }
|
||||||
|
func integer(d string) json.RawMessage { return scalar("integer", d) }
|
||||||
|
|
||||||
|
func scalar(typ, desc string) json.RawMessage {
|
||||||
|
b, _ := json.Marshal(map[string]any{"type": typ, "description": desc})
|
||||||
|
return b
|
||||||
|
}
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
package promotions
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Store is a thread-safe, file-backed collection of promotion rules. It is the
|
||||||
|
// single source of truth shared by the cart's mutation processor (which reads a
|
||||||
|
// snapshot on every recompute) and the promotion MCP tools (which mutate it and
|
||||||
|
// persist to disk). Because the cart re-reads the snapshot each time totals are
|
||||||
|
// recomputed, MCP edits take effect on the next cart mutation — no restart.
|
||||||
|
type Store struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
path string
|
||||||
|
version int
|
||||||
|
promotions []PromotionRule
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewStore loads the state file at path (an absent file yields an empty store)
|
||||||
|
// and returns a Store that persists back to the same path.
|
||||||
|
func NewStore(path string) (*Store, error) {
|
||||||
|
sf, err := LoadStateFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
version := sf.Version
|
||||||
|
if version == 0 {
|
||||||
|
version = 1
|
||||||
|
}
|
||||||
|
return &Store{
|
||||||
|
path: path,
|
||||||
|
version: version,
|
||||||
|
promotions: sf.State.Promotions,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Snapshot returns a copy of the current rules, safe to evaluate without holding
|
||||||
|
// the lock. The PromotionRule values are passed by value to EvaluateAll, so a
|
||||||
|
// shallow copy of the slice is enough to avoid racing with concurrent mutations.
|
||||||
|
func (s *Store) Snapshot() []PromotionRule {
|
||||||
|
s.mu.RLock()
|
||||||
|
defer s.mu.RUnlock()
|
||||||
|
out := make([]PromotionRule, len(s.promotions))
|
||||||
|
copy(out, s.promotions)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// List returns a copy of all rules (for the list_promotions tool).
|
||||||
|
func (s *Store) List() []PromotionRule {
|
||||||
|
return s.Snapshot()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get returns the rule with the given id.
|
||||||
|
func (s *Store) Get(id string) (PromotionRule, bool) {
|
||||||
|
s.mu.RLock()
|
||||||
|
defer s.mu.RUnlock()
|
||||||
|
for _, r := range s.promotions {
|
||||||
|
if r.ID == id {
|
||||||
|
return r, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return PromotionRule{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upsert inserts the rule or replaces the existing one with the same ID, then
|
||||||
|
// persists. Returns true if an existing rule was replaced.
|
||||||
|
func (s *Store) Upsert(rule PromotionRule) (replaced bool, err error) {
|
||||||
|
if rule.ID == "" {
|
||||||
|
return false, fmt.Errorf("promotion id is required")
|
||||||
|
}
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
for i := range s.promotions {
|
||||||
|
if s.promotions[i].ID == rule.ID {
|
||||||
|
s.promotions[i] = rule
|
||||||
|
return true, s.saveLocked()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.promotions = append(s.promotions, rule)
|
||||||
|
return false, s.saveLocked()
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetStatus changes the status of a rule and persists. Returns false if no rule
|
||||||
|
// with the id exists.
|
||||||
|
func (s *Store) SetStatus(id string, status PromotionStatus) (bool, error) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
for i := range s.promotions {
|
||||||
|
if s.promotions[i].ID == id {
|
||||||
|
s.promotions[i].Status = status
|
||||||
|
return true, s.saveLocked()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete removes the rule with the id and persists. Returns false if not found.
|
||||||
|
func (s *Store) Delete(id string) (bool, error) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
for i := range s.promotions {
|
||||||
|
if s.promotions[i].ID == id {
|
||||||
|
s.promotions = append(s.promotions[:i], s.promotions[i+1:]...)
|
||||||
|
return true, s.saveLocked()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// saveLocked writes the current state to disk atomically. Caller holds s.mu.
|
||||||
|
func (s *Store) saveLocked() error {
|
||||||
|
sf := StateFile{
|
||||||
|
Version: s.version,
|
||||||
|
State: PromotionState{Promotions: s.promotions},
|
||||||
|
}
|
||||||
|
data, err := json.MarshalIndent(sf, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
dir := filepath.Dir(s.path)
|
||||||
|
tmp, err := os.CreateTemp(dir, ".promotions-*.json")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
tmpName := tmp.Name()
|
||||||
|
defer os.Remove(tmpName) // no-op once renamed
|
||||||
|
if _, err := tmp.Write(data); err != nil {
|
||||||
|
tmp.Close()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := tmp.Close(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return os.Rename(tmpName, s.path)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user