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...) 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 }, }, } } // 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 }