all the refactor
Build and Publish / BuildAndDeployAmd64 (push) Failing after 5s
Build and Publish / BuildAndDeployArm64 (push) Failing after 6s

This commit is contained in:
2026-06-28 17:51:52 +02:00
parent 7db0d236c7
commit aa8b2bdedc
84 changed files with 4328 additions and 2344 deletions
+46 -117
View File
@@ -1,61 +1,32 @@
package mcp
import (
"context"
"encoding/json"
"fmt"
"time"
"git.k6n.net/mats/go-cart-actor/pkg/cart"
"git.k6n.net/mats/go-cart-actor/pkg/promotions"
pmcp "git.k6n.net/mats/platform/mcp"
"git.k6n.net/mats/platform/tax"
)
// 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{
func (s *Server) buildTools() []pmcp.Tool {
return []pmcp.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"),
InputSchema: pmcp.Object(pmcp.Props{
"status": pmcp.String("optional status filter: active|inactive|scheduled|expired"),
}, nil),
invoke: func(args json.RawMessage) (any, error) {
Invoke: func(_ context.Context, args json.RawMessage) (any, error) {
var a struct {
Status string `json:"status"`
}
if err := decode(args, &a); err != nil {
if err := pmcp.Decode(args, &a); err != nil {
return nil, err
}
rules := s.store.List()
@@ -74,12 +45,12 @@ func (s *Server) buildTools() []tool {
{
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) {
InputSchema: pmcp.Object(pmcp.Props{"id": pmcp.String("the promotion id")}, []string{"id"}),
Invoke: func(_ context.Context, args json.RawMessage) (any, error) {
var a struct {
ID string `json:"id"`
}
if err := decode(args, &a); err != nil {
if err := pmcp.Decode(args, &a); err != nil {
return nil, err
}
r, ok := s.store.Get(a.ID)
@@ -95,14 +66,14 @@ func (s *Server) buildTools() []tool {
"`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"),
InputSchema: pmcp.Object(pmcp.Props{
"promotion": pmcp.ObjectValue("the full promotion rule object"),
}, []string{"promotion"}),
invoke: func(args json.RawMessage) (any, error) {
Invoke: func(_ context.Context, args json.RawMessage) (any, error) {
var a struct {
Promotion json.RawMessage `json:"promotion"`
}
if err := decode(args, &a); err != nil {
if err := pmcp.Decode(args, &a); err != nil {
return nil, err
}
var rule promotions.PromotionRule
@@ -125,16 +96,16 @@ func (s *Server) buildTools() []tool {
{
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"),
InputSchema: pmcp.Object(pmcp.Props{
"id": pmcp.String("the promotion id"),
"status": pmcp.String("new status: active|inactive|scheduled|expired"),
}, []string{"id", "status"}),
invoke: func(args json.RawMessage) (any, error) {
Invoke: func(_ context.Context, args json.RawMessage) (any, error) {
var a struct {
ID string `json:"id"`
Status string `json:"status"`
}
if err := decode(args, &a); err != nil {
if err := pmcp.Decode(args, &a); err != nil {
return nil, err
}
if !validStatus(a.Status) {
@@ -153,12 +124,12 @@ func (s *Server) buildTools() []tool {
{
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) {
InputSchema: pmcp.Object(pmcp.Props{"id": pmcp.String("the promotion id")}, []string{"id"}),
Invoke: func(_ context.Context, args json.RawMessage) (any, error) {
var a struct {
ID string `json:"id"`
}
if err := decode(args, &a); err != nil {
if err := pmcp.Decode(args, &a); err != nil {
return nil, err
}
found, err := s.store.Delete(a.ID)
@@ -176,18 +147,18 @@ func (s *Server) buildTools() []tool {
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"),
InputSchema: pmcp.Object(pmcp.Props{
"cartTotalIncVat": pmcp.Integer("cart total incl. VAT, in öre"),
"itemQuantity": pmcp.Integer("optional total item quantity"),
"customerSegment": pmcp.String("optional customer segment"),
}, []string{"cartTotalIncVat"}),
invoke: func(args json.RawMessage) (any, error) {
Invoke: func(_ context.Context, 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 {
if err := pmcp.Decode(args, &a); err != nil {
return nil, err
}
g := syntheticCart(a.CartTotalIncVat, a.ItemQuantity, s.defaultVatRate())
@@ -210,19 +181,19 @@ func (s *Server) buildTools() []tool {
}
}
// syntheticCart builds a one-line cart whose gross total is incVat ore so the
// promotion engine can be evaluated against an arbitrary total. defaultVatRate
// is the VAT rate as a float32 percentage (e.g. 25 = 25 %).
func syntheticCart(incVat int64, qty int, defaultVatRate float32) *cart.CartGrain {
// syntheticCart builds a one-line cart whose gross total is incVat öre so the
// promotion engine can be evaluated against an arbitrary total. rateBp is the VAT
// rate in basis points (2500 = 25%).
func syntheticCart(incVat int64, qty int, rateBp int) *cart.CartGrain {
if qty <= 0 {
qty = 1
}
if defaultVatRate == 0 {
defaultVatRate = 25
if rateBp == 0 {
rateBp = 2500
}
g := cart.NewCartGrain(0, time.Now())
g.Items = []*cart.CartItem{
{Id: 1, Sku: "PREVIEW", Quantity: uint16(qty), Price: *cart.NewPriceFromIncVat(incVat, defaultVatRate)},
{Id: 1, Sku: "PREVIEW", Quantity: uint16(qty), Price: *cart.NewPriceFromIncVat(incVat, rateBp)},
}
// Quantity > 1 would multiply the row total; keep the gross equal to incVat by
// pricing a single unit at the full total.
@@ -240,55 +211,13 @@ func validStatus(s string) bool {
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
// defaultVatRate is the VAT rate used to build the synthetic preview cart when
// the caller supplies none. Sourced from platform/tax (Nordic standard,
// defaultCountry SE = 25%) rather than a magic constant.
//
// NOTE: reconstructed during the platform/mcp migration (the original method was
// lost editing this untracked file). Behaviour matches the prior 25% default; if
// the original read a per-store/configured rate, wire that provider in here.
func (s *Server) defaultVatRate() int {
return tax.NewNordic("").DefaultTaxRate("")
}