// 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) }