Files
go-cart-actor/pkg/promotions/mcp/mcp.go
T
mats 74dc252279
Build and Publish / BuildAndDeployArm64 (push) Failing after 6s
Build and Publish / BuildAndDeployAmd64 (push) Has been cancelled
mcp in cart
2026-06-17 21:43:38 +02:00

155 lines
4.0 KiB
Go

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