package mcp import ( "context" "encoding/json" "fmt" "log" "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") } // Extract UCP meta from arguments (UCP-Agent profile advertisement). // Per the UCP spec, meta is a sibling key inside arguments: // "arguments": { "meta": { "ucp-agent": { "profile": "..." } }, ... } args := extractUCPAgentMeta(&p.Arguments) 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(args) if err != nil { return result(req.ID, toolError(err)) } return result(req.ID, toolText(out)) } // extractUCPAgentMeta checks if the JSON arguments contain a "meta" key with // a nested "ucp-agent.profile" field. If found, it logs the profile (for // observability) and strips the "meta" key before returning the cleaned // arguments to the tool handler, so the meta object does not leak into the // tool's argument namespace. func extractUCPAgentMeta(arguments *json.RawMessage) json.RawMessage { if arguments == nil || len(*arguments) == 0 { return *arguments } var obj map[string]json.RawMessage if err := json.Unmarshal(*arguments, &obj); err != nil { return *arguments } metaRaw, hasMeta := obj["meta"] if !hasMeta { return *arguments } // Try to extract the profile for observability. var meta struct { UCPAgent *struct { Profile string `json:"profile"` } `json:"ucp-agent"` } if err := json.Unmarshal(metaRaw, &meta); err == nil && meta.UCPAgent != nil && meta.UCPAgent.Profile != "" { log.Printf("ucp-agent: profile=%s", meta.UCPAgent.Profile) } // Strip meta from arguments before passing to the tool handler. delete(obj, "meta") cleaned, err := json.Marshal(obj) if err != nil { return *arguments } return cleaned } 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 }