parcoursmob/servers/mcp/journey_tool.go

159 lines
5.5 KiB
Go

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