mcp
Build and Publish / BuildAndDeployAmd64 (push) Failing after 5s
Build and Publish / BuildAndDeployArm64 (push) Failing after 7s

This commit is contained in:
2026-06-27 09:14:59 +02:00
parent ae66ee162f
commit 492f54ff45
4 changed files with 292 additions and 10 deletions
+111
View File
@@ -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
},
},
}
}
+173
View File
@@ -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
},
},
}
}