291 lines
8.8 KiB
Go
291 lines
8.8 KiB
Go
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
|
|
}
|