160 lines
4.2 KiB
Go
160 lines
4.2 KiB
Go
// 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)
|
|
}
|