diff --git a/cmd/checkout/cart-client.go b/cmd/checkout/cart-client.go index 49439a7..37442ae 100644 --- a/cmd/checkout/cart-client.go +++ b/cmd/checkout/cart-client.go @@ -6,20 +6,27 @@ import ( "fmt" "log" "net/http" + "os" "time" "git.k6n.net/mats/go-cart-actor/pkg/cart" ) type CartClient struct { - httpClient *http.Client - baseUrl string + httpClient *http.Client + baseUrl string + agentProfile string // UCP-Agent profile URI } func NewCartClient(baseUrl string) *CartClient { + profile := os.Getenv("UCP_AGENT_PROFILE") + if profile == "" { + profile = "https://checkout.k6n.net/.well-known/ucp" + } return &CartClient{ - httpClient: &http.Client{Timeout: 10 * time.Second}, - baseUrl: baseUrl, + httpClient: &http.Client{Timeout: 10 * time.Second}, + baseUrl: baseUrl, + agentProfile: profile, } } @@ -52,6 +59,7 @@ func (s *CartClient) getCartGrain(ctx context.Context, cartId cart.CartId) (*car if err != nil { return nil, err } + req.Header.Set("UCP-Agent", `profile="`+s.agentProfile+`"`) resp, err := s.httpClient.Do(req) if err != nil { return nil, err diff --git a/cmd/checkout/order_client.go b/cmd/checkout/order_client.go index c443897..b8accfc 100644 --- a/cmd/checkout/order_client.go +++ b/cmd/checkout/order_client.go @@ -6,6 +6,7 @@ import ( "encoding/json" "fmt" "net/http" + "os" "time" ) @@ -16,15 +17,21 @@ import ( // Currently only CreateOrder is implemented (the from-checkout handoff). // Add GetOrder/ListOrders methods as the backoffice order UI evolves. type OrderClient struct { - httpClient *http.Client - baseURL string // e.g. "http://order-service:8092" + httpClient *http.Client + baseURL string // e.g. "http://order-service:8092" + agentProfile string // UCP-Agent profile URI } // NewOrderClient returns a client that talks to the order service at baseURL. func NewOrderClient(baseURL string) *OrderClient { + profile := os.Getenv("UCP_AGENT_PROFILE") + if profile == "" { + profile = "https://cart.k6n.net/.well-known/ucp" + } return &OrderClient{ - httpClient: &http.Client{Timeout: 30 * time.Second}, - baseURL: baseURL, + httpClient: &http.Client{Timeout: 30 * time.Second}, + baseURL: baseURL, + agentProfile: profile, } } @@ -91,6 +98,7 @@ func (c *OrderClient) CreateOrder(ctx context.Context, req CreateOrderReq, flowN return nil, fmt.Errorf("order client: new request: %w", err) } httpReq.Header.Set("Content-Type", "application/json") + httpReq.Header.Set("UCP-Agent", `profile="`+c.agentProfile+`"`) resp, err := c.httpClient.Do(httpReq) if err != nil { diff --git a/internal/ucp/agent.go b/internal/ucp/agent.go new file mode 100644 index 0000000..1da39d5 --- /dev/null +++ b/internal/ucp/agent.go @@ -0,0 +1,151 @@ +package ucp + +import ( + "context" + "net/http" + "strings" +) + +// --------------------------------------------------------------------------- +// UCP-Agent header parsing (RFC 8941 Dictionary Structured Field) +// --------------------------------------------------------------------------- + +// ucpAgentProfileKey is the context key for the UCP-Agent profile URI. +type ucpAgentProfileKey struct{} + +// UCPAgentProfileFromContext returns the UCP-Agent profile URI stored in the +// request context, or empty string if none was advertised. +func UCPAgentProfileFromContext(ctx context.Context) string { + v, _ := ctx.Value(ucpAgentProfileKey{}).(string) + return v +} + +// parseUCPAgentProfile parses a single "profile" member value from an RFC 8941 +// Dictionary Structured Field header. It only handles the string-value form: +// +// UCP-Agent: profile="https://agent.example/profiles/shopping-agent.json" +// +// Returns the profile URI (without surrounding quotes) or empty string. +func parseUCPAgentProfile(header string) string { + if header == "" { + return "" + } + + // Walk the dictionary entries looking for "profile". + // RFC 8941 §3.1.2: Dict = ((im-key | key) value) *(SP "," ((im-key | key) value)) + i := 0 + bs := []byte(header) + n := len(bs) + + for i < n { + // Skip whitespace. + for i < n && (bs[i] == ' ' || bs[i] == '\t') { + i++ + } + if i >= n { + break + } + + // Read key (im-key or key). Lowercase letters, digits, "_", "-", "*". + start := i + for i < n && isKeyChar(bs[i]) { + i++ + } + if i == start { + // No key found; skip to next comma or end. + for i < n && bs[i] != ',' { + i++ + } + i++ // skip comma + continue + } + key := string(bs[start:i]) + + // Check for "=" separator. RFC 8941 allows bare names (boolean true) + // when "=? " follows — but we only care about key=value entries. + if i < n && bs[i] == '=' { + i++ // skip '=' + + // Read the value. For our case it's a string (starts with '"'). + if i < n && bs[i] == '"' { + i++ // skip opening quote + valStart := i + for i < n && bs[i] != '"' { + if bs[i] == '\\' { + i++ // skip escape + } + i++ + } + val := string(bs[valStart:i]) + if i < n { + i++ // skip closing quote + } + if strings.EqualFold(key, "profile") { + return val + } + } + } + + // Skip to next comma or end. + for i < n && bs[i] != ',' { + i++ + } + i++ // skip comma + } + + return "" +} + +// isKeyChar reports whether b is valid in an RFC 8941 key (lc-ltr, DIGIT, "_", "-", "*"). +func isKeyChar(b byte) bool { + return (b >= 'a' && b <= 'z') || (b >= '0' && b <= '9') || b == '_' || b == '-' || b == '*' +} + +// WithUCPAgent is HTTP middleware that reads the UCP-Agent header from incoming +// requests, parses it per RFC 8941, and stores the profile URI in the request +// context so downstream handlers can inspect the calling platform's identity. +// +// Usage: +// +// mux.Handle("/ucp/v1/carts", ucp.WithUCPAgent(ucp.CartHandler(pool))) +func WithUCPAgent(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if profile := parseUCPAgentProfile(r.Header.Get("UCP-Agent")); profile != "" { + ctx := context.WithValue(r.Context(), ucpAgentProfileKey{}, profile) + r = r.WithContext(ctx) + } + next.ServeHTTP(w, r) + }) +} + +// DefaultUCPAgentProfile is the profile URI sent on outgoing requests from +// this commerce platform. Override via SetDefaultUCPAgentProfile or the +// UCP_AGENT_PROFILE environment variable at init time. +var DefaultUCPAgentProfile = "https://cart.k6n.net/.well-known/ucp" + +// SetDefaultUCPAgentProfile overrides the default profile URI used for outgoing +// UCP-Agent headers. +func SetDefaultUCPAgentProfile(profile string) { + if profile != "" { + DefaultUCPAgentProfile = profile + } +} + +// UCPAgentHeaderValue returns the RFC 8941 Dictionary value for the UCP-Agent +// header, encoding the given profile URI. +func UCPAgentHeaderValue(profile string) string { + if profile == "" { + profile = DefaultUCPAgentProfile + } + return `profile="` + profile + `"` +} + +// WithUCPAgentOnRequest adds the UCP-Agent header to an outgoing HTTP request. +// It sets the header to the default profile unless a custom profile is provided. +func WithUCPAgentOnRequest(req *http.Request, profile ...string) { + p := DefaultUCPAgentProfile + if len(profile) > 0 && profile[0] != "" { + p = profile[0] + } + req.Header.Set("UCP-Agent", `profile="`+p+`"`) +} diff --git a/internal/ucp/checkout.go b/internal/ucp/checkout.go index 02e298f..0e1a963 100644 --- a/internal/ucp/checkout.go +++ b/internal/ucp/checkout.go @@ -5,7 +5,9 @@ import ( "context" "encoding/json" "fmt" + "io" "net/http" + "os" "time" "git.k6n.net/mats/go-cart-actor/pkg/checkout" @@ -61,17 +63,52 @@ func (s *CheckoutServer) handleCreateCheckout(w http.ResponseWriter, r *http.Req return } - // Initialize checkout with cart reference. + // Fetch cart state from cart service + cartBaseURL := os.Getenv("CART_INTERNAL_URL") + if cartBaseURL == "" { + cartBaseURL = "http://cart:8080" + } + url := fmt.Sprintf("%s/cart/byid/%s", cartBaseURL, cartId.String()) + + client := &http.Client{Timeout: 10 * time.Second} + cartReq, err := http.NewRequestWithContext(r.Context(), "GET", url, nil) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to create cart request: "+err.Error()) + return + } + + profile := os.Getenv("UCP_AGENT_PROFILE") + if profile == "" { + profile = DefaultUCPAgentProfile + } + cartReq.Header.Set("UCP-Agent", `profile="`+profile+`"`) + + cartResp, err := client.Do(cartReq) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to fetch cart: "+err.Error()) + return + } + defer cartResp.Body.Close() + + if cartResp.StatusCode != http.StatusOK { + writeError(w, http.StatusBadRequest, fmt.Sprintf("cart service returned status: %d", cartResp.StatusCode)) + return + } + + cartStateBytes, err := io.ReadAll(cartResp.Body) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to read cart state: "+err.Error()) + return + } + + // Initialize checkout with cart reference and state. initMsg := &checkoutMessages.InitializeCheckout{ OrderId: "", CartId: uint64(cartId), - } - if req.Country != "" { - // Pass country through the cart state snapshot (minimal). - initMsg.CartState = &anypb.Any{ - TypeUrl: "request/country", - Value: []byte(req.Country), - } + CartState: &anypb.Any{ + TypeUrl: "type.googleapis.com/cart.CartGrain", + Value: cartStateBytes, + }, } if _, err := s.applier.Apply(r.Context(), uint64(checkoutId), initMsg); err != nil { @@ -290,14 +327,22 @@ func (s *CheckoutServer) handleCancelCheckout(w http.ResponseWriter, r *http.Req type OrderHTTPClient struct { baseURL string httpClient *http.Client + agentProfile string // UCP-Agent profile URI } // NewOrderHTTPClient returns an order applier that creates orders by calling -// the order service at baseURL (e.g. "http://order-service:8092"). +// the order service at baseURL (e.g. "http://order-service:8092"). It reads +// the UCP_AGENT_PROFILE env var for the profile URI, falling back to +// DefaultUCPAgentProfile from the ucp package. func NewOrderHTTPClient(baseURL string) *OrderHTTPClient { + profile := os.Getenv("UCP_AGENT_PROFILE") + if profile == "" { + profile = DefaultUCPAgentProfile + } return &OrderHTTPClient{ baseURL: baseURL, httpClient: &http.Client{Timeout: 30 * time.Second}, + agentProfile: profile, } } @@ -381,6 +426,8 @@ func (c *OrderHTTPClient) CreateOrder(ctx context.Context, checkoutID uint64, g return "", fmt.Errorf("order: new request: %w", err) } httpReq.Header.Set("Content-Type", "application/json") + // Advertise this platform's UCP profile on all outgoing requests. + WithUCPAgentOnRequest(httpReq, c.agentProfile) resp, err := c.httpClient.Do(httpReq) if err != nil { diff --git a/internal/ucp/handler.go b/internal/ucp/handler.go index 0a5e90a..75fc357 100644 --- a/internal/ucp/handler.go +++ b/internal/ucp/handler.go @@ -23,7 +23,7 @@ func OrderHandler(applier OrderReadApplier) http.Handler { mux.HandleFunc("POST /{id}/fulfillments", s.handleCreateFulfillment) mux.HandleFunc("POST /{id}/returns", s.handleRequestReturn) mux.HandleFunc("POST /{id}/refunds", s.handleIssueRefund) - return mux + return withUCPAgent(withJSONContentType(mux)) } // --------------------------------------------------------------------------- @@ -46,7 +46,7 @@ func CartHandler(applier CartApplier) http.Handler { mux.HandleFunc("GET /{id}", s.handleGetCart) mux.HandleFunc("PUT /{id}", s.handleUpdateCart) mux.HandleFunc("POST /{id}/cancel", s.handleCancelCart) - return mux + return withUCPAgent(withJSONContentType(mux)) } // --------------------------------------------------------------------------- @@ -70,7 +70,7 @@ func CheckoutHandler(applier CheckoutApplier, orderSvc ...OrderApplier) http.Han mux.HandleFunc("PUT /{id}", s.handleUpdateCheckout) mux.HandleFunc("POST /{id}/complete", s.handleCompleteCheckout) mux.HandleFunc("POST /{id}/cancel", s.handleCancelCheckout) - return mux + return withUCPAgent(withJSONContentType(mux)) } // --------------------------------------------------------------------------- @@ -113,5 +113,18 @@ func Handler(cartApplier CartApplier, opts Options) http.Handler { mux.HandleFunc("POST /checkout-sessions/{id}/cancel", checkoutSrv.handleCancelCheckout) } - return mux + return withUCPAgent(withJSONContentType(mux)) +} + +// withUCPAgent wraps a handler with UCP-Agent header parsing middleware. +func withUCPAgent(next http.Handler) http.Handler { + return WithUCPAgent(next) +} + +// withJSONContentType is a middleware that ensures all responses have Content-Type: application/json. +func withJSONContentType(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + next.ServeHTTP(w, r) + }) } diff --git a/pkg/cart/mcp/tools.go b/pkg/cart/mcp/tools.go index 879873c..9067ee3 100644 --- a/pkg/cart/mcp/tools.go +++ b/pkg/cart/mcp/tools.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "fmt" + "log" "git.k6n.net/mats/go-cart-actor/pkg/cart" messages "git.k6n.net/mats/go-cart-actor/proto/cart" @@ -26,6 +27,12 @@ func (s *Server) callTool(req *rpcRequest) *rpcResponse { if err := json.Unmarshal(req.Params, &p); err != nil { return errorResponse(req.ID, codeInvalidParams, "invalid params") } + + // Extract UCP meta from arguments (UCP-Agent profile advertisement). + // Per the UCP spec, meta is a sibling key inside arguments: + // "arguments": { "meta": { "ucp-agent": { "profile": "..." } }, ... } + args := extractUCPAgentMeta(&p.Arguments) + var t *tool for i := range s.tools { if s.tools[i].Name == p.Name { @@ -36,13 +43,48 @@ func (s *Server) callTool(req *rpcRequest) *rpcResponse { if t == nil { return errorResponse(req.ID, codeInvalidParams, "unknown tool: "+p.Name) } - out, err := t.invoke(p.Arguments) + out, err := t.invoke(args) if err != nil { return result(req.ID, toolError(err)) } return result(req.ID, toolText(out)) } +// extractUCPAgentMeta checks if the JSON arguments contain a "meta" key with +// a nested "ucp-agent.profile" field. If found, it logs the profile (for +// observability) and strips the "meta" key before returning the cleaned +// arguments to the tool handler, so the meta object does not leak into the +// tool's argument namespace. +func extractUCPAgentMeta(arguments *json.RawMessage) json.RawMessage { + if arguments == nil || len(*arguments) == 0 { + return *arguments + } + var obj map[string]json.RawMessage + if err := json.Unmarshal(*arguments, &obj); err != nil { + return *arguments + } + metaRaw, hasMeta := obj["meta"] + if !hasMeta { + return *arguments + } + // Try to extract the profile for observability. + var meta struct { + UCPAgent *struct { + Profile string `json:"profile"` + } `json:"ucp-agent"` + } + if err := json.Unmarshal(metaRaw, &meta); err == nil && meta.UCPAgent != nil && meta.UCPAgent.Profile != "" { + log.Printf("ucp-agent: profile=%s", meta.UCPAgent.Profile) + } + // Strip meta from arguments before passing to the tool handler. + delete(obj, "meta") + cleaned, err := json.Marshal(obj) + if err != nil { + return *arguments + } + return cleaned +} + func (s *Server) buildTools() []tool { return []tool{ { diff --git a/pkg/order/mcp/tools.go b/pkg/order/mcp/tools.go index 365fc50..4798ae7 100644 --- a/pkg/order/mcp/tools.go +++ b/pkg/order/mcp/tools.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "fmt" + "log" "time" "git.k6n.net/mats/go-cart-actor/pkg/order" @@ -27,6 +28,12 @@ func (s *Server) callTool(req *rpcRequest) *rpcResponse { if err := json.Unmarshal(req.Params, &p); err != nil { return errorResponse(req.ID, codeInvalidParams, "invalid params") } + + // Extract UCP meta from arguments (UCP-Agent profile advertisement). + // Per the UCP spec, meta is a sibling key inside arguments: + // "arguments": { "meta": { "ucp-agent": { "profile": "..." } }, ... } + args := extractUCPAgentMeta(&p.Arguments) + var t *tool for i := range s.tools { if s.tools[i].Name == p.Name { @@ -37,13 +44,48 @@ func (s *Server) callTool(req *rpcRequest) *rpcResponse { if t == nil { return errorResponse(req.ID, codeInvalidParams, "unknown tool: "+p.Name) } - out, err := t.invoke(p.Arguments) + out, err := t.invoke(args) if err != nil { return result(req.ID, toolError(err)) } return result(req.ID, toolText(out)) } +// extractUCPAgentMeta checks if the JSON arguments contain a "meta" key with +// a nested "ucp-agent.profile" field. If found, it logs the profile (for +// observability) and strips the "meta" key before returning the cleaned +// arguments to the tool handler, so the meta object does not leak into the +// tool's argument namespace. +func extractUCPAgentMeta(arguments *json.RawMessage) json.RawMessage { + if arguments == nil || len(*arguments) == 0 { + return *arguments + } + var obj map[string]json.RawMessage + if err := json.Unmarshal(*arguments, &obj); err != nil { + return *arguments + } + metaRaw, hasMeta := obj["meta"] + if !hasMeta { + return *arguments + } + // Try to extract the profile for observability. + var meta struct { + UCPAgent *struct { + Profile string `json:"profile"` + } `json:"ucp-agent"` + } + if err := json.Unmarshal(metaRaw, &meta); err == nil && meta.UCPAgent != nil && meta.UCPAgent.Profile != "" { + log.Printf("ucp-agent: profile=%s", meta.UCPAgent.Profile) + } + // Strip meta from arguments before passing to the tool handler. + delete(obj, "meta") + cleaned, err := json.Marshal(obj) + if err != nil { + return *arguments + } + return cleaned +} + func (s *Server) buildTools() []tool { return []tool{ {