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 }, }, } }