Files
go-cart-actor/pkg/order/mcp/tools.go
T
mats 9aa587b9cf
Build and Publish / BuildAndDeployArm64 (push) Failing after 6s
Build and Publish / BuildAndDeployAmd64 (push) Has been cancelled
mcp and ucp
2026-06-24 10:50:32 +02:00

520 lines
17 KiB
Go

package mcp
import (
"context"
"encoding/json"
"fmt"
"time"
"git.k6n.net/mats/go-cart-actor/pkg/order"
messages "git.k6n.net/mats/go-cart-actor/proto/order"
)
// 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: "get_order",
Description: "Get the full detail of an order by its base62 order id: status, line items, payments, fulfillments, returns, refunds, customer info, billing/shipping addresses, and amounts.",
InputSchema: object(props{
"orderId": str("the base62 order id"),
}, []string{"orderId"}),
invoke: func(args json.RawMessage) (any, error) {
var a struct {
OrderID string `json:"orderId"`
}
if err := decode(args, &a); err != nil {
return nil, err
}
id, ok := order.ParseOrderId(a.OrderID)
if !ok {
return nil, fmt.Errorf("invalid order id %q", a.OrderID)
}
g, err := s.applier.Get(context.Background(), uint64(id))
if err != nil {
return nil, fmt.Errorf("get order: %w", err)
}
if g.Status == order.StatusNew {
return nil, fmt.Errorf("order %q not found", a.OrderID)
}
return g, nil
},
},
{
Name: "get_order_status",
Description: "Get the current status (new|pending|authorized|captured|partially_fulfilled|fulfilled|completed|cancelled|refunded) of an order and the legal next transitions.",
InputSchema: object(props{
"orderId": str("the base62 order id"),
}, []string{"orderId"}),
invoke: func(args json.RawMessage) (any, error) {
var a struct {
OrderID string `json:"orderId"`
}
if err := decode(args, &a); err != nil {
return nil, err
}
id, ok := order.ParseOrderId(a.OrderID)
if !ok {
return nil, fmt.Errorf("invalid order id %q", a.OrderID)
}
g, err := s.applier.Get(context.Background(), uint64(id))
if err != nil {
return nil, fmt.Errorf("get order: %w", err)
}
if g.Status == order.StatusNew {
return nil, fmt.Errorf("order %q not found", a.OrderID)
}
return map[string]any{
"orderId": a.OrderID,
"status": string(g.Status),
"nextTransitions": validTransitions(g.Status),
}, nil
},
},
{
Name: "get_order_lines",
Description: "List all line items on an order with reference, SKU, name, quantity, unit price, tax rate, total amount, total tax, and fulfilled quantity.",
InputSchema: object(props{
"orderId": str("the base62 order id"),
}, []string{"orderId"}),
invoke: func(args json.RawMessage) (any, error) {
var a struct {
OrderID string `json:"orderId"`
}
if err := decode(args, &a); err != nil {
return nil, err
}
id, ok := order.ParseOrderId(a.OrderID)
if !ok {
return nil, fmt.Errorf("invalid order id %q", a.OrderID)
}
g, err := s.applier.Get(context.Background(), uint64(id))
if err != nil {
return nil, fmt.Errorf("get order: %w", err)
}
if g.Status == order.StatusNew {
return nil, fmt.Errorf("order %q not found", a.OrderID)
}
return map[string]any{"lines": g.Lines, "count": len(g.Lines)}, nil
},
},
{
Name: "get_order_payments",
Description: "List all payment records on an order: provider, authorized/captured/refunded amounts, authorization and capture references.",
InputSchema: object(props{
"orderId": str("the base62 order id"),
}, []string{"orderId"}),
invoke: func(args json.RawMessage) (any, error) {
var a struct {
OrderID string `json:"orderId"`
}
if err := decode(args, &a); err != nil {
return nil, err
}
id, ok := order.ParseOrderId(a.OrderID)
if !ok {
return nil, fmt.Errorf("invalid order id %q", a.OrderID)
}
g, err := s.applier.Get(context.Background(), uint64(id))
if err != nil {
return nil, fmt.Errorf("get order: %w", err)
}
if g.Status == order.StatusNew {
return nil, fmt.Errorf("order %q not found", a.OrderID)
}
return map[string]any{"payments": g.Payments, "capturedAmount": g.CapturedAmount, "refundedAmount": g.RefundedAmount}, nil
},
},
{
Name: "get_order_fulfillments",
Description: "List all fulfillments (shipments) on an order: carrier, tracking number/URI, shipped lines with quantities, and creation date.",
InputSchema: object(props{
"orderId": str("the base62 order id"),
}, []string{"orderId"}),
invoke: func(args json.RawMessage) (any, error) {
var a struct {
OrderID string `json:"orderId"`
}
if err := decode(args, &a); err != nil {
return nil, err
}
id, ok := order.ParseOrderId(a.OrderID)
if !ok {
return nil, fmt.Errorf("invalid order id %q", a.OrderID)
}
g, err := s.applier.Get(context.Background(), uint64(id))
if err != nil {
return nil, fmt.Errorf("get order: %w", err)
}
if g.Status == order.StatusNew {
return nil, fmt.Errorf("order %q not found", a.OrderID)
}
return map[string]any{"fulfillments": g.Fulfillments, "count": len(g.Fulfillments)}, nil
},
},
{
Name: "get_order_returns",
Description: "List all return requests (RMAs) on an order: reason, returned lines, and request date.",
InputSchema: object(props{
"orderId": str("the base62 order id"),
}, []string{"orderId"}),
invoke: func(args json.RawMessage) (any, error) {
var a struct {
OrderID string `json:"orderId"`
}
if err := decode(args, &a); err != nil {
return nil, err
}
id, ok := order.ParseOrderId(a.OrderID)
if !ok {
return nil, fmt.Errorf("invalid order id %q", a.OrderID)
}
g, err := s.applier.Get(context.Background(), uint64(id))
if err != nil {
return nil, fmt.Errorf("get order: %w", err)
}
if g.Status == order.StatusNew {
return nil, fmt.Errorf("order %q not found", a.OrderID)
}
return map[string]any{"returns": g.Returns, "count": len(g.Returns)}, nil
},
},
{
Name: "cancel_order",
Description: "Cancel an order that is still in a cancellable state (pending or authorized) with an optional reason. Returns the updated order.",
InputSchema: object(props{
"orderId": str("the base62 order id"),
"reason": str("optional reason for cancellation"),
}, []string{"orderId"}),
invoke: func(args json.RawMessage) (any, error) {
var a struct {
OrderID string `json:"orderId"`
Reason string `json:"reason"`
}
if err := decode(args, &a); err != nil {
return nil, err
}
id, ok := order.ParseOrderId(a.OrderID)
if !ok {
return nil, fmt.Errorf("invalid order id %q", a.OrderID)
}
res, err := s.applier.Apply(context.Background(), uint64(id), &messages.CancelOrder{
Reason: a.Reason,
AtMs: time.Now().UnixMilli(),
})
if err != nil {
return nil, fmt.Errorf("cancel order: %w", err)
}
for _, m := range res.Mutations {
if m.Error != nil {
return nil, m.Error
}
}
return map[string]any{"orderId": a.OrderID, "order": &res.Result}, nil
},
},
{
Name: "create_fulfillment",
Description: "Ship lines on a captured order. Provide carrier and optionally tracking info. Each line entry requires a reference (the line reference) and quantity to ship. Returns the updated order.",
InputSchema: object(props{
"orderId": str("the base62 order id"),
"carrier": str("the shipping carrier name (e.g. PostNord, DHL)"),
"trackingNumber": str("optional tracking number"),
"trackingUri": str("optional tracking URL"),
"lines": objArray("lines to fulfill, each with reference (string) and quantity (integer)"),
}, []string{"orderId", "lines"}),
invoke: func(args json.RawMessage) (any, error) {
var a struct {
OrderID string `json:"orderId"`
Carrier string `json:"carrier"`
TrackingNumber string `json:"trackingNumber"`
TrackingURI string `json:"trackingUri"`
Lines []struct {
Reference string `json:"reference"`
Quantity int32 `json:"quantity"`
} `json:"lines"`
}
if err := decode(args, &a); err != nil {
return nil, err
}
id, ok := order.ParseOrderId(a.OrderID)
if !ok {
return nil, fmt.Errorf("invalid order id %q", a.OrderID)
}
fid, _ := order.NewOrderId()
fulfillmentLines := make([]*messages.FulfillmentLine, len(a.Lines))
for i, l := range a.Lines {
fulfillmentLines[i] = &messages.FulfillmentLine{Reference: l.Reference, Quantity: l.Quantity}
}
res, err := s.applier.Apply(context.Background(), uint64(id), &messages.CreateFulfillment{
Id: "f_" + fid.String(),
Carrier: a.Carrier,
TrackingNumber: a.TrackingNumber,
TrackingUri: a.TrackingURI,
Lines: fulfillmentLines,
AtMs: time.Now().UnixMilli(),
})
if err != nil {
return nil, fmt.Errorf("create fulfillment: %w", err)
}
for _, m := range res.Mutations {
if m.Error != nil {
return nil, m.Error
}
}
return map[string]any{"orderId": a.OrderID, "order": &res.Result}, nil
},
},
{
Name: "complete_order",
Description: "Mark a fulfilled order as completed. Only allowed when the order is in 'fulfilled' status. Returns the updated order.",
InputSchema: object(props{
"orderId": str("the base62 order id"),
}, []string{"orderId"}),
invoke: func(args json.RawMessage) (any, error) {
var a struct {
OrderID string `json:"orderId"`
}
if err := decode(args, &a); err != nil {
return nil, err
}
id, ok := order.ParseOrderId(a.OrderID)
if !ok {
return nil, fmt.Errorf("invalid order id %q", a.OrderID)
}
res, err := s.applier.Apply(context.Background(), uint64(id), &messages.CompleteOrder{
AtMs: time.Now().UnixMilli(),
})
if err != nil {
return nil, fmt.Errorf("complete order: %w", err)
}
for _, m := range res.Mutations {
if m.Error != nil {
return nil, m.Error
}
}
return map[string]any{"orderId": a.OrderID, "order": &res.Result}, nil
},
},
{
Name: "request_return",
Description: "Open a return request (RMA) against fulfilled lines on an order. Provide the lines being returned and a reason. Returns the updated order with the return recorded.",
InputSchema: object(props{
"orderId": str("the base62 order id"),
"reason": str("the reason for the return"),
"lines": objArray("lines being returned, each with reference (string) and quantity (integer)"),
}, []string{"orderId", "reason", "lines"}),
invoke: func(args json.RawMessage) (any, error) {
var a struct {
OrderID string `json:"orderId"`
Reason string `json:"reason"`
Lines []struct {
Reference string `json:"reference"`
Quantity int32 `json:"quantity"`
} `json:"lines"`
}
if err := decode(args, &a); err != nil {
return nil, err
}
id, ok := order.ParseOrderId(a.OrderID)
if !ok {
return nil, fmt.Errorf("invalid order id %q", a.OrderID)
}
rid, _ := order.NewOrderId()
returnLines := make([]*messages.FulfillmentLine, len(a.Lines))
for i, l := range a.Lines {
returnLines[i] = &messages.FulfillmentLine{Reference: l.Reference, Quantity: l.Quantity}
}
res, err := s.applier.Apply(context.Background(), uint64(id), &messages.RequestReturn{
Id: "r_" + rid.String(),
Reason: a.Reason,
Lines: returnLines,
AtMs: time.Now().UnixMilli(),
})
if err != nil {
return nil, fmt.Errorf("request return: %w", err)
}
for _, m := range res.Mutations {
if m.Error != nil {
return nil, m.Error
}
}
return map[string]any{"orderId": a.OrderID, "order": &res.Result}, nil
},
},
{
Name: "issue_refund",
Description: "Issue a refund against a captured order. Amount is in minor units (öre); omit for the full remaining captured amount. Returns the updated order.",
InputSchema: object(props{
"orderId": str("the base62 order id"),
"amount": integer("refund amount in minor units (öre); omit for full remaining captured amount"),
}, []string{"orderId"}),
invoke: func(args json.RawMessage) (any, error) {
var a struct {
OrderID string `json:"orderId"`
Amount int64 `json:"amount"`
}
if err := decode(args, &a); err != nil {
return nil, err
}
id, ok := order.ParseOrderId(a.OrderID)
if !ok {
return nil, fmt.Errorf("invalid order id %q", a.OrderID)
}
// If amount is 0 (omitted), default to full remaining captured amount.
if a.Amount <= 0 {
g, err := s.applier.Get(context.Background(), uint64(id))
if err != nil {
return nil, fmt.Errorf("get order for refund: %w", err)
}
if g.Status == order.StatusNew {
return nil, fmt.Errorf("order %q not found", a.OrderID)
}
a.Amount = g.CapturedAmount - g.RefundedAmount
}
if a.Amount <= 0 {
return nil, fmt.Errorf("refund amount must be positive")
}
res, err := s.applier.Apply(context.Background(), uint64(id), &messages.IssueRefund{
Provider: "mcp",
Amount: a.Amount,
Reference: "ref-mcp-" + a.OrderID,
AtMs: time.Now().UnixMilli(),
})
if err != nil {
return nil, fmt.Errorf("issue refund: %w", err)
}
for _, m := range res.Mutations {
if m.Error != nil {
return nil, m.Error
}
}
return map[string]any{"orderId": a.OrderID, "order": &res.Result}, nil
},
},
}
}
// validTransitions returns the list of legal next statuses for a given status,
// matching the transitions map in the order state machine.
func validTransitions(s order.Status) []string {
switch s {
case order.StatusNew:
return []string{"pending"}
case order.StatusPending:
return []string{"authorized", "cancelled"}
case order.StatusAuthorized:
return []string{"captured", "cancelled"}
case order.StatusCaptured:
return []string{"partially_fulfilled", "fulfilled", "refunded"}
case order.StatusPartiallyFulfilled:
return []string{"partially_fulfilled", "fulfilled", "refunded"}
case order.StatusFulfilled:
return []string{"completed", "refunded"}
case order.StatusCompleted:
return []string{"refunded"}
default:
return []string{}
}
}
// ---- result + schema helpers -----------------------------------------------
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 integer(d string) json.RawMessage { return scalar("integer", d) }
func obj(desc string) json.RawMessage { return scalar("object", desc) }
func scalar(typ, desc string) json.RawMessage {
b, _ := json.Marshal(map[string]any{"type": typ, "description": desc})
return b
}
func objArray(desc string) json.RawMessage {
b, _ := json.Marshal(map[string]any{
"type": "array", "description": desc, "items": map[string]string{"type": "object"},
})
return b
}
func stringArray(desc string) json.RawMessage {
b, _ := json.Marshal(map[string]any{
"type": "array", "description": desc, "items": map[string]string{"type": "string"},
})
return b
}