mcp and ucp
Build and Publish / BuildAndDeployArm64 (push) Failing after 6s
Build and Publish / BuildAndDeployAmd64 (push) Has been cancelled

This commit is contained in:
2026-06-24 10:50:32 +02:00
parent a1f0bad972
commit 9aa587b9cf
20 changed files with 4944 additions and 0 deletions
+44
View File
@@ -0,0 +1,44 @@
package mcp
import "encoding/json"
// JSON-RPC 2.0 envelope types (the wire format MCP rides on). Notifications are
// requests with no id and must not receive a response.
type rpcRequest struct {
JSONRPC string `json:"jsonrpc"`
ID json.RawMessage `json:"id,omitempty"`
Method string `json:"method"`
Params json.RawMessage `json:"params,omitempty"`
}
func (r *rpcRequest) isNotification() bool { return len(r.ID) == 0 }
type rpcResponse struct {
JSONRPC string `json:"jsonrpc"`
ID json.RawMessage `json:"id,omitempty"`
Result any `json:"result,omitempty"`
Error *rpcError `json:"error,omitempty"`
}
type rpcError struct {
Code int `json:"code"`
Message string `json:"message"`
Data any `json:"data,omitempty"`
}
const (
codeParseError = -32700
codeInvalidRequest = -32600
codeMethodNotFound = -32601
codeInvalidParams = -32602
codeInternalError = -32603
)
func result(id json.RawMessage, v any) *rpcResponse {
return &rpcResponse{JSONRPC: "2.0", ID: id, Result: v}
}
func errorResponse(id json.RawMessage, code int, msg string) *rpcResponse {
return &rpcResponse{JSONRPC: "2.0", ID: id, Error: &rpcError{Code: code, Message: msg}}
}
+159
View File
@@ -0,0 +1,159 @@
// Package mcp is the MCP edge for the cart: a Model Context Protocol server
// exposing the cart grain as tools so an agent can inspect and mutate carts
// (list items, change quantities, remove items, clear carts, apply vouchers,
// manage users, etc.).
//
// Transport is streamable HTTP (JSON-RPC 2.0 over POST). It is mounted by
// cmd/cart under /mcp on the same HTTP server as the cart API. Tools map
// 1:1 to cart grain read/apply operations; no restart is needed because every
// tool call goes through the shared grain pool.
package mcp
import (
"context"
"encoding/json"
"io"
"net/http"
"git.k6n.net/mats/go-cart-actor/pkg/actor"
"git.k6n.net/mats/go-cart-actor/pkg/cart"
"google.golang.org/protobuf/proto"
)
const (
serverName = "cart"
serverVersion = "0.1.0"
protocolVersion = "2025-06-18"
)
// Applier is the minimal grain-pool interface the cart MCP needs: read a
// cart's current state (Get) and apply a mutation (Apply). The
// SimpleGrainPool[cart.CartGrain] in cmd/cart satisfies it directly.
type Applier interface {
Get(ctx context.Context, id uint64) (*cart.CartGrain, error)
Apply(ctx context.Context, id uint64, mutation ...proto.Message) (*actor.MutationResult[cart.CartGrain], error)
}
// Server is the MCP edge over the cart grain pool.
type Server struct {
applier Applier
tools []tool
}
// New builds an MCP server exposing the cart grain as tools.
func New(applier Applier) *Server {
s := &Server{applier: applier}
s.tools = s.buildTools()
return s
}
// Handler returns the streamable-HTTP handler to mount (e.g. at /mcp).
func (s *Server) Handler() http.Handler {
return http.HandlerFunc(s.serveHTTP)
}
func (s *Server) serveHTTP(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
w.Header().Set("Allow", "POST")
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
body, err := io.ReadAll(io.LimitReader(r.Body, 4<<20))
if err != nil {
writeRPC(w, errorResponse(nil, codeParseError, "read error"))
return
}
if isBatch(body) {
var reqs []rpcRequest
if err := json.Unmarshal(body, &reqs); err != nil {
writeRPC(w, errorResponse(nil, codeParseError, "invalid JSON"))
return
}
var out []*rpcResponse
for i := range reqs {
if resp := s.dispatch(&reqs[i]); resp != nil {
out = append(out, resp)
}
}
if len(out) == 0 {
w.WriteHeader(http.StatusAccepted)
return
}
writeRPC(w, out)
return
}
var req rpcRequest
if err := json.Unmarshal(body, &req); err != nil {
writeRPC(w, errorResponse(nil, codeParseError, "invalid JSON"))
return
}
resp := s.dispatch(&req)
if resp == nil {
w.WriteHeader(http.StatusAccepted)
return
}
writeRPC(w, resp)
}
func (s *Server) dispatch(req *rpcRequest) *rpcResponse {
if req.JSONRPC != "2.0" {
if req.isNotification() {
return nil
}
return errorResponse(req.ID, codeInvalidRequest, "jsonrpc must be \"2.0\"")
}
switch req.Method {
case "initialize":
return result(req.ID, s.initialize(req.Params))
case "ping":
return result(req.ID, struct{}{})
case "tools/list":
return result(req.ID, map[string]any{"tools": s.tools})
case "tools/call":
return s.callTool(req)
case "notifications/initialized", "notifications/cancelled":
return nil
default:
if req.isNotification() {
return nil
}
return errorResponse(req.ID, codeMethodNotFound, "unknown method: "+req.Method)
}
}
func (s *Server) initialize(params json.RawMessage) map[string]any {
pv := protocolVersion
if len(params) > 0 {
var p struct {
ProtocolVersion string `json:"protocolVersion"`
}
if json.Unmarshal(params, &p) == nil && p.ProtocolVersion != "" {
pv = p.ProtocolVersion
}
}
return map[string]any{
"protocolVersion": pv,
"capabilities": map[string]any{"tools": map[string]any{}},
"serverInfo": map[string]any{"name": serverName, "version": serverVersion},
}
}
func isBatch(body []byte) bool {
for _, b := range body {
switch b {
case ' ', '\t', '\r', '\n':
continue
case '[':
return true
default:
return false
}
}
return false
}
func writeRPC(w http.ResponseWriter, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_ = json.NewEncoder(w).Encode(v)
}
+421
View File
@@ -0,0 +1,421 @@
package mcp
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"testing"
"time"
"git.k6n.net/mats/go-cart-actor/pkg/actor"
"git.k6n.net/mats/go-cart-actor/pkg/cart"
"google.golang.org/protobuf/proto"
)
// testApplier implements Applier with an in-memory map of carts, using the real
// cart mutation registry so write tools exercise the actual mutation handlers.
type testApplier struct {
grains map[uint64]*cart.CartGrain
reg actor.MutationRegistry
}
func newTestApplier() *testApplier {
reg := cart.NewCartMultationRegistry(cart.NewCartMutationContext(nil))
return &testApplier{
grains: make(map[uint64]*cart.CartGrain),
reg: reg,
}
}
func (a *testApplier) addCart(id uint64, items int) *cart.CartGrain {
g := cart.NewCartGrain(id, time.Now())
g.Currency = "SEK"
g.Language = "sv-se"
for i := 1; i <= items; i++ {
g.Items = append(g.Items, &cart.CartItem{
Id: uint32(i),
ItemId: uint32(1000 + i),
Sku: fmt.Sprintf("SKU-%03d", i),
Quantity: uint16(i),
Price: *cart.NewPriceFromIncVat(int64(i*10000), 25),
Tax: 2500,
Meta: &cart.ItemMeta{Name: fmt.Sprintf("Item %d", i)},
})
}
g.UpdateTotals()
a.grains[id] = g
return g
}
func (a *testApplier) Get(_ context.Context, id uint64) (*cart.CartGrain, error) {
g, ok := a.grains[id]
if !ok {
return nil, fmt.Errorf("cart %d not found", id)
}
return g, nil
}
func (a *testApplier) Apply(ctx context.Context, id uint64, msgs ...proto.Message) (*actor.MutationResult[cart.CartGrain], error) {
g, ok := a.grains[id]
if !ok {
return nil, fmt.Errorf("cart %d not found", id)
}
results, err := a.reg.Apply(ctx, g, msgs...)
if err != nil {
return nil, err
}
// Re-read state after mutations.
state, err := g.GetCurrentState()
if err != nil {
return nil, err
}
return &actor.MutationResult[cart.CartGrain]{
Result: *state, //nolint:govet // test helper snapshot, same pattern as SimpleGrainPool
Mutations: results,
}, nil
}
// call issues a tools/call against the handler and returns the decoded text
// payload (the JSON the tool returned), failing on a tool/transport error.
func call(t *testing.T, h http.Handler, name string, args map[string]any) map[string]any {
t.Helper()
body, _ := json.Marshal(map[string]any{
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": map[string]any{"name": name, "arguments": args},
})
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/mcp", bytes.NewReader(body))
h.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("%s: status %d", name, rec.Code)
}
var resp struct {
Result struct {
IsError bool `json:"isError"`
Content []struct {
Text string `json:"text"`
} `json:"content"`
} `json:"result"`
Error *struct {
Message string `json:"message"`
} `json:"error"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("%s: decode response: %v (%s)", name, err, rec.Body.String())
}
if resp.Error != nil {
t.Fatalf("%s: rpc error: %s", name, resp.Error.Message)
}
if resp.Result.IsError {
t.Fatalf("%s: tool error: %s", name, resp.Result.Content[0].Text)
}
var out map[string]any
if len(resp.Result.Content) > 0 {
_ = json.Unmarshal([]byte(resp.Result.Content[0].Text), &out)
}
return out
}
func TestCartMCPRoundTrip(t *testing.T) {
a := newTestApplier()
a.addCart(42, 3) // cart 42 with 3 items
h := New(a).Handler()
// get_cart
got := call(t, h, "get_cart", map[string]any{"cartId": "g"})
if got == nil {
t.Fatal("get_cart returned nil")
}
if id, _ := got["id"].(string); id != "g" {
t.Errorf("get_cart id = %v, want g", got["id"])
}
if curr, _ := got["currency"].(string); curr != "SEK" {
t.Errorf("get_cart currency = %q, want SEK", curr)
}
// get_cart_items
items := call(t, h, "get_cart_items", map[string]any{"cartId": "g"})
if c, _ := items["count"].(float64); c != 3 {
t.Errorf("get_cart_items count = %v, want 3", items["count"])
}
// get_cart_totals
totals := call(t, h, "get_cart_totals", map[string]any{"cartId": "g"})
if _, ok := totals["totalPrice"]; !ok {
t.Errorf("get_cart_totals missing totalPrice: %v", totals)
}
if _, ok := totals["totalDiscount"]; !ok {
t.Errorf("get_cart_totals missing totalDiscount: %v", totals)
}
// get_cart_vouchers (empty cart)
vouchers := call(t, h, "get_cart_vouchers", map[string]any{"cartId": "g"})
if c, _ := vouchers["count"].(float64); c != 0 {
t.Errorf("get_cart_vouchers count = %v, want 0", vouchers["count"])
}
// update_item_quantity: change item id=1 from qty 1 to qty 5
updated := call(t, h, "update_item_quantity", map[string]any{
"cartId": "g", "itemId": 1, "quantity": 5,
})
if updated == nil {
t.Fatal("update_item_quantity returned nil")
}
// Verify via get_cart_items
items2 := call(t, h, "get_cart_items", map[string]any{"cartId": "g"})
itemsRaw, _ := items2["items"].([]any)
if len(itemsRaw) != 3 {
t.Fatalf("after qty change: want 3 items, got %d", len(itemsRaw))
}
firstItem := itemsRaw[0].(map[string]any)
if q, _ := firstItem["qty"].(float64); q != 5 {
t.Errorf("item 1 qty = %v, want 5", q)
}
// remove_cart_item: remove item id=2
removed := call(t, h, "remove_cart_item", map[string]any{
"cartId": "g", "itemId": 2,
})
if removed == nil {
t.Fatal("remove_cart_item returned nil")
}
items3 := call(t, h, "get_cart_items", map[string]any{"cartId": "g"})
itemsRaw3, _ := items3["items"].([]any)
if len(itemsRaw3) != 2 {
t.Fatalf("after remove: want 2 items, got %d", len(itemsRaw3))
}
// apply_voucher
withVoucher := call(t, h, "apply_voucher", map[string]any{
"cartId": "g", "code": "SAVE10", "value": 10000,
})
if withVoucher == nil {
t.Fatal("apply_voucher returned nil")
}
vouchers2 := call(t, h, "get_cart_vouchers", map[string]any{"cartId": "g"})
if c, _ := vouchers2["count"].(float64); c != 1 {
t.Errorf("after voucher apply: count = %v, want 1", vouchers2["count"])
}
// remove_voucher: find the voucher id
voucherList, _ := vouchers2["vouchers"].([]any)
if len(voucherList) > 0 {
v := voucherList[0].(map[string]any)
vID := v["id"].(float64)
call(t, h, "remove_voucher", map[string]any{
"cartId": "g", "voucherId": int(vID),
})
vouchers3 := call(t, h, "get_cart_vouchers", map[string]any{"cartId": "g"})
if c, _ := vouchers3["count"].(float64); c != 0 {
t.Errorf("after remove voucher: count = %v, want 0", vouchers3["count"])
}
}
// set_cart_user
withUser := call(t, h, "set_cart_user", map[string]any{
"cartId": "g", "userId": "user-abc-123",
})
if withUser == nil {
t.Fatal("set_cart_user returned nil")
}
// clear_cart
cleared := call(t, h, "clear_cart", map[string]any{"cartId": "g"})
if cleared == nil {
t.Fatal("clear_cart returned nil")
}
items4 := call(t, h, "get_cart_items", map[string]any{"cartId": "g"})
if c, _ := items4["count"].(float64); c != 0 {
t.Errorf("after clear: count = %v, want 0", items4["count"])
}
}
func TestCartMCPErrors(t *testing.T) {
a := newTestApplier()
a.addCart(1, 1)
h := New(a).Handler()
// Invalid cart id -> isError result.
result := callWithRaw(t, h, "get_cart", map[string]any{"cartId": "!!!"})
if !result.IsError {
t.Fatal("expected isError for invalid cart id")
}
// Missing required cartId -> still hits decode (cartId absent is empty string)
result2 := callWithRaw(t, h, "get_cart", map[string]any{})
if !result2.IsError {
t.Fatal("expected isError for missing cartId")
}
// Non-existent cart -> isError result.
result3 := callWithRaw(t, h, "get_cart", map[string]any{"cartId": "zzzzzz"})
if !result3.IsError {
t.Fatal("expected isError for non-existent cart")
}
// Remove non-existent item -> isError
result4 := callWithRaw(t, h, "remove_cart_item", map[string]any{
"cartId": "1", "itemId": 999,
})
if !result4.IsError {
t.Fatal("expected isError for non-existent item")
}
// Unknown tool -> protocol error.
var resp struct {
Error *struct {
Message string `json:"message"`
} `json:"error"`
}
body, _ := json.Marshal(map[string]any{
"jsonrpc": "2.0", "id": 1, "method": "tools/call",
"params": map[string]any{"name": "nope", "arguments": map[string]any{}},
})
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/mcp", bytes.NewReader(body))
h.ServeHTTP(rec, req)
json.Unmarshal(rec.Body.Bytes(), &resp)
if resp.Error == nil {
t.Fatal("expected protocol error for unknown tool")
}
}
// callWithRaw returns the raw result (including isError) without failing.
type rawResult struct {
IsError bool
Content []struct {
Text string `json:"text"`
}
}
func callWithRaw(t *testing.T, h http.Handler, name string, args map[string]any) rawResult {
t.Helper()
body, _ := json.Marshal(map[string]any{
"jsonrpc": "2.0", "id": 1, "method": "tools/call",
"params": map[string]any{"name": name, "arguments": args},
})
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/mcp", bytes.NewReader(body))
h.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("%s: status %d", name, rec.Code)
}
var resp struct {
Result rawResult `json:"result"`
Error *struct{ Message string } `json:"error"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("%s: decode: %v", name, err)
}
if resp.Error != nil {
t.Fatalf("%s: rpc error: %s", name, resp.Error.Message)
}
return resp.Result
}
func TestInitializeAndToolsList(t *testing.T) {
a := newTestApplier()
h := New(a).Handler()
// Initialize.
body, _ := json.Marshal(map[string]any{
"jsonrpc": "2.0", "id": 1, "method": "initialize",
"params": map[string]any{"protocolVersion": "2025-06-18"},
})
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/mcp", bytes.NewReader(body))
h.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("initialize: status %d", rec.Code)
}
var initResp struct {
Result struct {
ProtocolVersion string `json:"protocolVersion"`
ServerInfo struct {
Name string `json:"name"`
} `json:"serverInfo"`
} `json:"result"`
}
json.Unmarshal(rec.Body.Bytes(), &initResp)
if initResp.Result.ProtocolVersion != "2025-06-18" || initResp.Result.ServerInfo.Name != "cart" {
t.Fatalf("unexpected initialize: %+v", initResp)
}
// tools/list.
body2, _ := json.Marshal(map[string]any{
"jsonrpc": "2.0", "id": 1, "method": "tools/list",
})
rec2 := httptest.NewRecorder()
req2 := httptest.NewRequest(http.MethodPost, "/mcp", bytes.NewReader(body2))
h.ServeHTTP(rec2, req2)
var listResp struct {
Result struct {
Tools []struct {
Name string `json:"name"`
InputSchema json.RawMessage `json:"inputSchema"`
} `json:"tools"`
} `json:"result"`
}
json.Unmarshal(rec2.Body.Bytes(), &listResp)
want := map[string]bool{
"get_cart": false, "update_item_quantity": false,
"remove_cart_item": false, "clear_cart": false,
}
for _, tl := range listResp.Result.Tools {
if _, ok := want[tl.Name]; ok {
want[tl.Name] = true
}
if len(tl.InputSchema) == 0 {
t.Fatalf("tool %s has empty inputSchema", tl.Name)
}
}
for name, found := range want {
if !found {
t.Fatalf("expected tool %q in tools/list", name)
}
}
}
func TestCartMCPCartIdRoundTrip(t *testing.T) {
// Verify that we can round-trip a base62 cart id through tools.
a := newTestApplier()
// Use a larger cart id to test base62 encoding.
g := cart.NewCartGrain(12345, time.Now())
g.Currency = "EUR"
a.grains[12345] = g
h := New(a).Handler()
got := call(t, h, "get_cart", map[string]any{"cartId": g.Id.String()})
if curr, _ := got["currency"].(string); curr != "EUR" {
t.Errorf("currency = %q, want EUR", curr)
}
}
func TestGetCartPromotions(t *testing.T) {
a := newTestApplier()
g := a.addCart(99, 2)
// Add a synthetic applied promotion.
g.AppliedPromotions = []cart.AppliedPromotion{
{PromotionId: "volymrabatt", Name: "Volymrabatt", Type: "tiered_discount", Discount: cart.NewPriceFromIncVat(10000, 25)},
}
h := New(a).Handler()
result := call(t, h, "get_cart_promotions", map[string]any{"cartId": g.Id.String()})
if result == nil {
t.Fatal("get_cart_promotions returned nil")
}
promos, _ := result["appliedPromotions"].([]any)
if len(promos) != 1 {
t.Fatalf("want 1 promotion, got %d", len(promos))
}
p := promos[0].(map[string]any)
if name, _ := p["name"].(string); name != "Volymrabatt" {
t.Errorf("promotion name = %q, want Volymrabatt", name)
}
}
+439
View File
@@ -0,0 +1,439 @@
package mcp
import (
"context"
"encoding/json"
"fmt"
"git.k6n.net/mats/go-cart-actor/pkg/cart"
messages "git.k6n.net/mats/go-cart-actor/proto/cart"
)
// 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_cart",
Description: "Get the full state of a cart by its base62 cart id: items, totals, vouchers, promotions, user info, currency, language, checkout status, subscription details, and all applied/pending promotions with progress nudges.",
InputSchema: object(props{
"cartId": str("the base62 cart id"),
}, []string{"cartId"}),
invoke: func(args json.RawMessage) (any, error) {
var a struct {
CartID string `json:"cartId"`
}
if err := decode(args, &a); err != nil {
return nil, err
}
id, ok := cart.ParseCartId(a.CartID)
if !ok {
return nil, fmt.Errorf("invalid cart id %q", a.CartID)
}
g, err := s.applier.Get(context.Background(), uint64(id))
if err != nil {
return nil, fmt.Errorf("get cart: %w", err)
}
return g, nil
},
},
{
Name: "get_cart_items",
Description: "List all items in a cart with their SKU, name, quantity, unit price, total price, stock, tax rate, markings, custom fields, and optional child/parent relationships.",
InputSchema: object(props{
"cartId": str("the base62 cart id"),
}, []string{"cartId"}),
invoke: func(args json.RawMessage) (any, error) {
var a struct {
CartID string `json:"cartId"`
}
if err := decode(args, &a); err != nil {
return nil, err
}
id, ok := cart.ParseCartId(a.CartID)
if !ok {
return nil, fmt.Errorf("invalid cart id %q", a.CartID)
}
g, err := s.applier.Get(context.Background(), uint64(id))
if err != nil {
return nil, fmt.Errorf("get cart: %w", err)
}
return map[string]any{"items": g.Items, "count": len(g.Items)}, nil
},
},
{
Name: "get_cart_totals",
Description: "Get price breakdown for a cart: total incVat, total exVat, VAT breakdown by rate, total discount (vouchers + promotions), and per-item totals.",
InputSchema: object(props{
"cartId": str("the base62 cart id"),
}, []string{"cartId"}),
invoke: func(args json.RawMessage) (any, error) {
var a struct {
CartID string `json:"cartId"`
}
if err := decode(args, &a); err != nil {
return nil, err
}
id, ok := cart.ParseCartId(a.CartID)
if !ok {
return nil, fmt.Errorf("invalid cart id %q", a.CartID)
}
g, err := s.applier.Get(context.Background(), uint64(id))
if err != nil {
return nil, fmt.Errorf("get cart: %w", err)
}
return map[string]any{
"totalPrice": g.TotalPrice,
"totalDiscount": g.TotalDiscount,
"currency": g.Currency,
"language": g.Language,
"vouchers": g.Vouchers,
}, nil
},
},
{
Name: "get_cart_promotions",
Description: "Get all applied and pending promotions on a cart, including discount amounts and progress nudges (e.g. 'spend 1200 SEK more for free shipping').",
InputSchema: object(props{
"cartId": str("the base62 cart id"),
}, []string{"cartId"}),
invoke: func(args json.RawMessage) (any, error) {
var a struct {
CartID string `json:"cartId"`
}
if err := decode(args, &a); err != nil {
return nil, err
}
id, ok := cart.ParseCartId(a.CartID)
if !ok {
return nil, fmt.Errorf("invalid cart id %q", a.CartID)
}
g, err := s.applier.Get(context.Background(), uint64(id))
if err != nil {
return nil, fmt.Errorf("get cart: %w", err)
}
return map[string]any{
"appliedPromotions": g.AppliedPromotions,
"totalDiscount": g.TotalDiscount,
}, nil
},
},
{
Name: "get_cart_vouchers",
Description: "List all vouchers applied to a cart with their codes, values, rules, and whether they are currently active.",
InputSchema: object(props{
"cartId": str("the base62 cart id"),
}, []string{"cartId"}),
invoke: func(args json.RawMessage) (any, error) {
var a struct {
CartID string `json:"cartId"`
}
if err := decode(args, &a); err != nil {
return nil, err
}
id, ok := cart.ParseCartId(a.CartID)
if !ok {
return nil, fmt.Errorf("invalid cart id %q", a.CartID)
}
g, err := s.applier.Get(context.Background(), uint64(id))
if err != nil {
return nil, fmt.Errorf("get cart: %w", err)
}
return map[string]any{"vouchers": g.Vouchers, "count": len(g.Vouchers)}, nil
},
},
{
Name: "update_item_quantity",
Description: "Change the quantity of a line item in a cart. Use quantity=0 to remove the item. Returns the updated cart state.",
InputSchema: object(props{
"cartId": str("the base62 cart id"),
"itemId": integer("the line item id (numeric, e.g. 1, 2, 3)"),
"quantity": integer("the new quantity (0 removes the item)"),
}, []string{"cartId", "itemId", "quantity"}),
invoke: func(args json.RawMessage) (any, error) {
var a struct {
CartID string `json:"cartId"`
ItemID int `json:"itemId"`
Quantity int32 `json:"quantity"`
}
if err := decode(args, &a); err != nil {
return nil, err
}
id, ok := cart.ParseCartId(a.CartID)
if !ok {
return nil, fmt.Errorf("invalid cart id %q", a.CartID)
}
res, err := s.applier.Apply(context.Background(), uint64(id), &messages.ChangeQuantity{
Id: uint32(a.ItemID),
Quantity: a.Quantity,
})
if err != nil {
return nil, fmt.Errorf("change quantity: %w", err)
}
// Check for per-mutation errors.
for _, m := range res.Mutations {
if m.Error != nil {
return nil, m.Error
}
}
return map[string]any{"cartId": a.CartID, "cart": &res.Result}, nil
},
},
{
Name: "remove_cart_item",
Description: "Remove a line item (and its children) from a cart by item id. Returns the updated cart state.",
InputSchema: object(props{
"cartId": str("the base62 cart id"),
"itemId": integer("the line item id to remove (numeric, e.g. 1, 2, 3)"),
}, []string{"cartId", "itemId"}),
invoke: func(args json.RawMessage) (any, error) {
var a struct {
CartID string `json:"cartId"`
ItemID int `json:"itemId"`
}
if err := decode(args, &a); err != nil {
return nil, err
}
id, ok := cart.ParseCartId(a.CartID)
if !ok {
return nil, fmt.Errorf("invalid cart id %q", a.CartID)
}
res, err := s.applier.Apply(context.Background(), uint64(id), &messages.RemoveItem{Id: uint32(a.ItemID)})
if err != nil {
return nil, fmt.Errorf("remove item: %w", err)
}
for _, m := range res.Mutations {
if m.Error != nil {
return nil, m.Error
}
}
return map[string]any{"cartId": a.CartID, "cart": &res.Result}, nil
},
},
{
Name: "clear_cart",
Description: "Remove all items and vouchers from a cart, resetting it to an empty state. The cart id, user id, and currency are preserved. Returns the updated (empty) cart.",
InputSchema: object(props{
"cartId": str("the base62 cart id"),
}, []string{"cartId"}),
invoke: func(args json.RawMessage) (any, error) {
var a struct {
CartID string `json:"cartId"`
}
if err := decode(args, &a); err != nil {
return nil, err
}
id, ok := cart.ParseCartId(a.CartID)
if !ok {
return nil, fmt.Errorf("invalid cart id %q", a.CartID)
}
res, err := s.applier.Apply(context.Background(), uint64(id), &messages.ClearCartRequest{})
if err != nil {
return nil, fmt.Errorf("clear cart: %w", err)
}
for _, m := range res.Mutations {
if m.Error != nil {
return nil, m.Error
}
}
return map[string]any{"cartId": a.CartID, "cart": &res.Result}, nil
},
},
{
Name: "apply_voucher",
Description: "Apply a voucher code to a cart. The voucher value is in öre (e.g. 10000 = 100 kr). Returns the updated cart with the voucher applied.",
InputSchema: object(props{
"cartId": str("the base62 cart id"),
"code": str("the voucher code"),
"value": integer("the voucher value in öre (e.g. 10000 = 100 kr)"),
"description": str("optional description of the voucher"),
"rules": stringArray("optional list of rule expressions for when the voucher applies"),
}, []string{"cartId", "code", "value"}),
invoke: func(args json.RawMessage) (any, error) {
var a struct {
CartID string `json:"cartId"`
Code string `json:"code"`
Value int64 `json:"value"`
Description string `json:"description"`
Rules []string `json:"rules"`
}
if err := decode(args, &a); err != nil {
return nil, err
}
id, ok := cart.ParseCartId(a.CartID)
if !ok {
return nil, fmt.Errorf("invalid cart id %q", a.CartID)
}
res, err := s.applier.Apply(context.Background(), uint64(id), &messages.AddVoucher{
Code: a.Code,
Value: a.Value,
Description: a.Description,
VoucherRules: a.Rules,
})
if err != nil {
return nil, fmt.Errorf("apply voucher: %w", err)
}
for _, m := range res.Mutations {
if m.Error != nil {
return nil, m.Error
}
}
return map[string]any{"cartId": a.CartID, "cart": &res.Result}, nil
},
},
{
Name: "remove_voucher",
Description: "Remove a voucher from a cart by its voucher id. Returns the updated cart.",
InputSchema: object(props{
"cartId": str("the base62 cart id"),
"voucherId": integer("the voucher id to remove (numeric)"),
}, []string{"cartId", "voucherId"}),
invoke: func(args json.RawMessage) (any, error) {
var a struct {
CartID string `json:"cartId"`
VoucherID int `json:"voucherId"`
}
if err := decode(args, &a); err != nil {
return nil, err
}
id, ok := cart.ParseCartId(a.CartID)
if !ok {
return nil, fmt.Errorf("invalid cart id %q", a.CartID)
}
res, err := s.applier.Apply(context.Background(), uint64(id), &messages.RemoveVoucher{Id: uint32(a.VoucherID)})
if err != nil {
return nil, fmt.Errorf("remove voucher: %w", err)
}
for _, m := range res.Mutations {
if m.Error != nil {
return nil, m.Error
}
}
return map[string]any{"cartId": a.CartID, "cart": &res.Result}, nil
},
},
{
Name: "set_cart_user",
Description: "Set or update the user ID on a cart, linking it to a customer account. Returns the updated cart.",
InputSchema: object(props{
"cartId": str("the base62 cart id"),
"userId": str("the user/customer id"),
}, []string{"cartId", "userId"}),
invoke: func(args json.RawMessage) (any, error) {
var a struct {
CartID string `json:"cartId"`
UserID string `json:"userId"`
}
if err := decode(args, &a); err != nil {
return nil, err
}
id, ok := cart.ParseCartId(a.CartID)
if !ok {
return nil, fmt.Errorf("invalid cart id %q", a.CartID)
}
res, err := s.applier.Apply(context.Background(), uint64(id), &messages.SetUserId{UserId: a.UserID})
if err != nil {
return nil, fmt.Errorf("set user: %w", err)
}
for _, m := range res.Mutations {
if m.Error != nil {
return nil, m.Error
}
}
return map[string]any{"cartId": a.CartID, "cart": &res.Result}, nil
},
},
}
}
// ---- 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 stringArray(desc string) json.RawMessage {
b, _ := json.Marshal(map[string]any{
"type": "array", "description": desc, "items": map[string]string{"type": "string"},
})
return b
}
+44
View File
@@ -0,0 +1,44 @@
package mcp
import "encoding/json"
// JSON-RPC 2.0 envelope types (the wire format MCP rides on). Notifications are
// requests with no id and must not receive a response.
type rpcRequest struct {
JSONRPC string `json:"jsonrpc"`
ID json.RawMessage `json:"id,omitempty"`
Method string `json:"method"`
Params json.RawMessage `json:"params,omitempty"`
}
func (r *rpcRequest) isNotification() bool { return len(r.ID) == 0 }
type rpcResponse struct {
JSONRPC string `json:"jsonrpc"`
ID json.RawMessage `json:"id,omitempty"`
Result any `json:"result,omitempty"`
Error *rpcError `json:"error,omitempty"`
}
type rpcError struct {
Code int `json:"code"`
Message string `json:"message"`
Data any `json:"data,omitempty"`
}
const (
codeParseError = -32700
codeInvalidRequest = -32600
codeMethodNotFound = -32601
codeInvalidParams = -32602
codeInternalError = -32603
)
func result(id json.RawMessage, v any) *rpcResponse {
return &rpcResponse{JSONRPC: "2.0", ID: id, Result: v}
}
func errorResponse(id json.RawMessage, code int, msg string) *rpcResponse {
return &rpcResponse{JSONRPC: "2.0", ID: id, Error: &rpcError{Code: code, Message: msg}}
}
+158
View File
@@ -0,0 +1,158 @@
// Package mcp is the MCP edge for the order: a Model Context Protocol server
// exposing the order grain as tools so an agent can inspect order state and
// drive the order lifecycle (cancel, fulfill, complete, return, refund, etc.).
//
// Transport is streamable HTTP (JSON-RPC 2.0 over POST). It is mounted by
// cmd/order under /mcp on the same HTTP server as the order API. Tools map
// 1:1 to order grain read/apply operations; no restart is needed because every
// tool call goes through the shared grain pool.
package mcp
import (
"context"
"encoding/json"
"io"
"net/http"
"git.k6n.net/mats/go-cart-actor/pkg/actor"
"git.k6n.net/mats/go-cart-actor/pkg/order"
"google.golang.org/protobuf/proto"
)
const (
serverName = "orders"
serverVersion = "0.1.0"
protocolVersion = "2025-06-18"
)
// OrderApplier is the minimal grain-pool interface the order MCP needs: read an
// order's current state (Get) and apply a mutation (Apply). The
// SimpleGrainPool[order.OrderGrain] in cmd/order satisfies it directly.
type OrderApplier interface {
Get(ctx context.Context, id uint64) (*order.OrderGrain, error)
Apply(ctx context.Context, id uint64, mutation ...proto.Message) (*actor.MutationResult[order.OrderGrain], error)
}
// Server is the MCP edge over the order grain pool.
type Server struct {
applier OrderApplier
tools []tool
}
// New builds an MCP server exposing the order grain as tools.
func New(applier OrderApplier) *Server {
s := &Server{applier: applier}
s.tools = s.buildTools()
return s
}
// Handler returns the streamable-HTTP handler to mount (e.g. at /mcp).
func (s *Server) Handler() http.Handler {
return http.HandlerFunc(s.serveHTTP)
}
func (s *Server) serveHTTP(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
w.Header().Set("Allow", "POST")
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
body, err := io.ReadAll(io.LimitReader(r.Body, 4<<20))
if err != nil {
writeRPC(w, errorResponse(nil, codeParseError, "read error"))
return
}
if isBatch(body) {
var reqs []rpcRequest
if err := json.Unmarshal(body, &reqs); err != nil {
writeRPC(w, errorResponse(nil, codeParseError, "invalid JSON"))
return
}
var out []*rpcResponse
for i := range reqs {
if resp := s.dispatch(&reqs[i]); resp != nil {
out = append(out, resp)
}
}
if len(out) == 0 {
w.WriteHeader(http.StatusAccepted)
return
}
writeRPC(w, out)
return
}
var req rpcRequest
if err := json.Unmarshal(body, &req); err != nil {
writeRPC(w, errorResponse(nil, codeParseError, "invalid JSON"))
return
}
resp := s.dispatch(&req)
if resp == nil {
w.WriteHeader(http.StatusAccepted)
return
}
writeRPC(w, resp)
}
func (s *Server) dispatch(req *rpcRequest) *rpcResponse {
if req.JSONRPC != "2.0" {
if req.isNotification() {
return nil
}
return errorResponse(req.ID, codeInvalidRequest, "jsonrpc must be \"2.0\"")
}
switch req.Method {
case "initialize":
return result(req.ID, s.initialize(req.Params))
case "ping":
return result(req.ID, struct{}{})
case "tools/list":
return result(req.ID, map[string]any{"tools": s.tools})
case "tools/call":
return s.callTool(req)
case "notifications/initialized", "notifications/cancelled":
return nil
default:
if req.isNotification() {
return nil
}
return errorResponse(req.ID, codeMethodNotFound, "unknown method: "+req.Method)
}
}
func (s *Server) initialize(params json.RawMessage) map[string]any {
pv := protocolVersion
if len(params) > 0 {
var p struct {
ProtocolVersion string `json:"protocolVersion"`
}
if json.Unmarshal(params, &p) == nil && p.ProtocolVersion != "" {
pv = p.ProtocolVersion
}
}
return map[string]any{
"protocolVersion": pv,
"capabilities": map[string]any{"tools": map[string]any{}},
"serverInfo": map[string]any{"name": serverName, "version": serverVersion},
}
}
func isBatch(body []byte) bool {
for _, b := range body {
switch b {
case ' ', '\t', '\r', '\n':
continue
case '[':
return true
default:
return false
}
}
return false
}
func writeRPC(w http.ResponseWriter, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_ = json.NewEncoder(w).Encode(v)
}
+457
View File
@@ -0,0 +1,457 @@
package mcp
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"testing"
"time"
"git.k6n.net/mats/go-cart-actor/pkg/actor"
"git.k6n.net/mats/go-cart-actor/pkg/order"
messages "git.k6n.net/mats/go-cart-actor/proto/order"
"google.golang.org/protobuf/proto"
)
// testApplier implements OrderApplier with an in-memory map of orders, using
// the real order mutation registry so write tools exercise the actual handlers.
type testApplier struct {
grains map[uint64]*order.OrderGrain
reg actor.MutationRegistry
}
func newTestApplier() *testApplier {
reg := actor.NewMutationRegistry()
order.RegisterMutations(reg)
return &testApplier{
grains: make(map[uint64]*order.OrderGrain),
reg: reg,
}
}
// addPlacedOrder creates a fully placed order with n line items, returning its
// base62 id string.
func (a *testApplier) addPlacedOrder(id uint64, items int) string {
g := order.NewOrderGrain(id, time.Now())
lines := make([]*messages.OrderLine, items)
for i := 0; i < items; i++ {
lines[i] = &messages.OrderLine{
Reference: fmt.Sprintf("line-%d", i+1),
Sku: fmt.Sprintf("SKU-%03d", i+1),
Name: fmt.Sprintf("Item %d", i+1),
Quantity: int32(i + 1),
UnitPrice: int64((i + 1) * 10000),
TaxRate: 2500,
}
}
// Place the order through the real handler.
po := &messages.PlaceOrder{
OrderReference: "ord-" + order.OrderId(id).String(),
CartId: "cart-abc",
Currency: "SEK",
Locale: "sv-se",
Country: "se",
CustomerEmail: "test@example.com",
TotalAmount: 60000,
TotalTax: 12000,
PlacedAtMs: time.Now().UnixMilli(),
Lines: lines,
}
results, err := a.reg.Apply(context.Background(), g, po)
if err != nil || len(results) == 0 || results[0].Error != nil {
panic(fmt.Sprintf("place order: %v %v", err, results))
}
a.grains[id] = g
return order.OrderId(id).String()
}
func (a *testApplier) Get(_ context.Context, id uint64) (*order.OrderGrain, error) {
g, ok := a.grains[id]
if !ok {
return nil, fmt.Errorf("order %d not found", id)
}
return g, nil
}
func (a *testApplier) Apply(ctx context.Context, id uint64, msgs ...proto.Message) (*actor.MutationResult[order.OrderGrain], error) {
g, ok := a.grains[id]
if !ok {
return nil, fmt.Errorf("order %d not found", id)
}
results, err := a.reg.Apply(ctx, g, msgs...)
if err != nil {
return nil, err
}
state, err := g.GetCurrentState()
if err != nil {
return nil, err
}
return &actor.MutationResult[order.OrderGrain]{
Result: *state, //nolint:govet // test helper snapshot, same pattern as SimpleGrainPool
Mutations: results,
}, nil
}
// call issues a tools/call against the handler and returns the decoded text
// payload (the JSON the tool returned), failing on a tool/transport error.
func call(t *testing.T, h http.Handler, name string, args map[string]any) map[string]any {
t.Helper()
body, _ := json.Marshal(map[string]any{
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": map[string]any{"name": name, "arguments": args},
})
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/mcp", bytes.NewReader(body))
h.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("%s: status %d", name, rec.Code)
}
var resp struct {
Result struct {
IsError bool `json:"isError"`
Content []struct {
Text string `json:"text"`
} `json:"content"`
} `json:"result"`
Error *struct {
Message string `json:"message"`
} `json:"error"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("%s: decode response: %v (%s)", name, err, rec.Body.String())
}
if resp.Error != nil {
t.Fatalf("%s: rpc error: %s", name, resp.Error.Message)
}
if resp.Result.IsError {
t.Fatalf("%s: tool error: %s", name, resp.Result.Content[0].Text)
}
var out map[string]any
if len(resp.Result.Content) > 0 {
_ = json.Unmarshal([]byte(resp.Result.Content[0].Text), &out)
}
return out
}
// callWithRaw returns the raw result (including isError) without failing.
type rawResult struct {
IsError bool
Content []struct {
Text string `json:"text"`
}
}
func callWithRaw(t *testing.T, h http.Handler, name string, args map[string]any) rawResult {
t.Helper()
body, _ := json.Marshal(map[string]any{
"jsonrpc": "2.0", "id": 1, "method": "tools/call",
"params": map[string]any{"name": name, "arguments": args},
})
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/mcp", bytes.NewReader(body))
h.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("%s: status %d", name, rec.Code)
}
var resp struct {
Result rawResult `json:"result"`
Error *struct{ Message string } `json:"error"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("%s: decode: %v", name, err)
}
if resp.Error != nil {
t.Fatalf("%s: rpc error: %s", name, resp.Error.Message)
}
return resp.Result
}
func TestInitializeAndToolsList(t *testing.T) {
a := newTestApplier()
h := New(a).Handler()
// Initialize.
body, _ := json.Marshal(map[string]any{
"jsonrpc": "2.0", "id": 1, "method": "initialize",
"params": map[string]any{"protocolVersion": "2025-06-18"},
})
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/mcp", bytes.NewReader(body))
h.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("initialize: status %d", rec.Code)
}
var initResp struct {
Result struct {
ProtocolVersion string `json:"protocolVersion"`
ServerInfo struct {
Name string `json:"name"`
} `json:"serverInfo"`
} `json:"result"`
}
json.Unmarshal(rec.Body.Bytes(), &initResp)
if initResp.Result.ProtocolVersion != "2025-06-18" || initResp.Result.ServerInfo.Name != "orders" {
t.Fatalf("unexpected initialize: %+v", initResp)
}
// tools/list.
body2, _ := json.Marshal(map[string]any{
"jsonrpc": "2.0", "id": 1, "method": "tools/list",
})
rec2 := httptest.NewRecorder()
req2 := httptest.NewRequest(http.MethodPost, "/mcp", bytes.NewReader(body2))
h.ServeHTTP(rec2, req2)
var listResp struct {
Result struct {
Tools []struct {
Name string `json:"name"`
InputSchema json.RawMessage `json:"inputSchema"`
} `json:"tools"`
} `json:"result"`
}
json.Unmarshal(rec2.Body.Bytes(), &listResp)
want := map[string]bool{
"get_order": false, "get_order_status": false,
"cancel_order": false, "complete_order": false,
"create_fulfillment": false, "request_return": false,
"issue_refund": false,
}
for _, tl := range listResp.Result.Tools {
if _, ok := want[tl.Name]; ok {
want[tl.Name] = true
}
if len(tl.InputSchema) == 0 {
t.Fatalf("tool %s has empty inputSchema", tl.Name)
}
}
for name, found := range want {
if !found {
t.Fatalf("expected tool %q in tools/list", name)
}
}
}
func TestGetOrder(t *testing.T) {
a := newTestApplier()
oid := a.addPlacedOrder(42, 2)
h := New(a).Handler()
got := call(t, h, "get_order", map[string]any{"orderId": oid})
if got == nil {
t.Fatal("get_order returned nil")
}
if ref, _ := got["orderReference"].(string); ref != "ord-"+oid {
t.Errorf("orderReference = %q, want ord-%s", ref, oid)
}
if curr, _ := got["currency"].(string); curr != "SEK" {
t.Errorf("currency = %q, want SEK", curr)
}
if email, _ := got["customerEmail"].(string); email != "test@example.com" {
t.Errorf("customerEmail = %q, want test@example.com", email)
}
}
func TestGetOrderStatus(t *testing.T) {
a := newTestApplier()
oid := a.addPlacedOrder(1, 1)
h := New(a).Handler()
got := call(t, h, "get_order_status", map[string]any{"orderId": oid})
if s, _ := got["status"].(string); s != "pending" {
t.Errorf("status = %q, want pending", s)
}
transitions, _ := got["nextTransitions"].([]any)
if len(transitions) == 0 {
t.Fatal("expected non-empty nextTransitions")
}
}
func TestGetOrderLines(t *testing.T) {
a := newTestApplier()
oid := a.addPlacedOrder(1, 3)
h := New(a).Handler()
got := call(t, h, "get_order_lines", map[string]any{"orderId": oid})
if c, _ := got["count"].(float64); c != 3 {
t.Errorf("lines count = %v, want 3", got["count"])
}
}
func TestGetOrderPayments(t *testing.T) {
a := newTestApplier()
oid := a.addPlacedOrder(1, 1)
// Simulate an authorized + captured payment.
g, _ := a.Get(context.Background(), 1)
g.Status = order.StatusAuthorized
g.Payments = append(g.Payments, &order.Payment{
Provider: "mock",
Authorized: 60000,
AuthRef: "auth-123",
})
h := New(a).Handler()
got := call(t, h, "get_order_payments", map[string]any{"orderId": oid})
if got == nil {
t.Fatal("get_order_payments returned nil")
}
payments, _ := got["payments"].([]any)
if len(payments) != 1 {
t.Fatalf("want 1 payment, got %d", len(payments))
}
p := payments[0].(map[string]any)
if prov, _ := p["provider"].(string); prov != "mock" {
t.Errorf("payment provider = %q, want mock", prov)
}
}
func TestCancelOrder(t *testing.T) {
a := newTestApplier()
oid := a.addPlacedOrder(1, 1)
h := New(a).Handler()
// Cancel pending order.
got := call(t, h, "cancel_order", map[string]any{"orderId": oid, "reason": "test cancellation"})
if got == nil {
t.Fatal("cancel_order returned nil")
}
// Verify via get_order.
after := call(t, h, "get_order", map[string]any{"orderId": oid})
if s, _ := after["status"].(string); s != "cancelled" {
t.Errorf("after cancel: status = %q, want cancelled", s)
}
}
func TestCreateFulfillment(t *testing.T) {
a := newTestApplier()
oid := a.addPlacedOrder(1, 1)
// Advance to captured state.
h := New(a).Handler()
g, _ := a.Get(context.Background(), 1)
g.Status = order.StatusCaptured
g.CapturedAmount = 60000
// Fulfill the only line.
got := call(t, h, "create_fulfillment", map[string]any{
"orderId": oid,
"carrier": "PostNord",
"trackingNumber": "TN123456",
"lines": []any{
map[string]any{"reference": "line-1", "quantity": 1},
},
})
if got == nil {
t.Fatal("create_fulfillment returned nil")
}
after := call(t, h, "get_order", map[string]any{"orderId": oid})
if s, _ := after["status"].(string); s != "fulfilled" {
t.Errorf("after fulfill: status = %q, want fulfilled", s)
}
}
func TestCompleteOrder(t *testing.T) {
a := newTestApplier()
oid := a.addPlacedOrder(1, 1)
// Advance to fulfilled state.
g, _ := a.Get(context.Background(), 1)
g.Status = order.StatusFulfilled
h := New(a).Handler()
got := call(t, h, "complete_order", map[string]any{"orderId": oid})
if got == nil {
t.Fatal("complete_order returned nil")
}
after := call(t, h, "get_order", map[string]any{"orderId": oid})
if s, _ := after["status"].(string); s != "completed" {
t.Errorf("after complete: status = %q, want completed", s)
}
}
func TestRequestReturn(t *testing.T) {
a := newTestApplier()
oid := a.addPlacedOrder(1, 2)
// Advance to fulfilled state.
g, _ := a.Get(context.Background(), 1)
g.Status = order.StatusFulfilled
h := New(a).Handler()
got := call(t, h, "request_return", map[string]any{
"orderId": oid,
"reason": "defective item",
"lines": []any{
map[string]any{"reference": "line-1", "quantity": 1},
},
})
if got == nil {
t.Fatal("request_return returned nil")
}
after := call(t, h, "get_order", map[string]any{"orderId": oid})
returns, _ := after["returns"].([]any)
if len(returns) != 1 {
t.Errorf("after return: want 1 return, got %d", len(returns))
}
}
func TestIssueRefund(t *testing.T) {
a := newTestApplier()
oid := a.addPlacedOrder(1, 1)
// Advance to captured state.
g, _ := a.Get(context.Background(), 1)
g.Status = order.StatusCaptured
g.CapturedAmount = 60000
h := New(a).Handler()
got := call(t, h, "issue_refund", map[string]any{"orderId": oid, "amount": 30000})
if got == nil {
t.Fatal("issue_refund returned nil")
}
after := call(t, h, "get_order", map[string]any{"orderId": oid})
if r, _ := after["refundedAmount"].(float64); r != 30000 {
t.Errorf("refundedAmount = %v, want 30000", r)
}
}
func TestOrderMCPErrors(t *testing.T) {
a := newTestApplier()
oid := a.addPlacedOrder(1, 1)
h := New(a).Handler()
// Invalid order id -> isError.
result := callWithRaw(t, h, "get_order", map[string]any{"orderId": "!!!"})
if !result.IsError {
t.Fatal("expected isError for invalid order id")
}
// Non-existent order -> isError.
result2 := callWithRaw(t, h, "get_order", map[string]any{"orderId": "zzzzzz"})
if !result2.IsError {
t.Fatal("expected isError for non-existent order")
}
// Cancel wrong state (already cancelled) -> isError.
call(t, h, "cancel_order", map[string]any{"orderId": oid, "reason": "cancel"})
result3 := callWithRaw(t, h, "cancel_order", map[string]any{"orderId": oid, "reason": "double cancel"})
if !result3.IsError {
t.Fatal("expected isError for cancelling already-cancelled order")
}
// Unknown tool -> protocol error.
var resp struct {
Error *struct{ Message string } `json:"error"`
}
body, _ := json.Marshal(map[string]any{
"jsonrpc": "2.0", "id": 1, "method": "tools/call",
"params": map[string]any{"name": "nope", "arguments": map[string]any{}},
})
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/mcp", bytes.NewReader(body))
h.ServeHTTP(rec, req)
json.Unmarshal(rec.Body.Bytes(), &resp)
if resp.Error == nil {
t.Fatal("expected protocol error for unknown tool")
}
}
+519
View File
@@ -0,0 +1,519 @@
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
}