From 492f54ff45d0c738ad13569c07f6b9ec532b8e0b Mon Sep 17 00:00:00 2001 From: matst80 Date: Sat, 27 Jun 2026 09:14:59 +0200 Subject: [PATCH] mcp --- cmd/cart/main.go | 10 -- cmd/order/main.go | 8 ++ pkg/promotions/mcp/admin.go | 111 ++++++++++++++++++++++ pkg/promotions/mcp/public.go | 173 +++++++++++++++++++++++++++++++++++ 4 files changed, 292 insertions(+), 10 deletions(-) create mode 100644 pkg/promotions/mcp/admin.go create mode 100644 pkg/promotions/mcp/public.go diff --git a/cmd/cart/main.go b/cmd/cart/main.go index 5543c23..c5d1d36 100644 --- a/cmd/cart/main.go +++ b/cmd/cart/main.go @@ -16,7 +16,6 @@ import ( "git.k6n.net/mats/go-cart-actor/pkg/cart" cartmcp "git.k6n.net/mats/go-cart-actor/pkg/cart/mcp" "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/internal/ucp" "git.k6n.net/mats/go-cart-actor/pkg/proxy" "git.k6n.net/mats/go-cart-actor/pkg/voucher" @@ -142,7 +141,6 @@ func main() { log.Printf("loaded %d promotions", len(promotionStore.List())) promotionService := promotions.NewPromotionService(nil) - promotionMCP := promotionmcp.New(promotionStore, promotionService) //inventoryPubSub := actor.NewPubSub[inventory.InventoryChange]() @@ -296,14 +294,6 @@ func main() { 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). - // Mounted at /promotions-mcp to avoid conflicting with the backoffice CMS MCP - // at /mcp (which is routed by the edge). - mux.Handle("/promotions-mcp", promotionMCP.Handler()) - mux.Handle("/promotions-mcp/", promotionMCP.Handler()) - // Cart MCP edge: inspect and mutate carts — get_cart, get_cart_items, // update_item_quantity, remove_cart_item, clear_cart, apply_voucher, etc. // Exposes 11 tools on the same grain pool, mounted at /cart-mcp so it does diff --git a/cmd/order/main.go b/cmd/order/main.go index a88c733..6f2bd2e 100644 --- a/cmd/order/main.go +++ b/cmd/order/main.go @@ -24,6 +24,7 @@ import ( "git.k6n.net/mats/go-cart-actor/pkg/flow" "git.k6n.net/mats/go-cart-actor/pkg/idempotency" "git.k6n.net/mats/go-cart-actor/pkg/order" + ordermcp "git.k6n.net/mats/go-cart-actor/pkg/order/mcp" "git.k6n.net/mats/go-cart-actor/pkg/outbox" messages "git.k6n.net/mats/go-cart-actor/proto/order" @@ -169,6 +170,13 @@ func main() { // for why the exact-match pattern is omitted). mux.Handle("/ucp/v1/orders/", http.StripPrefix("/ucp/v1/orders", orderUCP)) + // Order MCP streamable-HTTP server (inspect + mutate orders via tools). + // Mounted at /order-mcp to avoid conflicting with the backoffice CMS MCP at + // /mcp (which is routed by the edge). + orderMCP := ordermcp.New(applier) + mux.Handle("/order-mcp", orderMCP.Handler()) + mux.Handle("/order-mcp/", orderMCP.Handler()) + // Saga / flow admin (backoffice editor). mux.HandleFunc("GET /sagas/capabilities", s.handleCapabilities) mux.HandleFunc("GET /sagas/flows", s.handleListFlows) diff --git a/pkg/promotions/mcp/admin.go b/pkg/promotions/mcp/admin.go new file mode 100644 index 0000000..5b4d5d2 --- /dev/null +++ b/pkg/promotions/mcp/admin.go @@ -0,0 +1,111 @@ +package mcp + +import ( + "encoding/json" + "fmt" + + "git.k6n.net/mats/go-cart-actor/pkg/promotions" +) + +// AdminTool is one admin promotion tool definition — name, description, input +// schema, and the invoke function. It mirrors cmsmount.MCPTool but lives in +// this package so the promotions module doesn't import the CMS module. +type AdminTool struct { + Name string + Description string + InputSchema json.RawMessage + Invoke func(args json.RawMessage) (any, error) +} + +// AdminTools returns the admin-only promotion MCP tool definitions (upsert, +// set_status, delete). Pass these to cmsmount.AddMCPTools to merge them into +// the CMS MCP behind full auth. +func AdminTools(store *promotions.Store, eval *promotions.PromotionService) []AdminTool { + if eval == nil { + eval = promotions.NewPromotionService(nil) + } + return []AdminTool{ + { + 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 := 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 := 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 := 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 + }, + }, + } +} diff --git a/pkg/promotions/mcp/public.go b/pkg/promotions/mcp/public.go new file mode 100644 index 0000000..dc25e82 --- /dev/null +++ b/pkg/promotions/mcp/public.go @@ -0,0 +1,173 @@ +package mcp + +import ( + "encoding/json" + "fmt" + "time" + + "git.k6n.net/mats/go-cart-actor/pkg/promotions" +) + +// 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.tools = s.buildPublicTools() + return s +} + +func (s *Server) buildPublicTools() []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: "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 + }, + }, + { + 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: 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"), + }, []string{"cartTotalIncVat"}), + invoke: func(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 { + 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) + 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 + }, + }, + } +}