Phase 1 MCP Tool Enhancement - Implementation Guide
Phase 1 MCP Tool Enhancement - Implementation Guide
Section titled “Phase 1 MCP Tool Enhancement - Implementation Guide”This guide provides detailed implementation steps for Phase 1 quick-win MCP tools based on the golden prompts analysis.
Overview
Section titled “Overview”Phase 1 focuses on enhancements that leverage existing data and infrastructure:
- Enhanced Purchase History - Add store visit patterns
- Venue-Type Filtering - Add venue filtering to offers
- Points Balance - Integrate with points API
1. Enhanced Purchase History with Store Patterns
Section titled “1. Enhanced Purchase History with Store Patterns”Enable queries like “What store did I visit most in my last 20 trips?” and “Find offers for my usual Thursday night grocery shop”
Files to Modify
Section titled “Files to Modify”A. internal/models/tools/user_context.go
Section titled “A. internal/models/tools/user_context.go”Add StoreAnalytics to PurchaseHistoryResponse:
// After line 31, add new field:type PurchaseHistoryResponse struct { UserID string `json:"user_id"` Purchases []PurchaseHistoryItem `json:"purchases"` Count int `json:"count"` StoreAnalytics *StoreAnalytics `json:"store_analytics,omitempty"` // NEW GeneratedAt time.Time `json:"generated_at"`}
// Add new type at end of file:// StoreAnalytics provides aggregated store visit statisticstype StoreAnalytics struct { MostVisitedStore string `json:"most_visited_store"` StoreVisitCounts map[string]int `json:"store_visit_counts"` AverageBasketByStore map[string]float64 `json:"average_basket_by_store"` DayOfWeekPattern map[string]int `json:"day_of_week_pattern"` // "Monday": 5, "Thursday": 12 PeakShoppingDay string `json:"peak_shopping_day"` TotalTrips int `json:"total_trips"`}Add IncludeStoreAnalytics flag to Request:
// Modify PurchaseHistoryRequest (line 12):type PurchaseHistoryRequest struct { UserFilter TimeFilter PaginationParams IncludeStoreAnalytics bool `json:"include_store_analytics,omitempty"` // NEW}B. internal/graph/repository/tools_repository.go
Section titled “B. internal/graph/repository/tools_repository.go”Update GetUserPurchaseHistory (lines 42-138):
Add store analytics query after main purchase history query:
func (r *toolsRepository) GetUserPurchaseHistory(ctx context.Context, req *tools.PurchaseHistoryRequest) (*tools.PurchaseHistoryResponse, error) { start := time.Now() req.SetDefaults()
// ... existing purchase history query ...
response := &tools.PurchaseHistoryResponse{ UserID: req.UserID, Purchases: purchases, Count: len(purchases), GeneratedAt: time.Now(), }
// NEW: Add store analytics if requested if req.IncludeStoreAnalytics { storeAnalytics, err := r.getUserStoreAnalytics(ctx, req) if err != nil { logger.Error("failed to get store analytics", "user_id", req.UserID, "error", err.Error()) // Don't fail the whole request, just omit analytics } else { response.StoreAnalytics = storeAnalytics } }
logger.Info("✅ query completed", "tool", "get_user_purchase_history", "count", len(purchases), "duration", time.Since(start)) return response, nil}
// NEW: Add helper methodfunc (r *toolsRepository) getUserStoreAnalytics(ctx context.Context, req *tools.PurchaseHistoryRequest) (*tools.StoreAnalytics, error) { query := ` MATCH (u:User {user_id: $user_id})-[pa:PURCHASED_AT]->(r:Retailer) WHERE pa.last >= datetime() - duration({days: $lookback_days}) WITH r, pa ORDER BY pa.last DESC WITH r.name AS store_name, COUNT(pa) AS visit_count, AVG(pa.total_spent) AS avg_basket, COLLECT(pa.last) AS visit_dates WITH COLLECT({ store: store_name, visits: visit_count, avg_basket: avg_basket, dates: visit_dates }) AS store_data, SUM(visit_count) AS total_trips UNWIND store_data AS store WITH store_data, total_trips, REDUCE(s = NULL, x IN store_data | CASE WHEN s IS NULL OR x.visits > s.visits THEN x ELSE s END ) AS most_visited RETURN store_data, total_trips, most_visited.store AS most_visited_store `
params := map[string]interface{}{ "user_id": req.UserID, "lookback_days": req.LookbackDays, }
result, err := r.client.ExecuteRead(ctx, func(tx neo4j.ManagedTransaction) (interface{}, error) { queryResult, err := tx.Run(ctx, query, params) if err != nil { return nil, err }
if !queryResult.Next(ctx) { return &tools.StoreAnalytics{}, nil }
record := queryResult.Record() storeDataList, _ := record.Get("store_data") totalTrips, _ := record.Get("total_trips") mostVisitedStore, _ := record.Get("most_visited_store")
analytics := &tools.StoreAnalytics{ MostVisitedStore: getString(map[string]interface{}{"value": mostVisitedStore}, "value"), StoreVisitCounts: make(map[string]int), AverageBasketByStore: make(map[string]float64), DayOfWeekPattern: make(map[string]int), TotalTrips: int(getInt64(map[string]interface{}{"value": totalTrips}, "value")), }
// Parse store data if stores, ok := storeDataList.([]interface{}); ok { for _, store := range stores { if storeMap, ok := store.(map[string]interface{}); ok { storeName := getString(storeMap, "store") visitCount := getInt(storeMap, "visits") avgBasket := getFloat64(storeMap, "avg_basket")
analytics.StoreVisitCounts[storeName] = visitCount analytics.AverageBasketByStore[storeName] = avgBasket
// Parse visit dates for day-of-week pattern if dates, ok := storeMap["dates"].([]interface{}); ok { for _, dateVal := range dates { if t, ok := dateVal.(time.Time); ok { dayName := t.Weekday().String() analytics.DayOfWeekPattern[dayName]++ } } } } } }
// Find peak shopping day maxCount := 0 for day, count := range analytics.DayOfWeekPattern { if count > maxCount { maxCount = count analytics.PeakShoppingDay = day } }
return analytics, nil })
if err != nil { return nil, err }
return result.(*tools.StoreAnalytics), nil}C. pkg/tools/user_purchase_history.go
Section titled “C. pkg/tools/user_purchase_history.go”Update handler to accept new parameter:
// Around line 30-40, add parameter extraction:includeAnalytics, _ := getBool(request, "include_store_analytics")
// Build request:req := &tools.PurchaseHistoryRequest{ UserFilter: tools.UserFilter{UserID: userID}, TimeFilter: tools.TimeFilter{LookbackDays: int(lookbackDays)}, PaginationParams: tools.PaginationParams{Limit: int(limit)}, IncludeStoreAnalytics: includeAnalytics, // NEW}Update tool schema:
// Update InputSchema properties to include new parameter:"include_store_analytics": map[string]any{ "type": "boolean", "description": "Include aggregated store visit analytics and shopping patterns",},D. Testing
Section titled “D. Testing”Create test in pkg/tools/integration_test/user_purchase_history_test.go:
func TestGetUserPurchaseHistory_WithStoreAnalytics(t *testing.T) { // ... setup ...
request := mcp.CallToolRequest{ Params: mcp.CallToolRequestParams{ Name: "get_user_purchase_history", Arguments: map[string]interface{}{ "user_id": testUserID, "include_store_analytics": true, }, }, }
result, err := handler(ctx, request, graphService) require.NoError(t, err)
var response tools.PurchaseHistoryResponse err = json.Unmarshal([]byte(result.Content[0].(mcp.TextContent).Text), &response) require.NoError(t, err)
// Verify store analytics require.NotNil(t, response.StoreAnalytics) assert.NotEmpty(t, response.StoreAnalytics.MostVisitedStore) assert.Greater(t, response.StoreAnalytics.TotalTrips, 0) assert.NotEmpty(t, response.StoreAnalytics.StoreVisitCounts) assert.NotEmpty(t, response.StoreAnalytics.DayOfWeekPattern)}2. Venue-Type Filtering for Offers
Section titled “2. Venue-Type Filtering for Offers”Enable queries like “I’m in the airport - what can I buy and earn points from?”
Files to Modify
Section titled “Files to Modify”A. internal/models/tools/common.go
Section titled “A. internal/models/tools/common.go”Add VenueType to OfferFilter:
// Around line 50-60, modify OfferFilter:type OfferFilter struct { MinPoints int `json:"min_points,omitempty"` SearchTerm string `json:"search_term,omitempty"` VenueType string `json:"venue_type,omitempty"` // NEW: "grocery", "convenience", "airport", "gas_station", "pharmacy"}B. internal/models/retailer.go
Section titled “B. internal/models/retailer.go”Add VenueType field:
type Retailer struct { RetailerID string `json:"retailer_id"` Name string `json:"name"` Address string `json:"address,omitempty"` City string `json:"city,omitempty"` State string `json:"state,omitempty"` Zip string `json:"zip,omitempty"` VenueType string `json:"venue_type,omitempty"` // NEW CreatedAt time.Time `json:"created_at"`}C. internal/graph/repository/tools_repository.go
Section titled “C. internal/graph/repository/tools_repository.go”Update GetActiveOffers query (lines 1173-1272):
// Modify query to include venue type filtering:query := ` MATCH (u:User {user_id: $user_id})<-[:ELIGIBLE_FOR]-(o:Offer) WHERE datetime($now) >= o.start AND datetime($now) <= o.end AND ($min_points IS NULL OR o.points >= $min_points) AND ( $search_term IS NULL OR toLower(o.title) CONTAINS toLower($search_term) OR toLower(o.description) CONTAINS toLower($search_term) ) OPTIONAL MATCH (o)-[:APPLIES_TO]->(p:Product) OPTIONAL MATCH (o)-[:AVAILABLE_AT]->(r:Retailer) WHERE $venue_type IS NULL OR r.venue_type = $venue_type // NEW WITH o, COLLECT(DISTINCT p.product_id) AS product_ids, COLLECT(DISTINCT r.name) AS retailer_names RETURN {...} AS offer ORDER BY o.points DESC LIMIT $limit`
// Add venue_type to parameters:params := map[string]interface{}{ "user_id": req.UserID, "now": time.Now(), "min_points": getIntOrNil(req.MinPoints), "search_term": getStringOrNil(req.SearchTerm), "venue_type": getStringOrNil(req.VenueType), // NEW "limit": req.Limit,}D. pkg/tools/active_offers.go
Section titled “D. pkg/tools/active_offers.go”Update handler to extract venue_type:
// Add parameter extraction:venueType, _ := getString(request, "venue_type")
// Add to request:req := &tools.ActiveOffersRequest{ UserFilter: tools.UserFilter{UserID: userID}, OfferFilter: tools.OfferFilter{ MinPoints: int(minPoints), SearchTerm: searchTerm, VenueType: venueType, // NEW }, PaginationParams: tools.PaginationParams{Limit: int(limit)},}Update tool schema:
"venue_type": map[string]any{ "type": "string", "description": "Filter offers by venue type (grocery, convenience, airport, gas_station, pharmacy, restaurant)", "enum": []string{"grocery", "convenience", "airport", "gas_station", "pharmacy", "restaurant"},},E. Database Migration
Section titled “E. Database Migration”Add venue_type to Retailer nodes:
// In internal/graph/seed/ - add venue type mapping logicMATCH (r:Retailer)SET r.venue_type = CASE WHEN r.name =~ '.*(Walmart|Target|Kroger|Safeway|Albertsons).*' THEN 'grocery' WHEN r.name =~ '.*(7-Eleven|Circle K).*' THEN 'convenience' WHEN r.name =~ '.*(CVS|Walgreens|Rite Aid).*' THEN 'pharmacy' WHEN r.name =~ '.*(Shell|BP|Exxon|Chevron).*' THEN 'gas_station' ELSE 'other'ENDF. Testing
Section titled “F. Testing”func TestGetActiveOffers_WithVenueTypeFilter(t *testing.T) { request := mcp.CallToolRequest{ Params: mcp.CallToolRequestParams{ Name: "get_active_offers", Arguments: map[string]interface{}{ "user_id": testUserID, "venue_type": "airport", }, }, }
result, err := handler(ctx, request, graphService) require.NoError(t, err)
var response tools.ActiveOffersResponse err = json.Unmarshal([]byte(result.Content[0].(mcp.TextContent).Text), &response) require.NoError(t, err)
// Verify all offers are from airport venues for _, offer := range response.Offers { assert.Contains(t, offer.RetailerNames, "Airport", "Expected airport retailer") }}3. Points Balance API Integration
Section titled “3. Points Balance API Integration”Enable queries like “get me to gift card” and “How many points have I earned in last 30 days?”
Implementation Steps
Section titled “Implementation Steps”A. Create Points Client Package
Section titled “A. Create Points Client Package”Create pkg/points/client.go:
package points
import ( "context" "encoding/json" "fmt" "net/http" "time")
// Client handles communication with the points servicetype Client struct { httpClient *http.Client baseURL string apiKey string}
// NewClient creates a new points API clientfunc NewClient(baseURL, apiKey string) *Client { return &Client{ httpClient: &http.Client{Timeout: 10 * time.Second}, baseURL: baseURL, apiKey: apiKey, }}
// BalanceResponse represents the user's points balancetype BalanceResponse struct { UserID string `json:"user_id"` CurrentBalance int `json:"current_balance"` PendingPoints int `json:"pending_points"` LifetimePoints int `json:"lifetime_points"` PointsLast30Days int `json:"points_last_30_days"` NextMilestone int `json:"next_milestone,omitempty"` PointsToNextMilestone int `json:"points_to_next_milestone,omitempty"` LastUpdated time.Time `json:"last_updated"`}
// GetBalance retrieves the user's current points balancefunc (c *Client) GetBalance(ctx context.Context, userID string) (*BalanceResponse, error) { url := fmt.Sprintf("%s/users/%s/balance", c.baseURL, userID)
req, err := http.NewRequestWithContext(ctx, "GET", url, nil) if err != nil { return nil, fmt.Errorf("failed to create request: %w", err) }
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", c.apiKey)) req.Header.Set("Content-Type", "application/json")
resp, err := c.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to execute request: %w", err) } defer resp.Body.Close()
if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode) }
var balance BalanceResponse if err := json.NewDecoder(resp.Body).Decode(&balance); err != nil { return nil, fmt.Errorf("failed to decode response: %w", err) }
return &balance, nil}
// RedemptionOption represents a reward the user can redeemtype RedemptionOption struct { RewardID string `json:"reward_id"` RewardType string `json:"reward_type"` // "gift_card", "sweepstakes", "charity" Brand string `json:"brand"` Amount string `json:"amount"` // "$25", "$50" PointsRequired int `json:"points_required"` Available bool `json:"available"` ImageURL string `json:"image_url,omitempty"`}
// RedemptionOptionsResponse represents available redemption optionstype RedemptionOptionsResponse struct { UserID string `json:"user_id"` Options []RedemptionOption `json:"options"` Count int `json:"count"`}
// GetRedemptionOptions retrieves available reward redemptionsfunc (c *Client) GetRedemptionOptions(ctx context.Context, userID string, maxPoints int) (*RedemptionOptionsResponse, error) { url := fmt.Sprintf("%s/users/%s/redemptions?max_points=%d", c.baseURL, userID, maxPoints)
req, err := http.NewRequestWithContext(ctx, "GET", url, nil) if err != nil { return nil, fmt.Errorf("failed to create request: %w", err) }
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", c.apiKey)) req.Header.Set("Content-Type", "application/json")
resp, err := c.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to execute request: %w", err) } defer resp.Body.Close()
if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode) }
var options RedemptionOptionsResponse if err := json.NewDecoder(resp.Body).Decode(&options); err != nil { return nil, fmt.Errorf("failed to decode response: %w", err) }
options.Count = len(options.Options) return &options, nil}B. Create MCP Tool
Section titled “B. Create MCP Tool”Create pkg/tools/points_balance.go:
package tools
import ( "context" "encoding/json" "fmt"
"github.com/mark3labs/mcp-go/mcp" "github.com/consumer-graph-mcp/pkg/points")
// NewGetPointsBalanceTool creates the get_points_balance toolfunc NewGetPointsBalanceTool(pointsClient *points.Client) *ToolDefinition { return &ToolDefinition{ Tool: mcp.Tool{ Name: "get_points_balance", Description: "Get the user's current Fetch Points balance, pending points, lifetime points, and recent earning activity", InputSchema: mcp.ToolInputSchema{ Type: "object", Properties: map[string]any{ "user_id": map[string]any{ "type": "string", "description": "User ID", }, }, Required: []string{"user_id"}, }, }, Handler: func(ctx context.Context, request mcp.CallToolRequest, graphService GraphService) (*mcp.CallToolResult, error) { userID, err := request.RequireString("user_id") if err != nil { return mcp.NewToolResultError(fmt.Sprintf("Missing user_id: %v", err)), nil }
balance, err := pointsClient.GetBalance(ctx, userID) if err != nil { return mcp.NewToolResultError(fmt.Sprintf("Failed to get points balance: %v", err)), nil }
jsonBytes, err := json.Marshal(balance) if err != nil { return mcp.NewToolResultError(fmt.Sprintf("Failed to marshal response: %v", err)), nil }
return &mcp.CallToolResult{ Content: []mcp.Content{ mcp.TextContent{ Type: "text", Text: string(jsonBytes), }, }, }, nil }, }}
// NewGetRedemptionOptionsTo creates the get_redemption_options toolfunc NewGetRedemptionOptionsTool(pointsClient *points.Client) *ToolDefinition { return &ToolDefinition{ Tool: mcp.Tool{ Name: "get_redemption_options", Description: "Get available reward redemption options (gift cards, sweepstakes) based on user's current points", InputSchema: mcp.ToolInputSchema{ Type: "object", Properties: map[string]any{ "user_id": map[string]any{ "type": "string", "description": "User ID", }, "max_points": map[string]any{ "type": "number", "description": "Maximum points to spend (optional, defaults to current balance)", }, }, Required: []string{"user_id"}, }, }, Handler: func(ctx context.Context, request mcp.CallToolRequest, graphService GraphService) (*mcp.CallToolResult, error) { userID, err := request.RequireString("user_id") if err != nil { return mcp.NewToolResultError(fmt.Sprintf("Missing user_id: %v", err)), nil }
maxPoints, _ := getNumber(request, "max_points") if maxPoints == 0 { // Get user's current balance balance, err := pointsClient.GetBalance(ctx, userID) if err != nil { return mcp.NewToolResultError(fmt.Sprintf("Failed to get balance: %v", err)), nil } maxPoints = float64(balance.CurrentBalance) }
options, err := pointsClient.GetRedemptionOptions(ctx, userID, int(maxPoints)) if err != nil { return mcp.NewToolResultError(fmt.Sprintf("Failed to get redemption options: %v", err)), nil }
jsonBytes, err := json.Marshal(options) if err != nil { return mcp.NewToolResultError(fmt.Sprintf("Failed to marshal response: %v", err)), nil }
return &mcp.CallToolResult{ Content: []mcp.Content{ mcp.TextContent{ Type: "text", Text: string(jsonBytes), }, }, }, nil }, }}C. Update Configuration
Section titled “C. Update Configuration”Add to internal/config/config.go:
type Config struct { // ... existing fields ... Points PointsConfig `json:"points"`}
type PointsConfig struct { BaseURL string `json:"base_url"` APIKey string `json:"api_key"` Timeout string `json:"timeout"`}Add to config files (config/local/config.json):
{ "points": { "base_url": "https://api.fetchrewards.com/points", "api_key": "${POINTS_API_KEY}", "timeout": "10s" }}D. Update Main Entry Point
Section titled “D. Update Main Entry Point”Modify cmd/mcp-server/main.go:
import ( "github.com/consumer-graph-mcp/pkg/points" // ... other imports ...)
func main() { // ... existing setup ...
// Initialize points client pointsClient := points.NewClient(cfg.Points.BaseURL, cfg.Points.APIKey)
// Register tools tools.RegisterAll(registry, graphService, pointsClient) // Pass pointsClient}Update pkg/tools/registry.go:
func RegisterAll(registry *Registry, graphService GraphService, pointsClient *points.Client) { // ... existing registrations ...
// Points tools registry.Register(NewGetPointsBalanceTool(pointsClient)) registry.Register(NewGetRedemptionOptionsTool(pointsClient))}E. Update tools-config.yml
Section titled “E. Update tools-config.yml”tools: - name: get_points_balance enabled: true deployment: - all - apps-sdk description: Get user's current points balance and earning history
- name: get_redemption_options enabled: true deployment: - all - apps-sdk description: Get available reward redemption optionsF. Testing
Section titled “F. Testing”func TestGetPointsBalance(t *testing.T) { // Mock points client mockClient := &mockPointsClient{ balance: &points.BalanceResponse{ UserID: testUserID, CurrentBalance: 45230, PendingPoints: 500, LifetimePoints: 125000, PointsLast30Days: 3420, }, }
tool := NewGetPointsBalanceTool(mockClient)
request := mcp.CallToolRequest{ Params: mcp.CallToolRequestParams{ Name: "get_points_balance", Arguments: map[string]interface{}{ "user_id": testUserID, }, }, }
result, err := tool.Handler(ctx, request, nil) require.NoError(t, err)
var balance points.BalanceResponse err = json.Unmarshal([]byte(result.Content[0].(mcp.TextContent).Text), &balance) require.NoError(t, err)
assert.Equal(t, 45230, balance.CurrentBalance) assert.Equal(t, 3420, balance.PointsLast30Days)}Build & Test Commands
Section titled “Build & Test Commands”# Buildmake build
# Run testsmake test
# Run with coveragemake test-coverage
# Lintmake lint
# Run locallymake run ENV=local
# Format codemake fmtDeployment Checklist
Section titled “Deployment Checklist”- All tests passing
- Linter passing
- Documentation updated
- tools-config.yml updated
- Config files updated for all environments
- Database migrations run (if needed)
- Integration tests added
- Golden prompts tested manually
- Performance benchmarks acceptable
Next Steps
Section titled “Next Steps”After Phase 1 is complete and tested:
- Phase 2: Store-level offer listing, enhanced metadata
- Phase 3: Product location finder, cross-retailer comparison, offer stacking
Each phase builds on the previous infrastructure and patterns established here.