From 180a468e60d872753f10e751113823aa304c337c Mon Sep 17 00:00:00 2001 From: wydrox <79707825+wydrox@users.noreply.github.com> Date: Mon, 20 Apr 2026 18:55:59 +0200 Subject: [PATCH] Split large mcpserver tool files into focused modules Break tools_account_session_auth.go (740 lines) into tools_account.go (account tools + consent helpers) and tools_session_auth.go (session and auth tools plus shared mcpASA* helpers). Break tools_orders_reservation.go (844 lines) into tools_orders.go (orders tools + orders helpers) and tools_reservation.go (reservation tools + reservation helpers). Update server.go to call registerAccountTools, registerSessionAuthTools, registerOrdersTools, and registerReservationTools. No logic changes; all 30 MCP tools remain registered identically. --- internal/mcpserver/server.go | 6 +- ...count_session_auth.go => tools_account.go} | 225 +---------- internal/mcpserver/tools_orders.go | 363 ++++++++++++++++++ ...rs_reservation.go => tools_reservation.go} | 357 +---------------- internal/mcpserver/tools_session_auth.go | 239 ++++++++++++ 5 files changed, 611 insertions(+), 579 deletions(-) rename internal/mcpserver/{tools_account_session_auth.go => tools_account.go} (71%) create mode 100644 internal/mcpserver/tools_orders.go rename internal/mcpserver/{tools_orders_reservation.go => tools_reservation.go} (60%) create mode 100644 internal/mcpserver/tools_session_auth.go diff --git a/internal/mcpserver/server.go b/internal/mcpserver/server.go index 3f78180..05f0441 100644 --- a/internal/mcpserver/server.go +++ b/internal/mcpserver/server.go @@ -15,8 +15,10 @@ func New() *mcp.Server { }, nil) registerCartAndProductsTools(server) - registerOrdersAndReservationTools(server) - registerAccountSessionAuthTools(server) + registerOrdersTools(server) + registerReservationTools(server) + registerAccountTools(server) + registerSessionAuthTools(server) return server } diff --git a/internal/mcpserver/tools_account_session_auth.go b/internal/mcpserver/tools_account.go similarity index 71% rename from internal/mcpserver/tools_account_session_auth.go rename to internal/mcpserver/tools_account.go index 0e16140..aca86fd 100644 --- a/internal/mcpserver/tools_account_session_auth.go +++ b/internal/mcpserver/tools_account.go @@ -11,12 +11,11 @@ import ( "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/wydrox/martmart-cli/internal/httpclient" - "github.com/wydrox/martmart-cli/internal/login" "github.com/wydrox/martmart-cli/internal/session" ) -// registerAccountSessionAuthTools registers all account, session, and auth MCP tools. -func registerAccountSessionAuthTools(server *mcp.Server) { +// registerAccountTools registers all account-related MCP tools. +func registerAccountTools(server *mcp.Server) { mcp.AddTool(server, &mcp.Tool{ Name: "account_profile", Description: "Fetch account profile (GET /users/{id}).", @@ -66,26 +65,6 @@ func registerAccountSessionAuthTools(server *mcp.Server) { Name: "account_membership_points", Description: "Fetch membership points history (GET /users/{id}/membership/points).", }, toolAccountMembershipPoints) - - mcp.AddTool(server, &mcp.Tool{ - Name: "session_login", - Description: "Opens the provider page in the user's default Chromium-based browser app with temporary remote debugging, captures auth session data automatically, and saves the session.", - }, toolSessionLogin) - - mcp.AddTool(server, &mcp.Tool{ - Name: "session_show", - Description: "Current session with secrets redacted (same as CLI session show).", - }, toolSessionShow) - - mcp.AddTool(server, &mcp.Tool{ - Name: "session_from_curl", - Description: "Parse curl, ApplyFromCurl, Save (mirrors CLI session from-curl).", - }, toolSessionFromCurl) - - mcp.AddTool(server, &mcp.Tool{ - Name: "session_refresh_token", - Description: "POST /app/commerce/connect/token with refresh_token grant.", - }, toolAuthRefreshToken) } // accountAddressesListIn is the input type for the account_addresses_list tool. @@ -451,46 +430,6 @@ func mcpASANormalizeConsentType(v string) string { } } -// mcpASAGetStringAny returns the first non-empty string field by keys. -func mcpASAGetStringAny(m map[string]any, keys ...string) string { - if m == nil { - return "" - } - for _, k := range keys { - if v, ok := m[k]; ok { - if s, ok := mcpASAStringField(v); ok { - return s - } - } - } - return "" -} - -// mcpASAGetBoolAny returns first bool-like field by keys. -func mcpASAGetBoolAny(m map[string]any, keys ...string) (bool, bool) { - if m == nil { - return false, false - } - for _, k := range keys { - v, ok := m[k] - if !ok { - continue - } - switch t := v.(type) { - case bool: - return t, true - case string: - switch strings.ToLower(strings.TrimSpace(t)) { - case "true", "1", "yes": - return true, true - case "false", "0", "no": - return false, true - } - } - } - return false, false -} - // accountVouchersIn is the input type for the account_vouchers tool. type accountVouchersIn struct { UserID string `json:"user_id,omitempty"` @@ -578,163 +517,3 @@ func toolAccountMembershipPoints(_ context.Context, _ *mcp.CallToolRequest, in a return mcpCPWrapFriscoResult(result) } -// sessionShowIn is the (empty) input type for the session_show tool. -type sessionShowIn struct{} - -func toolSessionShow(_ context.Context, _ *mcp.CallToolRequest, _ sessionShowIn) (*mcp.CallToolResult, mcpCPFriscoToolOut, error) { - s, err := session.Load() - if err != nil { - return nil, mcpCPFriscoToolOut{}, err - } - return mcpCPWrapFriscoResult(session.RedactedCopy(s)) -} - -// sessionFromCurlIn is the input type for the session_from_curl tool. -type sessionFromCurlIn struct { - Curl string `json:"curl"` -} - -func toolSessionFromCurl(_ context.Context, _ *mcp.CallToolRequest, in sessionFromCurlIn) (*mcp.CallToolResult, mcpCPFriscoToolOut, error) { - if strings.TrimSpace(in.Curl) == "" { - return nil, mcpCPFriscoToolOut{}, errors.New("curl is required") - } - cd, err := session.ParseCurl(in.Curl) - if err != nil { - return nil, mcpCPFriscoToolOut{}, err - } - s, err := session.Load() - if err != nil { - return nil, mcpCPFriscoToolOut{}, err - } - session.ApplyFromCurl(s, cd) - if err := session.Save(s); err != nil { - return nil, mcpCPFriscoToolOut{}, err - } - return mcpCPWrapFriscoResult(map[string]any{ - "saved": true, - "base_url": s.BaseURL, - "user_id": s.UserID, - "token_saved": mcpASATokenSaved(s), - "headers_saved": mcpASAHeaderKeysSorted(s.Headers), - }) -} - -// authRefreshTokenIn is the input type for the session_refresh_token tool. -type authRefreshTokenIn struct { - RefreshToken string `json:"refresh_token,omitempty" jsonschema:"optional; else session refresh_token"` -} - -func toolAuthRefreshToken(_ context.Context, _ *mcp.CallToolRequest, in authRefreshTokenIn) (*mcp.CallToolResult, mcpCPFriscoToolOut, error) { - s, err := session.Load() - if err != nil { - return nil, mcpCPFriscoToolOut{}, err - } - rt := strings.TrimSpace(in.RefreshToken) - if rt == "" { - rt = session.RefreshTokenString(s) - } - if rt == "" { - return nil, mcpCPFriscoToolOut{}, errors.New("missing refresh_token (argument or session)") - } - payload := map[string]any{ - "grant_type": "refresh_token", - "refresh_token": rt, - } - result, err := httpclient.RequestJSON(s, "POST", "/app/commerce/connect/token", httpclient.RequestOpts{ - Data: payload, - DataFormat: httpclient.FormatForm, - }) - if err != nil { - return nil, mcpCPFriscoToolOut{}, err - } - if m, ok := result.(map[string]any); ok { - expiresIn := m["expires_in"] - if at, ok := mcpASAStringField(m["access_token"]); ok && at != "" { - s.Token = at - if s.Headers == nil { - s.Headers = map[string]string{} - } - s.Headers["Authorization"] = "Bearer " + at - } - if nr, ok := mcpASAStringField(m["refresh_token"]); ok && nr != "" { - s.RefreshToken = nr - } - if err := session.Save(s); err != nil { - return nil, mcpCPFriscoToolOut{}, err - } - return mcpCPWrapFriscoResult(map[string]any{ - "saved": true, - "token_saved": mcpASATokenSaved(s), - "refresh_token_saved": session.RefreshTokenString(s) != "", - "expires_in": expiresIn, - }) - } - return mcpCPWrapFriscoResult(map[string]any{ - "saved": false, - "token_saved": mcpASATokenSaved(s), - "refresh_token_saved": session.RefreshTokenString(s) != "", - "message": "Unexpected token endpoint payload shape; session not updated.", - }) -} - -// sessionLoginIn is the input type for the session_login tool. -type sessionLoginIn struct { - TimeoutSec *int `json:"timeout_sec,omitempty" jsonschema:"Login timeout in seconds; default 180"` -} - -func toolSessionLogin(ctx context.Context, _ *mcp.CallToolRequest, in sessionLoginIn) (*mcp.CallToolResult, mcpCPFriscoToolOut, error) { - timeout := 10 - if in.TimeoutSec != nil && *in.TimeoutSec > 0 { - timeout = *in.TimeoutSec - } - result, err := login.Run(ctx, login.Options{Provider: session.CurrentProvider(), TimeoutSec: timeout}) - if err != nil { - return nil, mcpCPFriscoToolOut{}, err - } - return mcpCPWrapFriscoResult(map[string]any{ - "saved": result.Saved, - "base_url": result.BaseURL, - "user_id": result.UserID, - "token_saved": result.TokenSaved, - "refresh_token_saved": result.RefreshTokenSaved, - "cookie_saved": result.CookieSaved, - }) -} - -// mcpASAStringField converts v to a trimmed string and reports whether it is non-empty. -func mcpASAStringField(v any) (string, bool) { - if v == nil { - return "", false - } - switch t := v.(type) { - case string: - return strings.TrimSpace(t), strings.TrimSpace(t) != "" - default: - s := strings.TrimSpace(fmt.Sprint(t)) - return s, s != "" - } -} - -// mcpASATokenSaved reports whether the session contains a non-empty access token. -func mcpASATokenSaved(s *session.Session) bool { - if s == nil || s.Token == nil { - return false - } - if str, ok := s.Token.(string); ok { - return str != "" - } - return true -} - -// mcpASAHeaderKeysSorted returns the header map keys in sorted order. -func mcpASAHeaderKeysSorted(h map[string]string) []string { - if len(h) == 0 { - return []string{} - } - keys := make([]string, 0, len(h)) - for k := range h { - keys = append(keys, k) - } - sort.Strings(keys) - return keys -} diff --git a/internal/mcpserver/tools_orders.go b/internal/mcpserver/tools_orders.go new file mode 100644 index 0000000..6300461 --- /dev/null +++ b/internal/mcpserver/tools_orders.go @@ -0,0 +1,363 @@ +package mcpserver + +import ( + "context" + "fmt" + "math" + "strings" + + "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/wydrox/martmart-cli/internal/httpclient" +) + +// registerOrdersTools registers all orders-related MCP tools. +func registerOrdersTools(server *mcp.Server) { + mcp.AddTool(server, &mcp.Tool{ + Name: "orders_list", + Description: "List user orders (GET /app/commerce/api/v1/users/{user}/orders). Mirrors martmart orders list CLI.", + }, orToolOrdersList) + + mcp.AddTool(server, &mcp.Tool{ + Name: "orders_details", + Description: "Order details (GET .../users/{user}/orders/{orderId}). Mirrors martmart orders get.", + }, orToolOrdersDetails) + + mcp.AddTool(server, &mcp.Tool{ + Name: "orders_delivery", + Description: "Order delivery details (GET .../users/{user}/orders/{orderId}/delivery). Mirrors martmart orders delivery.", + }, orToolOrdersDelivery) + + mcp.AddTool(server, &mcp.Tool{ + Name: "orders_payments", + Description: "Order payments details (GET .../users/{user}/orders/{orderId}/payments). Mirrors martmart orders payments.", + }, orToolOrdersPayments) +} + +// orOrdersListIn is the input type for the orders_list tool. +type orOrdersListIn struct { + UserID string `json:"user_id,omitempty" jsonschema:"Optional override, defaults to session user_id"` + PageIndex int `json:"page_index,omitempty" jsonschema:"1-based page index, default 1"` + PageSize int `json:"page_size,omitempty" jsonschema:"Page size, default 10"` + AllPages bool `json:"all_pages,omitempty" jsonschema:"Fetch every page until empty or cap"` + Raw bool `json:"raw,omitempty" jsonschema:"If true, only api_response (no compact summary)"` +} + +// orOrdersListOut is the output type for the orders_list tool. +type orOrdersListOut struct { + APIResponse map[string]any `json:"api_response" jsonschema:"Normalized Frisco API JSON payload"` + Summary map[string]any `json:"summary,omitempty"` + Orders []map[string]any `json:"orders,omitempty"` +} + +// orOrdersDetailsIn is the input type for the orders_details tool. +type orOrdersDetailsIn struct { + UserID string `json:"user_id,omitempty"` + OrderID string `json:"order_id" jsonschema:"Order id"` +} + +// orOrdersDetailsOut is the output type for the orders_details tool. +type orOrdersDetailsOut struct { + APIResponse map[string]any `json:"api_response"` +} + +// orOrdersDeliveryIn is the input type for the orders_delivery tool. +type orOrdersDeliveryIn struct { + UserID string `json:"user_id,omitempty"` + OrderID string `json:"order_id" jsonschema:"Order id"` +} + +// orOrdersDeliveryOut is the output type for the orders_delivery tool. +type orOrdersDeliveryOut struct { + APIResponse map[string]any `json:"api_response"` +} + +// orOrdersPaymentsIn is the input type for the orders_payments tool. +type orOrdersPaymentsIn struct { + UserID string `json:"user_id,omitempty"` + OrderID string `json:"order_id" jsonschema:"Order id"` +} + +// orOrdersPaymentsOut is the output type for the orders_payments tool. +type orOrdersPaymentsOut struct { + APIResponse map[string]any `json:"api_response"` +} + +func orToolOrdersList(_ context.Context, _ *mcp.CallToolRequest, in orOrdersListIn) (*mcp.CallToolResult, orOrdersListOut, error) { + s, uid, err := loadSessionAuth(in.UserID) + if err != nil { + return nil, orOrdersListOut{}, err + } + path := fmt.Sprintf("/app/commerce/api/v1/users/%s/orders", uid) + pageIndex := in.PageIndex + if pageIndex <= 0 { + pageIndex = 1 + } + pageSize := in.PageSize + if pageSize <= 0 { + pageSize = 10 + } + var result any + if in.AllPages { + var allItems []map[string]any + pi := pageIndex + for { + q := []string{ + fmt.Sprintf("pageIndex=%d", pi), + fmt.Sprintf("pageSize=%d", pageSize), + } + payload, err := httpclient.RequestJSON(s, "GET", path, httpclient.RequestOpts{Query: q}) + if err != nil { + return nil, orOrdersListOut{}, err + } + items := orExtractOrdersList(payload) + if len(items) == 0 { + break + } + allItems = append(allItems, items...) + if len(items) < pageSize { + break + } + pi++ + if pi-pageIndex > 100 { + break + } + } + result = allItems + } else { + q := []string{ + fmt.Sprintf("pageIndex=%d", pageIndex), + fmt.Sprintf("pageSize=%d", pageSize), + } + result, err = httpclient.RequestJSON(s, "GET", path, httpclient.RequestOpts{Query: q}) + if err != nil { + return nil, orOrdersListOut{}, err + } + } + out := orOrdersListOut{APIResponse: orNormalizeAPIResponse(result)} + if in.Raw { + return nil, out, nil + } + items := orExtractOrdersList(result) + var compact []map[string]any + for _, order := range items { + id := order["id"] + if id == nil { + id = order["orderId"] + } + st := order["status"] + if st == nil { + st = order["orderStatus"] + } + row := map[string]any{ + "id": id, + "status": st, + "createdAt": orExtractOrderDatetime(order), + } + if t := orExtractOrderTotal(order); t != nil { + row["totalPLN"] = math.Round(*t*100) / 100 + } else { + row["totalPLN"] = nil + } + compact = append(compact, row) + } + var totalVals []float64 + for _, x := range compact { + if v, ok := x["totalPLN"].(float64); ok { + totalVals = append(totalVals, v) + } + } + summary := map[string]any{"count": len(compact)} + if len(totalVals) > 0 { + var sum float64 + for _, v := range totalVals { + sum += v + } + summary["sumPLN"] = math.Round(sum*100) / 100 + summary["avgPLN"] = math.Round(sum/float64(len(totalVals))*100) / 100 + } else { + summary["sumPLN"] = nil + summary["avgPLN"] = nil + } + out.Summary = summary + out.Orders = compact + return nil, out, nil +} + +func orToolOrdersDetails(_ context.Context, _ *mcp.CallToolRequest, in orOrdersDetailsIn) (*mcp.CallToolResult, orOrdersDetailsOut, error) { + if strings.TrimSpace(in.OrderID) == "" { + return nil, orOrdersDetailsOut{}, fmt.Errorf("order_id is required") + } + s, uid, err := loadSessionAuth(in.UserID) + if err != nil { + return nil, orOrdersDetailsOut{}, err + } + path := fmt.Sprintf("/app/commerce/api/v1/users/%s/orders/%s", uid, in.OrderID) + result, err := httpclient.RequestJSON(s, "GET", path, httpclient.RequestOpts{}) + if err != nil { + return nil, orOrdersDetailsOut{}, err + } + return nil, orOrdersDetailsOut{APIResponse: orNormalizeAPIResponse(result)}, nil +} + +func orToolOrdersDelivery(_ context.Context, _ *mcp.CallToolRequest, in orOrdersDeliveryIn) (*mcp.CallToolResult, orOrdersDeliveryOut, error) { + if strings.TrimSpace(in.OrderID) == "" { + return nil, orOrdersDeliveryOut{}, fmt.Errorf("order_id is required") + } + s, uid, err := loadSessionAuth(in.UserID) + if err != nil { + return nil, orOrdersDeliveryOut{}, err + } + path := fmt.Sprintf("/app/commerce/api/v1/users/%s/orders/%s/delivery", uid, in.OrderID) + result, err := httpclient.RequestJSON(s, "GET", path, httpclient.RequestOpts{}) + if err != nil { + return nil, orOrdersDeliveryOut{}, err + } + return nil, orOrdersDeliveryOut{APIResponse: orNormalizeAPIResponse(result)}, nil +} + +func orToolOrdersPayments(_ context.Context, _ *mcp.CallToolRequest, in orOrdersPaymentsIn) (*mcp.CallToolResult, orOrdersPaymentsOut, error) { + if strings.TrimSpace(in.OrderID) == "" { + return nil, orOrdersPaymentsOut{}, fmt.Errorf("order_id is required") + } + s, uid, err := loadSessionAuth(in.UserID) + if err != nil { + return nil, orOrdersPaymentsOut{}, err + } + path := fmt.Sprintf("/app/commerce/api/v1/users/%s/orders/%s/payments", uid, in.OrderID) + result, err := httpclient.RequestJSON(s, "GET", path, httpclient.RequestOpts{}) + if err != nil { + return nil, orOrdersPaymentsOut{}, err + } + return nil, orOrdersPaymentsOut{APIResponse: orNormalizeAPIResponse(result)}, nil +} + +// --- Helpers for orders --- + +// orExtractOrdersList extracts an orders slice from various API response shapes. +func orExtractOrdersList(payload any) []map[string]any { + switch p := payload.(type) { + case []map[string]any: + return p + case []any: + var out []map[string]any + for _, x := range p { + if m, ok := x.(map[string]any); ok { + out = append(out, m) + } + } + return out + case map[string]any: + for _, key := range []string{"items", "orders", "results", "data"} { + if v, ok := p[key].([]any); ok { + var out []map[string]any + for _, x := range v { + if m, ok := x.(map[string]any); ok { + out = append(out, m) + } + } + return out + } + } + } + return nil +} + +// orExtractOrderDatetime returns the first non-empty date/time string found in an order map. +func orExtractOrderDatetime(order map[string]any) string { + for _, key := range []string{"createdAt", "created", "placedAt", "orderDate", "date"} { + if v, ok := order[key].(string); ok && v != "" { + return v + } + } + return "" +} + +// orExtractOrderTotal searches common pricing fields in an order map and returns +// the largest positive value found, or nil when no numeric total is present. +func orExtractOrderTotal(order map[string]any) *float64 { + var candidates []float64 + for _, key := range []string{"total", "totalValue", "amount", "grossValue", "orderValue", "finalPrice"} { + orAddNumber(order[key], &candidates) + if m, ok := order[key].(map[string]any); ok { + orAddNumber(m["_total"], &candidates) + } + } + for _, sectionKey := range []string{"pricing", "payment", "summary", "totals", "orderPricing"} { + section, ok := order[sectionKey].(map[string]any) + if !ok { + continue + } + for _, valueKey := range []string{ + "totalPayment", + "totalWithDeliveryCostAfterVoucherPayment", + "totalWithDeliveryCost", + "total", + } { + orAddNumber(section[valueKey], &candidates) + if m, ok := section[valueKey].(map[string]any); ok { + orAddNumber(m["_total"], &candidates) + } + } + } + if len(candidates) == 0 { + return nil + } + var positives []float64 + for _, x := range candidates { + if x > 0 { + positives = append(positives, x) + } + } + var best float64 + if len(positives) > 0 { + best = positives[0] + for _, x := range positives[1:] { + if x > best { + best = x + } + } + } else { + best = candidates[0] + for _, x := range candidates[1:] { + if x > best { + best = x + } + } + } + return &best +} + +// orAddNumber appends v to candidates if v is a numeric type. +func orAddNumber(v any, candidates *[]float64) { + switch n := v.(type) { + case float64: + *candidates = append(*candidates, n) + case int: + *candidates = append(*candidates, float64(n)) + case int64: + *candidates = append(*candidates, float64(n)) + } +} + +// orTruthy returns true when v is a bool with value true. +func orTruthy(v any) bool { + b, ok := v.(bool) + return ok && b +} + +// orNonEmptyStr converts v to a trimmed string and reports whether it is non-empty. +func orNonEmptyStr(v any) (string, bool) { + if v == nil { + return "", false + } + s := strings.TrimSpace(fmt.Sprint(v)) + return s, s != "" +} + +// orNormalizeAPIResponse keeps output schema stable as an object while preserving payload. +func orNormalizeAPIResponse(v any) map[string]any { + if m, ok := v.(map[string]any); ok { + return m + } + return map[string]any{"value": v} +} diff --git a/internal/mcpserver/tools_orders_reservation.go b/internal/mcpserver/tools_reservation.go similarity index 60% rename from internal/mcpserver/tools_orders_reservation.go rename to internal/mcpserver/tools_reservation.go index 0500836..049e41c 100644 --- a/internal/mcpserver/tools_orders_reservation.go +++ b/internal/mcpserver/tools_reservation.go @@ -3,7 +3,6 @@ package mcpserver import ( "context" "fmt" - "math" "net/url" "sort" "strconv" @@ -15,27 +14,8 @@ import ( "github.com/wydrox/martmart-cli/internal/session" ) -func registerOrdersAndReservationTools(server *mcp.Server) { - mcp.AddTool(server, &mcp.Tool{ - Name: "orders_list", - Description: "List user orders (GET /app/commerce/api/v1/users/{user}/orders). Mirrors martmart orders list CLI.", - }, orToolOrdersList) - - mcp.AddTool(server, &mcp.Tool{ - Name: "orders_details", - Description: "Order details (GET .../users/{user}/orders/{orderId}). Mirrors martmart orders get.", - }, orToolOrdersDetails) - - mcp.AddTool(server, &mcp.Tool{ - Name: "orders_delivery", - Description: "Order delivery details (GET .../users/{user}/orders/{orderId}/delivery). Mirrors martmart orders delivery.", - }, orToolOrdersDelivery) - - mcp.AddTool(server, &mcp.Tool{ - Name: "orders_payments", - Description: "Order payments details (GET .../users/{user}/orders/{orderId}/payments). Mirrors martmart orders payments.", - }, orToolOrdersPayments) - +// registerReservationTools registers all reservation-related MCP tools. +func registerReservationTools(server *mcp.Server) { mcp.AddTool(server, &mcp.Tool{ Name: "reservation_delivery_options", Description: "Delivery and payment options by postcode. Mirrors martmart reservation delivery-options.", @@ -67,57 +47,6 @@ func registerOrdersAndReservationTools(server *mcp.Server) { }, orToolReservationCancel) } -// --- Input / output types (orders + reservation, unique or* prefix) --- - -// orOrdersListIn is the input type for the orders_list tool. -type orOrdersListIn struct { - UserID string `json:"user_id,omitempty" jsonschema:"Optional override, defaults to session user_id"` - PageIndex int `json:"page_index,omitempty" jsonschema:"1-based page index, default 1"` - PageSize int `json:"page_size,omitempty" jsonschema:"Page size, default 10"` - AllPages bool `json:"all_pages,omitempty" jsonschema:"Fetch every page until empty or cap"` - Raw bool `json:"raw,omitempty" jsonschema:"If true, only api_response (no compact summary)"` -} - -// orOrdersListOut is the output type for the orders_list tool. -type orOrdersListOut struct { - APIResponse map[string]any `json:"api_response" jsonschema:"Normalized Frisco API JSON payload"` - Summary map[string]any `json:"summary,omitempty"` - Orders []map[string]any `json:"orders,omitempty"` -} - -// orOrdersDetailsIn is the input type for the orders_details tool. -type orOrdersDetailsIn struct { - UserID string `json:"user_id,omitempty"` - OrderID string `json:"order_id" jsonschema:"Order id"` -} - -// orOrdersDetailsOut is the output type for the orders_details tool. -type orOrdersDetailsOut struct { - APIResponse map[string]any `json:"api_response"` -} - -// orOrdersDeliveryIn is the input type for the orders_delivery tool. -type orOrdersDeliveryIn struct { - UserID string `json:"user_id,omitempty"` - OrderID string `json:"order_id" jsonschema:"Order id"` -} - -// orOrdersDeliveryOut is the output type for the orders_delivery tool. -type orOrdersDeliveryOut struct { - APIResponse map[string]any `json:"api_response"` -} - -// orOrdersPaymentsIn is the input type for the orders_payments tool. -type orOrdersPaymentsIn struct { - UserID string `json:"user_id,omitempty"` - OrderID string `json:"order_id" jsonschema:"Order id"` -} - -// orOrdersPaymentsOut is the output type for the orders_payments tool. -type orOrdersPaymentsOut struct { - APIResponse map[string]any `json:"api_response"` -} - // orReservationDeliveryOptionsIn is the input type for the reservation_delivery_options tool. type orReservationDeliveryOptionsIn struct { Postcode string `json:"postcode" jsonschema:"postcode, e.g. 00-001"` @@ -192,157 +121,6 @@ type orReservationPlanOut struct { APIResponse map[string]any `json:"api_response"` } -// --- Handlers --- - -func orToolOrdersList(_ context.Context, _ *mcp.CallToolRequest, in orOrdersListIn) (*mcp.CallToolResult, orOrdersListOut, error) { - s, uid, err := loadSessionAuth(in.UserID) - if err != nil { - return nil, orOrdersListOut{}, err - } - path := fmt.Sprintf("/app/commerce/api/v1/users/%s/orders", uid) - pageIndex := in.PageIndex - if pageIndex <= 0 { - pageIndex = 1 - } - pageSize := in.PageSize - if pageSize <= 0 { - pageSize = 10 - } - var result any - if in.AllPages { - var allItems []map[string]any - pi := pageIndex - for { - q := []string{ - fmt.Sprintf("pageIndex=%d", pi), - fmt.Sprintf("pageSize=%d", pageSize), - } - payload, err := httpclient.RequestJSON(s, "GET", path, httpclient.RequestOpts{Query: q}) - if err != nil { - return nil, orOrdersListOut{}, err - } - items := orExtractOrdersList(payload) - if len(items) == 0 { - break - } - allItems = append(allItems, items...) - if len(items) < pageSize { - break - } - pi++ - if pi-pageIndex > 100 { - break - } - } - result = allItems - } else { - q := []string{ - fmt.Sprintf("pageIndex=%d", pageIndex), - fmt.Sprintf("pageSize=%d", pageSize), - } - result, err = httpclient.RequestJSON(s, "GET", path, httpclient.RequestOpts{Query: q}) - if err != nil { - return nil, orOrdersListOut{}, err - } - } - out := orOrdersListOut{APIResponse: orNormalizeAPIResponse(result)} - if in.Raw { - return nil, out, nil - } - items := orExtractOrdersList(result) - var compact []map[string]any - for _, order := range items { - id := order["id"] - if id == nil { - id = order["orderId"] - } - st := order["status"] - if st == nil { - st = order["orderStatus"] - } - row := map[string]any{ - "id": id, - "status": st, - "createdAt": orExtractOrderDatetime(order), - } - if t := orExtractOrderTotal(order); t != nil { - row["totalPLN"] = math.Round(*t*100) / 100 - } else { - row["totalPLN"] = nil - } - compact = append(compact, row) - } - var totalVals []float64 - for _, x := range compact { - if v, ok := x["totalPLN"].(float64); ok { - totalVals = append(totalVals, v) - } - } - summary := map[string]any{"count": len(compact)} - if len(totalVals) > 0 { - var sum float64 - for _, v := range totalVals { - sum += v - } - summary["sumPLN"] = math.Round(sum*100) / 100 - summary["avgPLN"] = math.Round(sum/float64(len(totalVals))*100) / 100 - } else { - summary["sumPLN"] = nil - summary["avgPLN"] = nil - } - out.Summary = summary - out.Orders = compact - return nil, out, nil -} - -func orToolOrdersDetails(_ context.Context, _ *mcp.CallToolRequest, in orOrdersDetailsIn) (*mcp.CallToolResult, orOrdersDetailsOut, error) { - if strings.TrimSpace(in.OrderID) == "" { - return nil, orOrdersDetailsOut{}, fmt.Errorf("order_id is required") - } - s, uid, err := loadSessionAuth(in.UserID) - if err != nil { - return nil, orOrdersDetailsOut{}, err - } - path := fmt.Sprintf("/app/commerce/api/v1/users/%s/orders/%s", uid, in.OrderID) - result, err := httpclient.RequestJSON(s, "GET", path, httpclient.RequestOpts{}) - if err != nil { - return nil, orOrdersDetailsOut{}, err - } - return nil, orOrdersDetailsOut{APIResponse: orNormalizeAPIResponse(result)}, nil -} - -func orToolOrdersDelivery(_ context.Context, _ *mcp.CallToolRequest, in orOrdersDeliveryIn) (*mcp.CallToolResult, orOrdersDeliveryOut, error) { - if strings.TrimSpace(in.OrderID) == "" { - return nil, orOrdersDeliveryOut{}, fmt.Errorf("order_id is required") - } - s, uid, err := loadSessionAuth(in.UserID) - if err != nil { - return nil, orOrdersDeliveryOut{}, err - } - path := fmt.Sprintf("/app/commerce/api/v1/users/%s/orders/%s/delivery", uid, in.OrderID) - result, err := httpclient.RequestJSON(s, "GET", path, httpclient.RequestOpts{}) - if err != nil { - return nil, orOrdersDeliveryOut{}, err - } - return nil, orOrdersDeliveryOut{APIResponse: orNormalizeAPIResponse(result)}, nil -} - -func orToolOrdersPayments(_ context.Context, _ *mcp.CallToolRequest, in orOrdersPaymentsIn) (*mcp.CallToolResult, orOrdersPaymentsOut, error) { - if strings.TrimSpace(in.OrderID) == "" { - return nil, orOrdersPaymentsOut{}, fmt.Errorf("order_id is required") - } - s, uid, err := loadSessionAuth(in.UserID) - if err != nil { - return nil, orOrdersPaymentsOut{}, err - } - path := fmt.Sprintf("/app/commerce/api/v1/users/%s/orders/%s/payments", uid, in.OrderID) - result, err := httpclient.RequestJSON(s, "GET", path, httpclient.RequestOpts{}) - if err != nil { - return nil, orOrdersPaymentsOut{}, err - } - return nil, orOrdersPaymentsOut{APIResponse: orNormalizeAPIResponse(result)}, nil -} - func orToolReservationDeliveryOptions(_ context.Context, _ *mcp.CallToolRequest, in orReservationDeliveryOptionsIn) (*mcp.CallToolResult, orReservationDeliveryOptionsOut, error) { if strings.TrimSpace(in.Postcode) == "" { return nil, orReservationDeliveryOptionsOut{}, fmt.Errorf("postcode is required") @@ -554,136 +332,7 @@ func orToolReservationPlan(_ context.Context, _ *mcp.CallToolRequest, in orReser return nil, orReservationPlanOut{APIResponse: orNormalizeAPIResponse(result)}, nil } -// --- Helpers (logic aligned with internal/commands orders + reservation) --- - -// orExtractOrdersList extracts an orders slice from various API response shapes. -func orExtractOrdersList(payload any) []map[string]any { - switch p := payload.(type) { - case []map[string]any: - return p - case []any: - var out []map[string]any - for _, x := range p { - if m, ok := x.(map[string]any); ok { - out = append(out, m) - } - } - return out - case map[string]any: - for _, key := range []string{"items", "orders", "results", "data"} { - if v, ok := p[key].([]any); ok { - var out []map[string]any - for _, x := range v { - if m, ok := x.(map[string]any); ok { - out = append(out, m) - } - } - return out - } - } - } - return nil -} - -// orExtractOrderDatetime returns the first non-empty date/time string found in an order map. -func orExtractOrderDatetime(order map[string]any) string { - for _, key := range []string{"createdAt", "created", "placedAt", "orderDate", "date"} { - if v, ok := order[key].(string); ok && v != "" { - return v - } - } - return "" -} - -// orExtractOrderTotal searches common pricing fields in an order map and returns -// the largest positive value found, or nil when no numeric total is present. -func orExtractOrderTotal(order map[string]any) *float64 { - var candidates []float64 - for _, key := range []string{"total", "totalValue", "amount", "grossValue", "orderValue", "finalPrice"} { - orAddNumber(order[key], &candidates) - if m, ok := order[key].(map[string]any); ok { - orAddNumber(m["_total"], &candidates) - } - } - for _, sectionKey := range []string{"pricing", "payment", "summary", "totals", "orderPricing"} { - section, ok := order[sectionKey].(map[string]any) - if !ok { - continue - } - for _, valueKey := range []string{ - "totalPayment", - "totalWithDeliveryCostAfterVoucherPayment", - "totalWithDeliveryCost", - "total", - } { - orAddNumber(section[valueKey], &candidates) - if m, ok := section[valueKey].(map[string]any); ok { - orAddNumber(m["_total"], &candidates) - } - } - } - if len(candidates) == 0 { - return nil - } - var positives []float64 - for _, x := range candidates { - if x > 0 { - positives = append(positives, x) - } - } - var best float64 - if len(positives) > 0 { - best = positives[0] - for _, x := range positives[1:] { - if x > best { - best = x - } - } - } else { - best = candidates[0] - for _, x := range candidates[1:] { - if x > best { - best = x - } - } - } - return &best -} - -// orAddNumber appends v to candidates if v is a numeric type. -func orAddNumber(v any, candidates *[]float64) { - switch n := v.(type) { - case float64: - *candidates = append(*candidates, n) - case int: - *candidates = append(*candidates, float64(n)) - case int64: - *candidates = append(*candidates, float64(n)) - } -} - -// orTruthy returns true when v is a bool with value true. -func orTruthy(v any) bool { - b, ok := v.(bool) - return ok && b -} - -// orNonEmptyStr converts v to a trimmed string and reports whether it is non-empty. -func orNonEmptyStr(v any) (string, bool) { - if v == nil { - return "", false - } - s := strings.TrimSpace(fmt.Sprint(v)) - return s, s != "" -} - -// orNormalizeAPIResponse keeps output schema stable as an object while preserving payload. -func orNormalizeAPIResponse(v any) map[string]any { - if m, ok := v.(map[string]any); ok { - return m - } - return map[string]any{"value": v} -} +// --- Helpers for reservation --- // orGetShippingAddressFromAccount fetches the user's saved shipping addresses and // returns the preferred (default/current) one, falling back to the first entry. diff --git a/internal/mcpserver/tools_session_auth.go b/internal/mcpserver/tools_session_auth.go new file mode 100644 index 0000000..324e6fc --- /dev/null +++ b/internal/mcpserver/tools_session_auth.go @@ -0,0 +1,239 @@ +package mcpserver + +import ( + "context" + "errors" + "fmt" + "sort" + "strings" + + "github.com/modelcontextprotocol/go-sdk/mcp" + + "github.com/wydrox/martmart-cli/internal/httpclient" + "github.com/wydrox/martmart-cli/internal/login" + "github.com/wydrox/martmart-cli/internal/session" +) + +// registerSessionAuthTools registers all session and auth MCP tools. +func registerSessionAuthTools(server *mcp.Server) { + mcp.AddTool(server, &mcp.Tool{ + Name: "session_login", + Description: "Opens the provider page in the user's default Chromium-based browser app with temporary remote debugging, captures auth session data automatically, and saves the session.", + }, toolSessionLogin) + + mcp.AddTool(server, &mcp.Tool{ + Name: "session_show", + Description: "Current session with secrets redacted (same as CLI session show).", + }, toolSessionShow) + + mcp.AddTool(server, &mcp.Tool{ + Name: "session_from_curl", + Description: "Parse curl, ApplyFromCurl, Save (mirrors CLI session from-curl).", + }, toolSessionFromCurl) + + mcp.AddTool(server, &mcp.Tool{ + Name: "session_refresh_token", + Description: "POST /app/commerce/connect/token with refresh_token grant.", + }, toolAuthRefreshToken) +} + +// sessionShowIn is the (empty) input type for the session_show tool. +type sessionShowIn struct{} + +func toolSessionShow(_ context.Context, _ *mcp.CallToolRequest, _ sessionShowIn) (*mcp.CallToolResult, mcpCPFriscoToolOut, error) { + s, err := session.Load() + if err != nil { + return nil, mcpCPFriscoToolOut{}, err + } + return mcpCPWrapFriscoResult(session.RedactedCopy(s)) +} + +// sessionFromCurlIn is the input type for the session_from_curl tool. +type sessionFromCurlIn struct { + Curl string `json:"curl"` +} + +func toolSessionFromCurl(_ context.Context, _ *mcp.CallToolRequest, in sessionFromCurlIn) (*mcp.CallToolResult, mcpCPFriscoToolOut, error) { + if strings.TrimSpace(in.Curl) == "" { + return nil, mcpCPFriscoToolOut{}, errors.New("curl is required") + } + cd, err := session.ParseCurl(in.Curl) + if err != nil { + return nil, mcpCPFriscoToolOut{}, err + } + s, err := session.Load() + if err != nil { + return nil, mcpCPFriscoToolOut{}, err + } + session.ApplyFromCurl(s, cd) + if err := session.Save(s); err != nil { + return nil, mcpCPFriscoToolOut{}, err + } + return mcpCPWrapFriscoResult(map[string]any{ + "saved": true, + "base_url": s.BaseURL, + "user_id": s.UserID, + "token_saved": mcpASATokenSaved(s), + "headers_saved": mcpASAHeaderKeysSorted(s.Headers), + }) +} + +// authRefreshTokenIn is the input type for the session_refresh_token tool. +type authRefreshTokenIn struct { + RefreshToken string `json:"refresh_token,omitempty" jsonschema:"optional; else session refresh_token"` +} + +func toolAuthRefreshToken(_ context.Context, _ *mcp.CallToolRequest, in authRefreshTokenIn) (*mcp.CallToolResult, mcpCPFriscoToolOut, error) { + s, err := session.Load() + if err != nil { + return nil, mcpCPFriscoToolOut{}, err + } + rt := strings.TrimSpace(in.RefreshToken) + if rt == "" { + rt = session.RefreshTokenString(s) + } + if rt == "" { + return nil, mcpCPFriscoToolOut{}, errors.New("missing refresh_token (argument or session)") + } + payload := map[string]any{ + "grant_type": "refresh_token", + "refresh_token": rt, + } + result, err := httpclient.RequestJSON(s, "POST", "/app/commerce/connect/token", httpclient.RequestOpts{ + Data: payload, + DataFormat: httpclient.FormatForm, + }) + if err != nil { + return nil, mcpCPFriscoToolOut{}, err + } + if m, ok := result.(map[string]any); ok { + expiresIn := m["expires_in"] + if at, ok := mcpASAStringField(m["access_token"]); ok && at != "" { + s.Token = at + if s.Headers == nil { + s.Headers = map[string]string{} + } + s.Headers["Authorization"] = "Bearer " + at + } + if nr, ok := mcpASAStringField(m["refresh_token"]); ok && nr != "" { + s.RefreshToken = nr + } + if err := session.Save(s); err != nil { + return nil, mcpCPFriscoToolOut{}, err + } + return mcpCPWrapFriscoResult(map[string]any{ + "saved": true, + "token_saved": mcpASATokenSaved(s), + "refresh_token_saved": session.RefreshTokenString(s) != "", + "expires_in": expiresIn, + }) + } + return mcpCPWrapFriscoResult(map[string]any{ + "saved": false, + "token_saved": mcpASATokenSaved(s), + "refresh_token_saved": session.RefreshTokenString(s) != "", + "message": "Unexpected token endpoint payload shape; session not updated.", + }) +} + +// sessionLoginIn is the input type for the session_login tool. +type sessionLoginIn struct { + TimeoutSec *int `json:"timeout_sec,omitempty" jsonschema:"Login timeout in seconds; default 180"` +} + +func toolSessionLogin(ctx context.Context, _ *mcp.CallToolRequest, in sessionLoginIn) (*mcp.CallToolResult, mcpCPFriscoToolOut, error) { + timeout := 10 + if in.TimeoutSec != nil && *in.TimeoutSec > 0 { + timeout = *in.TimeoutSec + } + result, err := login.Run(ctx, login.Options{Provider: session.CurrentProvider(), TimeoutSec: timeout}) + if err != nil { + return nil, mcpCPFriscoToolOut{}, err + } + return mcpCPWrapFriscoResult(map[string]any{ + "saved": result.Saved, + "base_url": result.BaseURL, + "user_id": result.UserID, + "token_saved": result.TokenSaved, + "refresh_token_saved": result.RefreshTokenSaved, + "cookie_saved": result.CookieSaved, + }) +} + +// mcpASAStringField converts v to a trimmed string and reports whether it is non-empty. +func mcpASAStringField(v any) (string, bool) { + if v == nil { + return "", false + } + switch t := v.(type) { + case string: + return strings.TrimSpace(t), strings.TrimSpace(t) != "" + default: + s := strings.TrimSpace(fmt.Sprint(t)) + return s, s != "" + } +} + +// mcpASATokenSaved reports whether the session contains a non-empty access token. +func mcpASATokenSaved(s *session.Session) bool { + if s == nil || s.Token == nil { + return false + } + if str, ok := s.Token.(string); ok { + return str != "" + } + return true +} + +// mcpASAHeaderKeysSorted returns the header map keys in sorted order. +func mcpASAHeaderKeysSorted(h map[string]string) []string { + if len(h) == 0 { + return []string{} + } + keys := make([]string, 0, len(h)) + for k := range h { + keys = append(keys, k) + } + sort.Strings(keys) + return keys +} + +// mcpASAGetStringAny returns the first non-empty string field by keys. +func mcpASAGetStringAny(m map[string]any, keys ...string) string { + if m == nil { + return "" + } + for _, k := range keys { + if v, ok := m[k]; ok { + if s, ok := mcpASAStringField(v); ok { + return s + } + } + } + return "" +} + +// mcpASAGetBoolAny returns first bool-like field by keys. +func mcpASAGetBoolAny(m map[string]any, keys ...string) (bool, bool) { + if m == nil { + return false, false + } + for _, k := range keys { + v, ok := m[k] + if !ok { + continue + } + switch t := v.(type) { + case bool: + return t, true + case string: + switch strings.ToLower(strings.TrimSpace(t)) { + case "true", "1", "yes": + return true, true + case "false", "0", "no": + return false, true + } + } + } + return false, false +}