Skip to content

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.

Phase 1 focuses on enhancements that leverage existing data and infrastructure:

  1. Enhanced Purchase History - Add store visit patterns
  2. Venue-Type Filtering - Add venue filtering to offers
  3. 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”

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 statistics
type 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 method
func (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
}

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",
},

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

Enable queries like “I’m in the airport - what can I buy and earn points from?”

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"
}

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

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"},
},

Add venue_type to Retailer nodes:

// In internal/graph/seed/ - add venue type mapping logic
MATCH (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'
END
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")
}
}

Enable queries like “get me to gift card” and “How many points have I earned in last 30 days?”

Create pkg/points/client.go:

package points
import (
"context"
"encoding/json"
"fmt"
"net/http"
"time"
)
// Client handles communication with the points service
type Client struct {
httpClient *http.Client
baseURL string
apiKey string
}
// NewClient creates a new points API client
func NewClient(baseURL, apiKey string) *Client {
return &Client{
httpClient: &http.Client{Timeout: 10 * time.Second},
baseURL: baseURL,
apiKey: apiKey,
}
}
// BalanceResponse represents the user's points balance
type 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 balance
func (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 redeem
type 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 options
type RedemptionOptionsResponse struct {
UserID string `json:"user_id"`
Options []RedemptionOption `json:"options"`
Count int `json:"count"`
}
// GetRedemptionOptions retrieves available reward redemptions
func (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
}

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 tool
func 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 tool
func 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
},
}
}

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"
}
}

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))
}
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 options
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)
}

Terminal window
# Build
make build
# Run tests
make test
# Run with coverage
make test-coverage
# Lint
make lint
# Run locally
make run ENV=local
# Format code
make fmt

  • 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

After Phase 1 is complete and tested:

  1. Phase 2: Store-level offer listing, enhanced metadata
  2. Phase 3: Product location finder, cross-retailer comparison, offer stacking

Each phase builds on the previous infrastructure and patterns established here.