// Package mcp is the MCP edge for the cart's promotion engine: a Model Context // Protocol server exposing the promotion Store and evaluator as tools so an // agent can list, create, update, status-toggle and delete the promotions the // cart actually applies (e.g. Volymrabatt), plus preview the discount a cart // total would receive. // // 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 mutate the // shared promotions.Store and persist to data/promotions.json; the cart's // mutation processor reads a fresh Store snapshot on every recompute, so edits // take effect on the next cart change without a restart. package mcp import ( "encoding/json" "io" "net/http" "git.k6n.net/mats/go-cart-actor/pkg/promotions" ) const ( serverName = "cart-promotions" serverVersion = "0.1.0" protocolVersion = "2025-06-18" ) // Server is the MCP edge over the shared promotion Store and evaluator. type Server struct { store *promotions.Store eval *promotions.PromotionService tools []tool } // New builds an MCP server exposing the promotion store as tools. func New(store *promotions.Store, eval *promotions.PromotionService) *Server { if eval == nil { eval = promotions.NewPromotionService(nil) } s := &Server{store: store, eval: eval} 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}, } } // defaultVatRate returns the default VAT rate from the eval service's // configured tax provider, falling back to 25 if none is set. func (s *Server) defaultVatRate() float32 { if s.eval == nil || s.eval.DefaultTaxProvider == nil { return 25 } return float32(s.eval.DefaultTaxProvider.DefaultTaxRate("")) } 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) }