176 lines
6.3 KiB
Go
176 lines
6.3 KiB
Go
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:
|
|
// list_promotions, get_promotion, preview_cart_discount, and evaluate_promotion.
|
|
// This is mounted on the public-facing endpoint (no auth needed since all tools
|
|
// are safe to expose).
|
|
func NewPublic(store *promotions.Store, eval *promotions.PromotionService) *Server {
|
|
if eval == nil {
|
|
eval = promotions.NewPromotionService(nil)
|
|
}
|
|
s := &Server{store: store, eval: eval}
|
|
s.mcp = pmcp.NewServer(pmcp.Info{Name: serverName, Version: serverVersion}, s.buildPublicTools()...)
|
|
return s
|
|
}
|
|
|
|
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: pmcp.Object(pmcp.Props{
|
|
"status": pmcp.String("optional status filter: active|inactive|scheduled|expired"),
|
|
}, nil),
|
|
Invoke: func(_ context.Context, args json.RawMessage) (any, error) {
|
|
var a struct {
|
|
Status string `json:"status"`
|
|
}
|
|
if err := pmcp.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: 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 := pmcp.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: "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: 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(_ context.Context, args json.RawMessage) (any, error) {
|
|
var a struct {
|
|
CartTotalIncVat int64 `json:"cartTotalIncVat"`
|
|
ItemQuantity int `json:"itemQuantity"`
|
|
CustomerSegment string `json:"customerSegment"`
|
|
}
|
|
if err := pmcp.Decode(args, &a); err != nil {
|
|
return nil, err
|
|
}
|
|
g := syntheticCart(a.CartTotalIncVat, a.ItemQuantity, s.defaultVatRate())
|
|
opts := []promotions.ContextOption{promotions.WithNow(time.Now())}
|
|
if a.CustomerSegment != "" {
|
|
opts = append(opts, promotions.WithCustomerSegment(a.CustomerSegment))
|
|
}
|
|
ctx := promotions.NewContextFromCart(g, opts...)
|
|
results, actions := s.eval.EvaluateAll(s.store.Snapshot(), ctx)
|
|
s.eval.ApplyResults(g, results, ctx)
|
|
return map[string]any{
|
|
"subtotalIncVat": a.CartTotalIncVat,
|
|
"discountIncVat": g.TotalDiscount.IncVat,
|
|
"netIncVat": g.TotalPrice.IncVat,
|
|
"matchedActions": actions,
|
|
"appliedPromotions": g.AppliedPromotions,
|
|
}, nil
|
|
},
|
|
},
|
|
{
|
|
Name: "evaluate_promotion",
|
|
Description: "Evaluate promotions against a hypothetical cart and return the full evaluation result: " +
|
|
"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: 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(_ 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 := pmcp.Decode(args, &a); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
rules := s.store.Snapshot()
|
|
if a.PromotionId != "" {
|
|
filtered := rules[:0:0]
|
|
for _, r := range rules {
|
|
if r.ID == a.PromotionId {
|
|
filtered = append(filtered, r)
|
|
break
|
|
}
|
|
}
|
|
if len(filtered) == 0 {
|
|
return nil, fmt.Errorf("promotion %q not found", a.PromotionId)
|
|
}
|
|
rules = filtered
|
|
}
|
|
|
|
g := syntheticCart(a.CartTotalIncVat, a.ItemQuantity, s.defaultVatRate())
|
|
opts := []promotions.ContextOption{promotions.WithNow(time.Now())}
|
|
if a.CustomerSegment != "" {
|
|
opts = append(opts, promotions.WithCustomerSegment(a.CustomerSegment))
|
|
}
|
|
ctx := promotions.NewContextFromCart(g, opts...)
|
|
results, actions := s.eval.EvaluateAll(rules, ctx)
|
|
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,
|
|
"matchedActions": r.MatchedActions,
|
|
})
|
|
}
|
|
s.eval.ApplyResults(g, results, ctx)
|
|
return map[string]any{
|
|
"subtotalIncVat": a.CartTotalIncVat,
|
|
"discountIncVat": g.TotalDiscount.IncVat,
|
|
"netIncVat": g.TotalPrice.IncVat,
|
|
"matchedActions": actions,
|
|
"appliedPromotions": g.AppliedPromotions,
|
|
"evaluationResults": evalResults,
|
|
}, nil
|
|
},
|
|
},
|
|
}
|
|
}
|