Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
492f54ff45 | ||
|
|
ae66ee162f |
+1
-9
@@ -16,7 +16,6 @@ import (
|
|||||||
"git.k6n.net/mats/go-cart-actor/pkg/cart"
|
"git.k6n.net/mats/go-cart-actor/pkg/cart"
|
||||||
cartmcp "git.k6n.net/mats/go-cart-actor/pkg/cart/mcp"
|
cartmcp "git.k6n.net/mats/go-cart-actor/pkg/cart/mcp"
|
||||||
"git.k6n.net/mats/go-cart-actor/pkg/promotions"
|
"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/internal/ucp"
|
||||||
"git.k6n.net/mats/go-cart-actor/pkg/proxy"
|
"git.k6n.net/mats/go-cart-actor/pkg/proxy"
|
||||||
"git.k6n.net/mats/go-cart-actor/pkg/voucher"
|
"git.k6n.net/mats/go-cart-actor/pkg/voucher"
|
||||||
@@ -142,7 +141,6 @@ func main() {
|
|||||||
log.Printf("loaded %d promotions", len(promotionStore.List()))
|
log.Printf("loaded %d promotions", len(promotionStore.List()))
|
||||||
|
|
||||||
promotionService := promotions.NewPromotionService(nil)
|
promotionService := promotions.NewPromotionService(nil)
|
||||||
promotionMCP := promotionmcp.New(promotionStore, promotionService)
|
|
||||||
|
|
||||||
//inventoryPubSub := actor.NewPubSub[inventory.InventoryChange]()
|
//inventoryPubSub := actor.NewPubSub[inventory.InventoryChange]()
|
||||||
|
|
||||||
@@ -296,16 +294,10 @@ func main() {
|
|||||||
|
|
||||||
syncedServer.Serve(mux)
|
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).
|
|
||||||
mux.Handle("/mcp", promotionMCP.Handler())
|
|
||||||
mux.Handle("/mcp/", promotionMCP.Handler())
|
|
||||||
|
|
||||||
// Cart MCP edge: inspect and mutate carts — get_cart, get_cart_items,
|
// Cart MCP edge: inspect and mutate carts — get_cart, get_cart_items,
|
||||||
// update_item_quantity, remove_cart_item, clear_cart, apply_voucher, etc.
|
// 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
|
// Exposes 11 tools on the same grain pool, mounted at /cart-mcp so it does
|
||||||
// not conflict with the Promotions MCP at /mcp.
|
// not conflict with the Promotions MCP at /promotions-mcp.
|
||||||
mux.Handle("/cart-mcp", cartMCP.Handler())
|
mux.Handle("/cart-mcp", cartMCP.Handler())
|
||||||
mux.Handle("/cart-mcp/", cartMCP.Handler())
|
mux.Handle("/cart-mcp/", cartMCP.Handler())
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import (
|
|||||||
"git.k6n.net/mats/go-cart-actor/pkg/flow"
|
"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/idempotency"
|
||||||
"git.k6n.net/mats/go-cart-actor/pkg/order"
|
"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"
|
"git.k6n.net/mats/go-cart-actor/pkg/outbox"
|
||||||
messages "git.k6n.net/mats/go-cart-actor/proto/order"
|
messages "git.k6n.net/mats/go-cart-actor/proto/order"
|
||||||
|
|
||||||
@@ -169,6 +170,13 @@ func main() {
|
|||||||
// for why the exact-match pattern is omitted).
|
// for why the exact-match pattern is omitted).
|
||||||
mux.Handle("/ucp/v1/orders/", http.StripPrefix("/ucp/v1/orders", orderUCP))
|
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).
|
// Saga / flow admin (backoffice editor).
|
||||||
mux.HandleFunc("GET /sagas/capabilities", s.handleCapabilities)
|
mux.HandleFunc("GET /sagas/capabilities", s.handleCapabilities)
|
||||||
mux.HandleFunc("GET /sagas/flows", s.handleListFlows)
|
mux.HandleFunc("GET /sagas/flows", s.handleListFlows)
|
||||||
|
|||||||
@@ -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
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user