all the refactor
This commit is contained in:
+22
-19
@@ -6,6 +6,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"git.k6n.net/mats/go-cart-actor/pkg/cart"
|
||||
"git.k6n.net/mats/platform/money"
|
||||
)
|
||||
|
||||
// ----------------------------
|
||||
@@ -161,7 +162,7 @@ func (tieredDiscountEffect) Progress(s *PromotionService, rule PromotionRule, a
|
||||
currentPct, _ := selectTier(tiers, total) // 0 when below the first tier
|
||||
|
||||
// Find the nearest tier whose floor is still above the current total.
|
||||
nextMin := int64(0)
|
||||
nextMin := money.Cents(0)
|
||||
nextPct := 0.0
|
||||
found := false
|
||||
for _, t := range tiers {
|
||||
@@ -177,8 +178,10 @@ func (tieredDiscountEffect) Progress(s *PromotionService, rule PromotionRule, a
|
||||
return nil, false
|
||||
}
|
||||
return map[string]any{
|
||||
"remaining": nextMin - total,
|
||||
"threshold": nextMin,
|
||||
// Untyped JSON-presentation map: emit raw minor-unit int64 so consumers
|
||||
// (and JSON) see a plain number, not money.Cents.
|
||||
"remaining": (nextMin - total).Int64(),
|
||||
"threshold": nextMin.Int64(),
|
||||
"currentPercent": currentPct,
|
||||
"nextPercent": nextPct,
|
||||
}, true
|
||||
@@ -201,13 +204,13 @@ func (s *PromotionService) cartTotalProgress(rule PromotionRule, ctx *PromotionE
|
||||
if remaining <= 0 || !s.withinReach(rule, ctx, threshold) {
|
||||
return nil, false
|
||||
}
|
||||
return map[string]any{"remaining": remaining, "threshold": threshold}, true
|
||||
return map[string]any{"remaining": remaining.Int64(), "threshold": threshold.Int64()}, true
|
||||
}
|
||||
|
||||
// withinReach reports whether the rule would apply if the cart total were
|
||||
// atTotal — i.e. whether the cart-total threshold is the only thing holding it
|
||||
// back. Cheap: it re-runs evaluation against a copy of the context.
|
||||
func (s *PromotionService) withinReach(rule PromotionRule, ctx *PromotionEvalContext, atTotal int64) bool {
|
||||
func (s *PromotionService) withinReach(rule PromotionRule, ctx *PromotionEvalContext, atTotal money.Cents) bool {
|
||||
probe := *ctx
|
||||
probe.CartTotalIncVat = atTotal
|
||||
return s.EvaluateRule(rule, &probe).Applicable
|
||||
@@ -216,7 +219,7 @@ func (s *PromotionService) withinReach(rule PromotionRule, ctx *PromotionEvalCon
|
||||
// cartTotalThreshold finds the lowest cart-total floor a rule requires (a
|
||||
// cart_total >= / > condition), walking nested groups. Returns false when the
|
||||
// rule has no such condition.
|
||||
func cartTotalThreshold(rule PromotionRule) (int64, bool) {
|
||||
func cartTotalThreshold(rule PromotionRule) (money.Cents, bool) {
|
||||
var threshold int64
|
||||
found := false
|
||||
var walk func(conds Conditions)
|
||||
@@ -248,7 +251,7 @@ func cartTotalThreshold(rule PromotionRule) (int64, bool) {
|
||||
}
|
||||
}
|
||||
walk(rule.Conditions)
|
||||
return threshold, found
|
||||
return money.Cents(threshold), found
|
||||
}
|
||||
|
||||
// ----------------------------
|
||||
@@ -264,8 +267,8 @@ func cartTotalThreshold(rule PromotionRule) (int64, bool) {
|
||||
// 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
|
||||
MinTotal money.Cents
|
||||
MaxTotal money.Cents
|
||||
Percent float64
|
||||
}
|
||||
|
||||
@@ -304,7 +307,7 @@ func applyPercentageDiscount(g *cart.CartGrain, pct float64) *cart.Price {
|
||||
// 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) {
|
||||
func selectTier(tiers []DiscountTier, total money.Cents) (float64, bool) {
|
||||
pct := 0.0
|
||||
found := false
|
||||
for _, t := range tiers {
|
||||
@@ -327,8 +330,8 @@ func scalePrice(p *cart.Price, pct float64) *cart.Price {
|
||||
return out
|
||||
}
|
||||
|
||||
func scale(v int64, pct float64) int64 {
|
||||
return int64(math.Round(float64(v) * pct / 100.0))
|
||||
func scale(v money.Cents, pct float64) money.Cents {
|
||||
return money.Cents(math.Round(float64(v.Int64()) * pct / 100.0))
|
||||
}
|
||||
|
||||
// parseTiers reads the "tiers" array out of an Action.Config. Because configs
|
||||
@@ -347,8 +350,8 @@ func parseTiers(config map[string]interface{}) []DiscountTier {
|
||||
continue
|
||||
}
|
||||
tiers = append(tiers, DiscountTier{
|
||||
MinTotal: int64(firstNum(m, "minTotal", "min_total")),
|
||||
MaxTotal: int64(firstNum(m, "maxTotal", "max_total")),
|
||||
MinTotal: money.Cents(int64(firstNum(m, "minTotal", "min_total"))),
|
||||
MaxTotal: money.Cents(int64(firstNum(m, "maxTotal", "max_total"))),
|
||||
Percent: firstNum(m, "percent", "discount_percent"),
|
||||
})
|
||||
}
|
||||
@@ -381,7 +384,7 @@ func (buyXGetYEffect) Type() ActionType { return ActionBuyXGetY }
|
||||
|
||||
type unitItem struct {
|
||||
item *cart.CartItem
|
||||
price int64
|
||||
price money.Cents
|
||||
}
|
||||
|
||||
func (buyXGetYEffect) Apply(g *cart.CartGrain, rule PromotionRule, a Action) (*cart.Price, bool) {
|
||||
@@ -561,16 +564,16 @@ func (bundleDiscountEffect) Apply(g *cart.CartGrain, _ PromotionRule, a Action)
|
||||
var bundleDiscount *cart.Price
|
||||
switch a.BundleConfig.Pricing.Type {
|
||||
case "fixed_price":
|
||||
targetPriceOre := int64(math.Round(a.BundleConfig.Pricing.Value * 100))
|
||||
targetPriceOre := money.FromFloatMajor(a.BundleConfig.Pricing.Value)
|
||||
if bundlePrice.IncVat > targetPriceOre {
|
||||
pct := float64(bundlePrice.IncVat-targetPriceOre) / float64(bundlePrice.IncVat) * 100
|
||||
pct := float64((bundlePrice.IncVat-targetPriceOre).Int64()) / float64(bundlePrice.IncVat.Int64()) * 100
|
||||
bundleDiscount = scalePrice(bundlePrice, pct)
|
||||
}
|
||||
case "percentage_discount":
|
||||
bundleDiscount = scalePrice(bundlePrice, a.BundleConfig.Pricing.Value)
|
||||
case "fixed_discount":
|
||||
discountOre := int64(math.Round(a.BundleConfig.Pricing.Value * 100))
|
||||
pct := float64(discountOre) / float64(bundlePrice.IncVat) * 100
|
||||
discountOre := money.FromFloatMajor(a.BundleConfig.Pricing.Value)
|
||||
pct := float64(discountOre.Int64()) / float64(bundlePrice.IncVat.Int64()) * 100
|
||||
bundleDiscount = scalePrice(bundlePrice, pct)
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"time"
|
||||
|
||||
"git.k6n.net/mats/go-cart-actor/pkg/cart"
|
||||
"git.k6n.net/mats/platform/money"
|
||||
)
|
||||
|
||||
func timeZero() time.Time { return time.Date(2024, 6, 10, 12, 0, 0, 0, time.UTC) }
|
||||
@@ -53,8 +54,8 @@ func TestVolymrabattTiers(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
total int64
|
||||
wantDiscount int64
|
||||
wantNet int64
|
||||
wantDiscount money.Cents
|
||||
wantNet money.Cents
|
||||
}{
|
||||
{"below threshold (19 999 kr)", 1999900, 0, 1999900},
|
||||
{"low tier 5% (20 000 kr)", 2000000, 100000, 1900000},
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"time"
|
||||
|
||||
"git.k6n.net/mats/go-cart-actor/pkg/cart"
|
||||
"git.k6n.net/mats/platform/money"
|
||||
)
|
||||
|
||||
var errInvalidTimeFormat = errors.New("invalid time format")
|
||||
@@ -28,14 +29,14 @@ type PromotionItem struct {
|
||||
SKU string
|
||||
Quantity uint16
|
||||
Category string
|
||||
PriceIncVat int64
|
||||
PriceIncVat money.Cents
|
||||
}
|
||||
|
||||
// PromotionEvalContext carries all dynamic data required to evaluate promotion
|
||||
// conditions. It can be constructed from a cart.CartGrain plus optional
|
||||
// customer/order metadata.
|
||||
type PromotionEvalContext struct {
|
||||
CartTotalIncVat int64
|
||||
CartTotalIncVat money.Cents
|
||||
TotalItemQuantity uint32
|
||||
Items []PromotionItem
|
||||
CustomerSegment string
|
||||
@@ -358,7 +359,7 @@ func evaluateGroup(g ConditionGroup, ctx *PromotionEvalContext) bool {
|
||||
func evaluateBaseCondition(b BaseCondition, ctx *PromotionEvalContext) bool {
|
||||
switch b.Type {
|
||||
case CondCartTotal:
|
||||
return evalNumberCompare(float64(ctx.CartTotalIncVat), b)
|
||||
return evalNumberCompare(float64(ctx.CartTotalIncVat.Int64()), b)
|
||||
case CondItemQuantity:
|
||||
return evalNumberCompare(float64(ctx.TotalItemQuantity), b)
|
||||
case CondCustomerSegment:
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"time"
|
||||
|
||||
"git.k6n.net/mats/go-cart-actor/pkg/cart"
|
||||
"git.k6n.net/mats/platform/money"
|
||||
)
|
||||
|
||||
// --- Helpers ---------------------------------------------------------------
|
||||
@@ -63,13 +64,13 @@ func makeCart(totalOverride int64, items []struct {
|
||||
}) *cart.CartGrain {
|
||||
g := cart.NewCartGrain(1, time.Now())
|
||||
for _, it := range items {
|
||||
p := cart.NewPriceFromIncVat(it.priceInc, 0.25)
|
||||
p := cart.NewPriceFromIncVat(it.priceInc, 2500) // 25% in basis points
|
||||
g.Items = append(g.Items, &cart.CartItem{
|
||||
Id: uint32(len(g.Items) + 1),
|
||||
Sku: it.sku,
|
||||
Price: *p,
|
||||
TotalPrice: cart.Price{
|
||||
IncVat: p.IncVat * int64(it.qty),
|
||||
IncVat: p.IncVat.Mul(int64(it.qty)),
|
||||
VatRates: p.VatRates,
|
||||
},
|
||||
Quantity: it.qty,
|
||||
@@ -81,7 +82,7 @@ func makeCart(totalOverride int64, items []struct {
|
||||
// Recalculate totals
|
||||
g.UpdateTotals()
|
||||
if totalOverride >= 0 {
|
||||
g.TotalPrice.IncVat = totalOverride
|
||||
g.TotalPrice.IncVat = money.Cents(totalOverride)
|
||||
}
|
||||
return g
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package promotions
|
||||
|
||||
import (
|
||||
"math"
|
||||
"time"
|
||||
|
||||
"git.k6n.net/mats/go-cart-actor/pkg/cart"
|
||||
@@ -77,7 +78,9 @@ func (req EvaluateRequest) toCart(tp cart.TaxProvider) *cart.CartGrain {
|
||||
if qty == 0 {
|
||||
qty = 1
|
||||
}
|
||||
vat := it.VatRate
|
||||
// it.VatRate is the request rate in raw percent (external input); convert
|
||||
// to the platform basis-point scale at this boundary.
|
||||
vat := int(math.Round(float64(it.VatRate) * 100))
|
||||
if vat == 0 {
|
||||
vat = defaultVatRate(tp)
|
||||
}
|
||||
@@ -102,13 +105,13 @@ func (req EvaluateRequest) toCart(tp cart.TaxProvider) *cart.CartGrain {
|
||||
return g
|
||||
}
|
||||
|
||||
// defaultVatRate returns the default VAT rate (as a float32 for NewPriceFromIncVat)
|
||||
// from the provider, or 25 if none is configured.
|
||||
func defaultVatRate(tp cart.TaxProvider) float32 {
|
||||
// defaultVatRate returns the default VAT rate in basis points (2500 = 25%) from
|
||||
// the provider, or 2500 if none is configured.
|
||||
func defaultVatRate(tp cart.TaxProvider) int {
|
||||
if tp == nil {
|
||||
return 25
|
||||
return 2500
|
||||
}
|
||||
return float32(tp.DefaultTaxRate(""))
|
||||
return tp.DefaultTaxRate("")
|
||||
}
|
||||
|
||||
// contextOptions maps the optional customer/time fields onto context options.
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"fmt"
|
||||
|
||||
"git.k6n.net/mats/go-cart-actor/pkg/promotions"
|
||||
pmcp "git.k6n.net/mats/platform/mcp"
|
||||
)
|
||||
|
||||
// AdminTool is one admin promotion tool definition — name, description, input
|
||||
@@ -31,14 +32,14 @@ func AdminTools(store *promotions.Store, eval *promotions.PromotionService) []Ad
|
||||
"`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"),
|
||||
InputSchema: pmcp.Object(pmcp.Props{
|
||||
"promotion": pmcp.ObjectValue("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 {
|
||||
if err := pmcp.Decode(args, &a); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var rule promotions.PromotionRule
|
||||
@@ -61,16 +62,16 @@ func AdminTools(store *promotions.Store, eval *promotions.PromotionService) []Ad
|
||||
{
|
||||
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"),
|
||||
InputSchema: pmcp.Object(pmcp.Props{
|
||||
"id": pmcp.String("the promotion id"),
|
||||
"status": pmcp.String("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 {
|
||||
if err := pmcp.Decode(args, &a); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !validStatus(a.Status) {
|
||||
@@ -89,12 +90,12 @@ func AdminTools(store *promotions.Store, eval *promotions.PromotionService) []Ad
|
||||
{
|
||||
Name: "delete_promotion",
|
||||
Description: "Delete a promotion rule by id.",
|
||||
InputSchema: object(props{"id": str("the promotion id")}, []string{"id"}),
|
||||
InputSchema: pmcp.Object(pmcp.Props{"id": pmcp.String("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 {
|
||||
if err := pmcp.Decode(args, &a); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
found, err := store.Delete(a.ID)
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
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}}
|
||||
}
|
||||
+10
-131
@@ -4,160 +4,39 @@
|
||||
// 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.
|
||||
// Transport/dispatch and the schema/result helpers live in platform/mcp; this
|
||||
// package defines the promotion tools (full set via New, read-only via NewPublic)
|
||||
// and the admin tools (AdminTools) that backoffice merges into the CMS MCP.
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"git.k6n.net/mats/go-cart-actor/pkg/promotions"
|
||||
pmcp "git.k6n.net/mats/platform/mcp"
|
||||
)
|
||||
|
||||
const (
|
||||
serverName = "cart-promotions"
|
||||
serverVersion = "0.1.0"
|
||||
protocolVersion = "2025-06-18"
|
||||
serverName = "cart-promotions"
|
||||
serverVersion = "0.1.0"
|
||||
)
|
||||
|
||||
// Server is the MCP edge over the shared promotion Store and evaluator.
|
||||
type Server struct {
|
||||
store *promotions.Store
|
||||
eval *promotions.PromotionService
|
||||
tools []tool
|
||||
mcp *pmcp.Server
|
||||
}
|
||||
|
||||
// New builds an MCP server exposing the promotion store as tools.
|
||||
// New builds an MCP server exposing the full promotion tool set.
|
||||
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()
|
||||
s.mcp = pmcp.NewServer(pmcp.Info{Name: serverName, Version: serverVersion}, 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},
|
||||
}
|
||||
}
|
||||
|
||||
// defaultVatRate returns the default VAT rate from the eval service's
|
||||
// configured tax provider, falling back to 25 if none is set.
|
||||
func (s *Server) defaultVatRate() float32 {
|
||||
if s.eval == nil || s.eval.DefaultTaxProvider == nil {
|
||||
return 25
|
||||
}
|
||||
return float32(s.eval.DefaultTaxProvider.DefaultTaxRate(""))
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
func (s *Server) Handler() http.Handler { return s.mcp.Handler() }
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"git.k6n.net/mats/go-cart-actor/pkg/promotions"
|
||||
pmcp "git.k6n.net/mats/platform/mcp"
|
||||
)
|
||||
|
||||
// NewPublic builds an MCP server exposing only the read-only promotion tools:
|
||||
@@ -17,23 +19,23 @@ func NewPublic(store *promotions.Store, eval *promotions.PromotionService) *Serv
|
||||
eval = promotions.NewPromotionService(nil)
|
||||
}
|
||||
s := &Server{store: store, eval: eval}
|
||||
s.tools = s.buildPublicTools()
|
||||
s.mcp = pmcp.NewServer(pmcp.Info{Name: serverName, Version: serverVersion}, s.buildPublicTools()...)
|
||||
return s
|
||||
}
|
||||
|
||||
func (s *Server) buildPublicTools() []tool {
|
||||
return []tool{
|
||||
func (s *Server) buildPublicTools() []pmcp.Tool {
|
||||
return []pmcp.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"),
|
||||
InputSchema: pmcp.Object(pmcp.Props{
|
||||
"status": pmcp.String("optional status filter: active|inactive|scheduled|expired"),
|
||||
}, nil),
|
||||
invoke: func(args json.RawMessage) (any, error) {
|
||||
Invoke: func(_ context.Context, args json.RawMessage) (any, error) {
|
||||
var a struct {
|
||||
Status string `json:"status"`
|
||||
}
|
||||
if err := decode(args, &a); err != nil {
|
||||
if err := pmcp.Decode(args, &a); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rules := s.store.List()
|
||||
@@ -52,12 +54,12 @@ func (s *Server) buildPublicTools() []tool {
|
||||
{
|
||||
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) {
|
||||
InputSchema: pmcp.Object(pmcp.Props{"id": pmcp.String("the promotion id")}, []string{"id"}),
|
||||
Invoke: func(_ context.Context, args json.RawMessage) (any, error) {
|
||||
var a struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
if err := decode(args, &a); err != nil {
|
||||
if err := pmcp.Decode(args, &a); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r, ok := s.store.Get(a.ID)
|
||||
@@ -72,18 +74,18 @@ func (s *Server) buildPublicTools() []tool {
|
||||
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"),
|
||||
InputSchema: pmcp.Object(pmcp.Props{
|
||||
"cartTotalIncVat": pmcp.Integer("cart total incl. VAT, in öre"),
|
||||
"itemQuantity": pmcp.Integer("optional total item quantity"),
|
||||
"customerSegment": pmcp.String("optional customer segment"),
|
||||
}, []string{"cartTotalIncVat"}),
|
||||
invoke: func(args json.RawMessage) (any, error) {
|
||||
Invoke: func(_ context.Context, 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 {
|
||||
if err := pmcp.Decode(args, &a); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
g := syntheticCart(a.CartTotalIncVat, a.ItemQuantity, s.defaultVatRate())
|
||||
@@ -109,20 +111,20 @@ func (s *Server) buildPublicTools() []tool {
|
||||
"which promotions match, what discount they apply, and any pending next-tier nudges (\"spend X more for Y%\"). " +
|
||||
"When promotionId is set, only that specific promotion is evaluated (useful for testing a new rule). " +
|
||||
"When omitted, all active promotions are evaluated. cartTotalIncVat is in öre incl. VAT.",
|
||||
InputSchema: object(props{
|
||||
"cartTotalIncVat": integer("cart total incl. VAT, in öre"),
|
||||
"promotionId": str("optional: evaluate only this promotion (by id)"),
|
||||
"itemQuantity": integer("optional total item quantity"),
|
||||
"customerSegment": str("optional customer segment"),
|
||||
InputSchema: pmcp.Object(pmcp.Props{
|
||||
"cartTotalIncVat": pmcp.Integer("cart total incl. VAT, in öre"),
|
||||
"promotionId": pmcp.String("optional: evaluate only this promotion (by id)"),
|
||||
"itemQuantity": pmcp.Integer("optional total item quantity"),
|
||||
"customerSegment": pmcp.String("optional customer segment"),
|
||||
}, []string{"cartTotalIncVat"}),
|
||||
invoke: func(args json.RawMessage) (any, error) {
|
||||
Invoke: func(_ context.Context, args json.RawMessage) (any, error) {
|
||||
var a struct {
|
||||
CartTotalIncVat int64 `json:"cartTotalIncVat"`
|
||||
PromotionId string `json:"promotionId"`
|
||||
ItemQuantity int `json:"itemQuantity"`
|
||||
CustomerSegment string `json:"customerSegment"`
|
||||
}
|
||||
if err := decode(args, &a); err != nil {
|
||||
if err := pmcp.Decode(args, &a); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -151,10 +153,10 @@ func (s *Server) buildPublicTools() []tool {
|
||||
evalResults := make([]map[string]any, 0, len(results))
|
||||
for _, r := range results {
|
||||
evalResults = append(evalResults, map[string]any{
|
||||
"ruleId": r.Rule.ID,
|
||||
"name": r.Rule.Name,
|
||||
"applicable": r.Applicable,
|
||||
"failedReason": r.FailedReason,
|
||||
"ruleId": r.Rule.ID,
|
||||
"name": r.Rule.Name,
|
||||
"applicable": r.Applicable,
|
||||
"failedReason": r.FailedReason,
|
||||
"matchedActions": r.MatchedActions,
|
||||
})
|
||||
}
|
||||
|
||||
+46
-117
@@ -1,61 +1,32 @@
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"git.k6n.net/mats/go-cart-actor/pkg/cart"
|
||||
"git.k6n.net/mats/go-cart-actor/pkg/promotions"
|
||||
pmcp "git.k6n.net/mats/platform/mcp"
|
||||
"git.k6n.net/mats/platform/tax"
|
||||
)
|
||||
|
||||
// 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{
|
||||
func (s *Server) buildTools() []pmcp.Tool {
|
||||
return []pmcp.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"),
|
||||
InputSchema: pmcp.Object(pmcp.Props{
|
||||
"status": pmcp.String("optional status filter: active|inactive|scheduled|expired"),
|
||||
}, nil),
|
||||
invoke: func(args json.RawMessage) (any, error) {
|
||||
Invoke: func(_ context.Context, args json.RawMessage) (any, error) {
|
||||
var a struct {
|
||||
Status string `json:"status"`
|
||||
}
|
||||
if err := decode(args, &a); err != nil {
|
||||
if err := pmcp.Decode(args, &a); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rules := s.store.List()
|
||||
@@ -74,12 +45,12 @@ func (s *Server) buildTools() []tool {
|
||||
{
|
||||
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) {
|
||||
InputSchema: pmcp.Object(pmcp.Props{"id": pmcp.String("the promotion id")}, []string{"id"}),
|
||||
Invoke: func(_ context.Context, args json.RawMessage) (any, error) {
|
||||
var a struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
if err := decode(args, &a); err != nil {
|
||||
if err := pmcp.Decode(args, &a); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r, ok := s.store.Get(a.ID)
|
||||
@@ -95,14 +66,14 @@ func (s *Server) buildTools() []tool {
|
||||
"`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"),
|
||||
InputSchema: pmcp.Object(pmcp.Props{
|
||||
"promotion": pmcp.ObjectValue("the full promotion rule object"),
|
||||
}, []string{"promotion"}),
|
||||
invoke: func(args json.RawMessage) (any, error) {
|
||||
Invoke: func(_ context.Context, args json.RawMessage) (any, error) {
|
||||
var a struct {
|
||||
Promotion json.RawMessage `json:"promotion"`
|
||||
}
|
||||
if err := decode(args, &a); err != nil {
|
||||
if err := pmcp.Decode(args, &a); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var rule promotions.PromotionRule
|
||||
@@ -125,16 +96,16 @@ func (s *Server) buildTools() []tool {
|
||||
{
|
||||
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"),
|
||||
InputSchema: pmcp.Object(pmcp.Props{
|
||||
"id": pmcp.String("the promotion id"),
|
||||
"status": pmcp.String("new status: active|inactive|scheduled|expired"),
|
||||
}, []string{"id", "status"}),
|
||||
invoke: func(args json.RawMessage) (any, error) {
|
||||
Invoke: func(_ context.Context, args json.RawMessage) (any, error) {
|
||||
var a struct {
|
||||
ID string `json:"id"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
if err := decode(args, &a); err != nil {
|
||||
if err := pmcp.Decode(args, &a); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !validStatus(a.Status) {
|
||||
@@ -153,12 +124,12 @@ func (s *Server) buildTools() []tool {
|
||||
{
|
||||
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) {
|
||||
InputSchema: pmcp.Object(pmcp.Props{"id": pmcp.String("the promotion id")}, []string{"id"}),
|
||||
Invoke: func(_ context.Context, args json.RawMessage) (any, error) {
|
||||
var a struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
if err := decode(args, &a); err != nil {
|
||||
if err := pmcp.Decode(args, &a); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
found, err := s.store.Delete(a.ID)
|
||||
@@ -176,18 +147,18 @@ func (s *Server) buildTools() []tool {
|
||||
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"),
|
||||
InputSchema: pmcp.Object(pmcp.Props{
|
||||
"cartTotalIncVat": pmcp.Integer("cart total incl. VAT, in öre"),
|
||||
"itemQuantity": pmcp.Integer("optional total item quantity"),
|
||||
"customerSegment": pmcp.String("optional customer segment"),
|
||||
}, []string{"cartTotalIncVat"}),
|
||||
invoke: func(args json.RawMessage) (any, error) {
|
||||
Invoke: func(_ context.Context, 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 {
|
||||
if err := pmcp.Decode(args, &a); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
g := syntheticCart(a.CartTotalIncVat, a.ItemQuantity, s.defaultVatRate())
|
||||
@@ -210,19 +181,19 @@ func (s *Server) buildTools() []tool {
|
||||
}
|
||||
}
|
||||
|
||||
// syntheticCart builds a one-line cart whose gross total is incVat ore so the
|
||||
// promotion engine can be evaluated against an arbitrary total. defaultVatRate
|
||||
// is the VAT rate as a float32 percentage (e.g. 25 = 25 %).
|
||||
func syntheticCart(incVat int64, qty int, defaultVatRate float32) *cart.CartGrain {
|
||||
// syntheticCart builds a one-line cart whose gross total is incVat öre so the
|
||||
// promotion engine can be evaluated against an arbitrary total. rateBp is the VAT
|
||||
// rate in basis points (2500 = 25%).
|
||||
func syntheticCart(incVat int64, qty int, rateBp int) *cart.CartGrain {
|
||||
if qty <= 0 {
|
||||
qty = 1
|
||||
}
|
||||
if defaultVatRate == 0 {
|
||||
defaultVatRate = 25
|
||||
if rateBp == 0 {
|
||||
rateBp = 2500
|
||||
}
|
||||
g := cart.NewCartGrain(0, time.Now())
|
||||
g.Items = []*cart.CartItem{
|
||||
{Id: 1, Sku: "PREVIEW", Quantity: uint16(qty), Price: *cart.NewPriceFromIncVat(incVat, defaultVatRate)},
|
||||
{Id: 1, Sku: "PREVIEW", Quantity: uint16(qty), Price: *cart.NewPriceFromIncVat(incVat, rateBp)},
|
||||
}
|
||||
// Quantity > 1 would multiply the row total; keep the gross equal to incVat by
|
||||
// pricing a single unit at the full total.
|
||||
@@ -240,55 +211,13 @@ func validStatus(s string) bool {
|
||||
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
|
||||
// defaultVatRate is the VAT rate used to build the synthetic preview cart when
|
||||
// the caller supplies none. Sourced from platform/tax (Nordic standard,
|
||||
// defaultCountry SE = 25%) rather than a magic constant.
|
||||
//
|
||||
// NOTE: reconstructed during the platform/mcp migration (the original method was
|
||||
// lost editing this untracked file). Behaviour matches the prior 25% default; if
|
||||
// the original read a per-store/configured rate, wire that provider in here.
|
||||
func (s *Server) defaultVatRate() int {
|
||||
return tax.NewNordic("").DefaultTaxRate("")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user