package mcp import ( "context" "fmt" "time" "git.coopgo.io/coopgo-apps/parcoursmob/core/application" mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/paulmach/orb/geojson" "github.com/rs/zerolog/log" ) // JourneySearchInput represents the input for journey search type JourneySearchInput struct { Departure string `json:"departure" jsonschema:"Departure address as text (e.g. Paris or Gare de Lyon Paris)"` Destination string `json:"destination" jsonschema:"Destination address as text (e.g. Lyon or Part-Dieu Lyon)"` DepartureDate string `json:"departure_date" jsonschema:"Departure date in YYYY-MM-DD format"` DepartureTime string `json:"departure_time" jsonschema:"Departure time in HH:MM format (24-hour)"` PassengerID string `json:"passenger_id,omitempty" jsonschema:"Optional passenger ID to retrieve address from account"` ExcludeDriverIDs []string `json:"exclude_driver_ids,omitempty" jsonschema:"Optional list of driver IDs to exclude from solidarity transport results"` } // JourneySearchOutput represents the output of journey search type JourneySearchOutput struct { SearchParameters map[string]any `json:"search_parameters"` Results any `json:"results"` } // registerJourneySearchTool registers the journey search tool with the MCP server func (s *MCPServer) registerJourneySearchTool() { mcpsdk.AddTool( s.mcpServer, &mcpsdk.Tool{ Name: "search_journeys", Description: "Search for multimodal journeys including transit, carpooling, solidarity transport, organized carpool, and local solutions. Accepts departure and destination as text addresses that will be geocoded automatically.", }, s.handleJourneySearch, ) } // handleJourneySearch handles the journey search tool execution func (s *MCPServer) handleJourneySearch(ctx context.Context, req *mcpsdk.CallToolRequest, input JourneySearchInput) (*mcpsdk.CallToolResult, *JourneySearchOutput, error) { // Geocode departure address using French government API log.Info().Str("address", input.Departure).Msg("Geocoding departure address") departureFeature, err := s.geocodeAddress(input.Departure) if err != nil { log.Error().Err(err).Msg("failed to geocode departure address") return nil, nil, fmt.Errorf("failed to geocode departure address: %w", err) } // Geocode destination address using French government API log.Info().Str("address", input.Destination).Msg("Geocoding destination address") destinationFeature, err := s.geocodeAddress(input.Destination) if err != nil { log.Error().Err(err).Msg("failed to geocode destination address") return nil, nil, fmt.Errorf("failed to geocode destination address: %w", err) } // Parse date and time parisLoc, err := time.LoadLocation("Europe/Paris") if err != nil { return nil, nil, fmt.Errorf("failed to load Paris timezone: %w", err) } departureDateTime, err := time.ParseInLocation("2006-01-02 15:04", fmt.Sprintf("%s %s", input.DepartureDate, input.DepartureTime), parisLoc) if err != nil { log.Error().Err(err).Msg("failed to parse date/time") return nil, nil, fmt.Errorf("failed to parse departure date/time: %w", err) } // Convert to UTC for the search departureDateTime = departureDateTime.UTC() log.Info(). Str("departure", input.Departure). Str("destination", input.Destination). Time("departure_datetime", departureDateTime). Msg("Executing journey search") // Prepare exclude driver ID (only first one if provided) excludeDriverID := "" if len(input.ExcludeDriverIDs) > 0 { excludeDriverID = input.ExcludeDriverIDs[0] } // Prepare search options - disable transit for MCP requests searchOptions := &application.SearchJourneyOptions{ DisableTransit: true, } // Call the journey search from application handler searchResult, err := s.applicationHandler.SearchJourneys( ctx, departureDateTime, departureFeature, destinationFeature, input.PassengerID, excludeDriverID, "", // solidarityExcludeGroupId - not used in MCP context searchOptions, ) if err != nil { return nil, nil, fmt.Errorf("journey search failed: %w", err) } // Format the results for MCP response response := &JourneySearchOutput{ SearchParameters: map[string]any{ "departure": map[string]any{ "label": getFeatureLabel(departureFeature), "coordinates": departureFeature.Geometry, }, "destination": map[string]any{ "label": getFeatureLabel(destinationFeature), "coordinates": destinationFeature.Geometry, }, "departure_date": input.DepartureDate, "departure_time": input.DepartureTime, }, Results: searchResult, } return &mcpsdk.CallToolResult{}, response, nil } // geocodeAddress uses the geo service helper to geocode an address func (s *MCPServer) geocodeAddress(address string) (*geojson.Feature, error) { // Use the geo service to get autocomplete results featureCollection, err := s.geoService.Autocomplete(address) if err != nil { return nil, fmt.Errorf("geocoding request failed: %w", err) } if len(featureCollection.Features) == 0 { return nil, fmt.Errorf("no results found for address: %s", address) } // Return the first feature directly return featureCollection.Features[0], nil } // getFeatureLabel extracts a human-readable label from a GeoJSON Feature func getFeatureLabel(feature *geojson.Feature) string { if feature.Properties == nil { return "" } // Try common label fields if label, ok := feature.Properties["label"].(string); ok { return label } if name, ok := feature.Properties["name"].(string); ok { return name } return "" }