Add MCP server

This commit is contained in:
Arnaud Delcasse 2025-11-03 11:45:23 +01:00
parent d992a7984f
commit 52de8d363e
18 changed files with 997 additions and 210 deletions

View File

@ -20,7 +20,7 @@ func ReadConfig() (*viper.Viper, error) {
"listen": "0.0.0.0:8080",
},
"mcp": map[string]any{
"enabled": false,
"enabled": true,
"listen": "0.0.0.0:8081",
},
},
@ -123,6 +123,26 @@ func ReadConfig() (*viper.Viper, error) {
"journeys": map[string]any{
"enabled": true,
"search_view": "tabs",
"solutions": map[string]any{
"solidarity_transport": map[string]any{
"enabled": true,
},
"organized_carpool": map[string]any{
"enabled": true,
},
"carpool_operators": map[string]any{
"enabled": true,
},
"transit": map[string]any{
"enabled": true,
},
"fleet_vehicles": map[string]any{
"enabled": true,
},
"knowledge_base": map[string]any{
"enabled": true,
},
},
},
"solidarity_transport": map[string]any{
"enabled": true,
@ -226,8 +246,14 @@ func ReadConfig() (*viper.Viper, error) {
},
},
"geo": map[string]any{
"type": "addok", // Options: "pelias", "addok"
"pelias": map[string]any{
"url": "https://geocode.ridygo.fr",
"url": "https://geocode.ridygo.fr",
"autocomplete": "/autocomplete?text=",
},
"addok": map[string]any{
"url": "https://api-adresse.data.gouv.fr",
"autocomplete": "/search/?q=",
},
},
"geography": map[string]any{

View File

@ -282,6 +282,8 @@ func (h *ApplicationHandler) GetBeneficiaryData(ctx context.Context, beneficiary
solidarityTransportBookings = append(solidarityTransportBookings, booking)
}
// Don't filter out replaced bookings from beneficiary profile - show all bookings
// Collect unique driver IDs
driverIDs := []string{}
driverIDsMap := make(map[string]bool)

View File

@ -38,6 +38,16 @@ type SearchJourneysResult struct {
KnowledgeBaseResults []any
}
// SearchJourneyOptions contains per-request options for journey search
type SearchJourneyOptions struct {
DisableSolidarityTransport bool
DisableOrganizedCarpool bool
DisableCarpoolOperators bool
DisableTransit bool
DisableFleetVehicles bool
DisableKnowledgeBase bool
}
// SearchJourneys performs the business logic for journey search
func (h *ApplicationHandler) SearchJourneys(
ctx context.Context,
@ -46,6 +56,8 @@ func (h *ApplicationHandler) SearchJourneys(
destinationGeo *geojson.Feature,
passengerID string,
solidarityTransportExcludeDriver string,
solidarityExcludeGroupId string,
options *SearchJourneyOptions,
) (*SearchJourneysResult, error) {
var (
// Results
@ -64,6 +76,19 @@ func (h *ApplicationHandler) SearchJourneys(
if departureGeo != nil && destinationGeo != nil && !departureDateTime.IsZero() {
searched = true
// Default options if not provided
if options == nil {
options = &SearchJourneyOptions{}
}
// Check solution type configurations (global config AND per-request options)
solidarityTransportEnabled := h.config.GetBool("modules.journeys.solutions.solidarity_transport.enabled") && !options.DisableSolidarityTransport
organizedCarpoolEnabled := h.config.GetBool("modules.journeys.solutions.organized_carpool.enabled") && !options.DisableOrganizedCarpool
carpoolOperatorsEnabled := h.config.GetBool("modules.journeys.solutions.carpool_operators.enabled") && !options.DisableCarpoolOperators
transitEnabled := h.config.GetBool("modules.journeys.solutions.transit.enabled") && !options.DisableTransit
fleetVehiclesEnabled := h.config.GetBool("modules.journeys.solutions.fleet_vehicles.enabled") && !options.DisableFleetVehicles
knowledgeBaseEnabled := h.config.GetBool("modules.journeys.solutions.knowledge_base.enabled") && !options.DisableKnowledgeBase
// SOLIDARITY TRANSPORT
var err error
drivers, err = h.services.GetAccountsInNamespacesMap([]string{"solidarity_drivers", "organized_carpool_drivers"})
@ -74,36 +99,58 @@ func (h *ApplicationHandler) SearchJourneys(
protodep, _ := transformers.GeoJsonToProto(departureGeo)
protodest, _ := transformers.GeoJsonToProto(destinationGeo)
log.Debug().Time("departure time", departureDateTime).Msg("calling driver journeys with ...")
res, err := h.services.GRPC.SolidarityTransport.GetDriverJourneys(ctx, &gen.GetDriverJourneysRequest{
Departure: protodep,
Arrival: protodest,
DepartureDate: timestamppb.New(departureDateTime),
})
if err != nil {
log.Error().Err(err).Msg("error in grpc call to GetDriverJourneys")
} else {
solidarityTransportResults = slices.Collect(func(yield func(*gen.SolidarityTransportDriverJourney) bool) {
for _, dj := range res.DriverJourneys {
if a, ok := drivers[dj.DriverId].Data["archived"]; ok {
if archived, ok := a.(bool); ok {
if archived {
continue
}
}
}
if dj.DriverId == solidarityTransportExcludeDriver {
continue
}
if !yield(dj) {
return
// Get driver IDs to exclude based on group_id (drivers who already have bookings in this group)
excludedDriverIds := make(map[string]bool)
if solidarityExcludeGroupId != "" {
bookingsResp, err := h.services.GRPC.SolidarityTransport.GetSolidarityTransportBookings(ctx, &gen.GetSolidarityTransportBookingsRequest{
StartDate: timestamppb.New(time.Date(2000, 1, 1, 0, 0, 0, 0, time.UTC)),
EndDate: timestamppb.New(time.Date(9999, 12, 31, 0, 0, 0, 0, time.UTC)),
})
if err == nil {
for _, booking := range bookingsResp.Bookings {
if booking.GroupId == solidarityExcludeGroupId {
excludedDriverIds[booking.DriverId] = true
}
}
}
}
if solidarityTransportEnabled {
log.Debug().Time("departure time", departureDateTime).Msg("calling driver journeys with ...")
res, err := h.services.GRPC.SolidarityTransport.GetDriverJourneys(ctx, &gen.GetDriverJourneysRequest{
Departure: protodep,
Arrival: protodest,
DepartureDate: timestamppb.New(departureDateTime),
})
sort.Slice(solidarityTransportResults, func(i, j int) bool {
return solidarityTransportResults[i].DriverDistance < solidarityTransportResults[j].DriverDistance
})
if err != nil {
log.Error().Err(err).Msg("error in grpc call to GetDriverJourneys")
} else {
solidarityTransportResults = slices.Collect(func(yield func(*gen.SolidarityTransportDriverJourney) bool) {
for _, dj := range res.DriverJourneys {
if a, ok := drivers[dj.DriverId].Data["archived"]; ok {
if archived, ok := a.(bool); ok {
if archived {
continue
}
}
}
if dj.DriverId == solidarityTransportExcludeDriver {
continue
}
// Skip drivers who already have bookings in the same group
if excludedDriverIds[dj.DriverId] {
continue
}
if !yield(dj) {
return
}
}
})
sort.Slice(solidarityTransportResults, func(i, j int) bool {
return solidarityTransportResults[i].DriverDistance < solidarityTransportResults[j].DriverDistance
})
}
}
// Get departure and destination addresses from properties
@ -119,124 +166,134 @@ func (h *ApplicationHandler) SearchJourneys(
}
}
radius := float64(5)
// ORGANIZED CARPOOL
organizedCarpoolResultsRes, err := h.services.GRPC.CarpoolService.DriverJourneys(ctx, &carpoolproto.DriverJourneysRequest{
DepartureLat: departureGeo.Point().Lat(),
DepartureLng: departureGeo.Point().Lon(),
ArrivalLat: destinationGeo.Point().Lat(),
ArrivalLng: destinationGeo.Point().Lon(),
DepartureDate: timestamppb.New(departureDateTime),
DepartureAddress: &departureAddress,
ArrivalAddress: &destinationAddress,
DepartureRadius: &radius,
ArrivalRadius: &radius,
})
if err != nil {
log.Error().Err(err).Msg("error retrieving organized carpools")
} else {
organizedCarpoolResults = organizedCarpoolResultsRes.DriverJourneys
sort.Slice(organizedCarpoolResults, func(i, j int) bool {
return *organizedCarpoolResults[i].Distance < *organizedCarpoolResults[j].Distance
if organizedCarpoolEnabled {
radius := float64(5)
organizedCarpoolResultsRes, err := h.services.GRPC.CarpoolService.DriverJourneys(ctx, &carpoolproto.DriverJourneysRequest{
DepartureLat: departureGeo.Point().Lat(),
DepartureLng: departureGeo.Point().Lon(),
ArrivalLat: destinationGeo.Point().Lat(),
ArrivalLng: destinationGeo.Point().Lon(),
DepartureDate: timestamppb.New(departureDateTime),
DepartureAddress: &departureAddress,
ArrivalAddress: &destinationAddress,
DepartureRadius: &radius,
ArrivalRadius: &radius,
})
if err != nil {
log.Error().Err(err).Msg("error retrieving organized carpools")
} else {
organizedCarpoolResults = organizedCarpoolResultsRes.DriverJourneys
sort.Slice(organizedCarpoolResults, func(i, j int) bool {
return *organizedCarpoolResults[i].Distance < *organizedCarpoolResults[j].Distance
})
}
}
var wg sync.WaitGroup
// CARPOOL OPERATORS
carpools := make(chan *geojson.FeatureCollection)
go h.services.InteropCarpool.Search(carpools, *departureGeo, *destinationGeo, departureDateTime)
wg.Add(1)
go func() {
defer wg.Done()
for c := range carpools {
carpoolResults = append(carpoolResults, c)
}
}()
if carpoolOperatorsEnabled {
carpools := make(chan *geojson.FeatureCollection)
go h.services.InteropCarpool.Search(carpools, *departureGeo, *destinationGeo, departureDateTime)
wg.Add(1)
go func() {
defer wg.Done()
for c := range carpools {
carpoolResults = append(carpoolResults, c)
}
}()
}
// TRANSIT
transitch := make(chan *transitous.Itinerary)
go func(transitch chan *transitous.Itinerary, departure *geojson.Feature, destination *geojson.Feature, datetime *time.Time) {
defer close(transitch)
response, err := h.services.TransitRouting.PlanWithResponse(ctx, &transitous.PlanParams{
FromPlace: fmt.Sprintf("%f,%f", departure.Point().Lat(), departure.Point().Lon()),
ToPlace: fmt.Sprintf("%f,%f", destination.Point().Lat(), destination.Point().Lon()),
Time: datetime,
})
if err != nil {
log.Error().Err(err).Msg("error retrieving transit data from Transitous server")
return
}
for _, i := range response.Itineraries {
transitch <- &i
}
}(transitch, departureGeo, destinationGeo, &departureDateTime)
wg.Add(1)
go func() {
defer wg.Done()
paris, _ := time.LoadLocation("Europe/Paris")
requestedDay := departureDateTime.In(paris).Truncate(24 * time.Hour)
if transitEnabled {
transitch := make(chan *transitous.Itinerary)
go func(transitch chan *transitous.Itinerary, departure *geojson.Feature, destination *geojson.Feature, datetime *time.Time) {
defer close(transitch)
response, err := h.services.TransitRouting.PlanWithResponse(ctx, &transitous.PlanParams{
FromPlace: fmt.Sprintf("%f,%f", departure.Point().Lat(), departure.Point().Lon()),
ToPlace: fmt.Sprintf("%f,%f", destination.Point().Lat(), destination.Point().Lon()),
Time: datetime,
})
if err != nil {
log.Error().Err(err).Msg("error retrieving transit data from Transitous server")
return
}
for _, i := range response.Itineraries {
transitch <- &i
}
}(transitch, departureGeo, destinationGeo, &departureDateTime)
wg.Add(1)
go func() {
defer wg.Done()
paris, _ := time.LoadLocation("Europe/Paris")
requestedDay := departureDateTime.In(paris).Truncate(24 * time.Hour)
for itinerary := range transitch {
// Only include journeys that start on the requested day (in Paris timezone)
if !itinerary.StartTime.IsZero() && !itinerary.EndTime.IsZero() {
log.Info().
Time("startTime", itinerary.StartTime).
Time("endTime", itinerary.EndTime).
Str("startTimezone", itinerary.StartTime.Location().String()).
Str("endTimezone", itinerary.EndTime.Location().String()).
Str("startTimeRFC3339", itinerary.StartTime.Format(time.RFC3339)).
Str("endTimeRFC3339", itinerary.EndTime.Format(time.RFC3339)).
Msg("Journey search - received transit itinerary from Transitous")
startInParis := itinerary.StartTime.In(paris)
startDay := startInParis.Truncate(24 * time.Hour)
// Check if journey starts on the requested day
if startDay.Equal(requestedDay) {
transitResults = append(transitResults, itinerary)
} else {
for itinerary := range transitch {
// Only include journeys that start on the requested day (in Paris timezone)
if !itinerary.StartTime.IsZero() && !itinerary.EndTime.IsZero() {
log.Info().
Str("requestedDay", requestedDay.Format("2006-01-02")).
Str("startDay", startDay.Format("2006-01-02")).
Msg("Journey search - filtered out transit journey (not on requested day)")
Time("startTime", itinerary.StartTime).
Time("endTime", itinerary.EndTime).
Str("startTimezone", itinerary.StartTime.Location().String()).
Str("endTimezone", itinerary.EndTime.Location().String()).
Str("startTimeRFC3339", itinerary.StartTime.Format(time.RFC3339)).
Str("endTimeRFC3339", itinerary.EndTime.Format(time.RFC3339)).
Msg("Journey search - received transit itinerary from Transitous")
startInParis := itinerary.StartTime.In(paris)
startDay := startInParis.Truncate(24 * time.Hour)
// Check if journey starts on the requested day
if startDay.Equal(requestedDay) {
transitResults = append(transitResults, itinerary)
} else {
log.Info().
Str("requestedDay", requestedDay.Format("2006-01-02")).
Str("startDay", startDay.Format("2006-01-02")).
Msg("Journey search - filtered out transit journey (not on requested day)")
}
}
}
}
}()
}()
}
// VEHICLES
vehiclech := make(chan fleetsstorage.Vehicle)
go h.vehicleRequest(vehiclech, departureDateTime.Add(-24*time.Hour), departureDateTime.Add(168*time.Hour))
wg.Add(1)
go func() {
defer wg.Done()
for vehicle := range vehiclech {
vehicleResults = append(vehicleResults, vehicle)
}
slices.SortFunc(vehicleResults, sorting.VehiclesByDistanceFrom(*departureGeo))
}()
if fleetVehiclesEnabled {
vehiclech := make(chan fleetsstorage.Vehicle)
go h.vehicleRequest(vehiclech, departureDateTime.Add(-24*time.Hour), departureDateTime.Add(168*time.Hour))
wg.Add(1)
go func() {
defer wg.Done()
for vehicle := range vehiclech {
vehicleResults = append(vehicleResults, vehicle)
}
slices.SortFunc(vehicleResults, sorting.VehiclesByDistanceFrom(*departureGeo))
}()
}
wg.Wait()
// KNOWLEDGE BASE
departureGeoSearch, _ := h.services.Geography.GeoSearch(departureGeo)
kbData := h.config.Get("knowledge_base")
if kb, ok := kbData.([]any); ok {
for _, sol := range kb {
if solution, ok := sol.(map[string]any); ok {
if g, ok := solution["geography"]; ok {
if geography, ok := g.([]any); ok {
for _, gg := range geography {
if geog, ok := gg.(map[string]any); ok {
if layer, ok := geog["layer"].(string); ok {
code := geog["code"]
geo, err := h.services.Geography.Find(layer, fmt.Sprintf("%v", code))
if err == nil {
geog["geography"] = geo
geog["name"] = geo.Properties.MustString("nom")
}
if strings.Compare(fmt.Sprintf("%v", code), departureGeoSearch[layer].Properties.MustString("code")) == 0 {
knowledgeBaseResults = append(knowledgeBaseResults, solution)
break
if knowledgeBaseEnabled {
departureGeoSearch, _ := h.services.Geography.GeoSearch(departureGeo)
kbData := h.config.Get("knowledge_base")
if kb, ok := kbData.([]any); ok {
for _, sol := range kb {
if solution, ok := sol.(map[string]any); ok {
if g, ok := solution["geography"]; ok {
if geography, ok := g.([]any); ok {
for _, gg := range geography {
if geog, ok := gg.(map[string]any); ok {
if layer, ok := geog["layer"].(string); ok {
code := geog["code"]
geo, err := h.services.Geography.Find(layer, fmt.Sprintf("%v", code))
if err == nil {
geog["geography"] = geo
geog["name"] = geo.Properties.MustString("nom")
}
if strings.Compare(fmt.Sprintf("%v", code), departureGeoSearch[layer].Properties.MustString("code")) == 0 {
knowledgeBaseResults = append(knowledgeBaseResults, solution)
break
}
}
}
}

View File

@ -358,6 +358,18 @@ func (h *ApplicationHandler) GetSolidarityTransportOverview(ctx context.Context,
transformedBookings = filterBookingsByGeography(transformedBookings, departurePolygons, destinationPolygons)
transformedBookings = filterBookingsByPassengerAddressGeography(transformedBookings, beneficiariesMap, passengerAddressPolygons)
// Filter out replaced bookings from upcoming bookings list
filteredBookings := []*solidaritytypes.Booking{}
for _, booking := range transformedBookings {
if booking.Data != nil {
if _, hasReplacedBy := booking.Data["replaced_by"]; hasReplacedBy {
continue // Skip bookings that have been replaced
}
}
filteredBookings = append(filteredBookings, booking)
}
transformedBookings = filteredBookings
// Sort upcoming bookings by date (ascending - earliest first)
sort.Slice(transformedBookings, func(i, j int) bool {
if transformedBookings[i].Journey != nil && transformedBookings[j].Journey != nil {
@ -396,6 +408,8 @@ func (h *ApplicationHandler) GetSolidarityTransportOverview(ctx context.Context,
transformedBookingsHistory = filterBookingsByGeography(transformedBookingsHistory, histDeparturePolygons, histDestinationPolygons)
transformedBookingsHistory = filterBookingsByPassengerAddressGeography(transformedBookingsHistory, beneficiariesMap, histPassengerAddressPolygons)
// Don't filter out replaced bookings from history - we want to see them in history
// Sort history bookings by date (descending - most recent first)
sort.Slice(transformedBookingsHistory, func(i, j int) bool {
if transformedBookingsHistory[i].Journey != nil && transformedBookingsHistory[j].Journey != nil {
@ -503,6 +517,8 @@ func (h *ApplicationHandler) GetSolidarityTransportBookings(ctx context.Context,
transformedBookings = filterBookingsByGeography(transformedBookings, departurePolygons, destinationPolygons)
transformedBookings = filterBookingsByPassengerAddressGeography(transformedBookings, beneficiariesMap, passengerAddressPolygons)
// Don't filter out replaced bookings for exports - include all bookings
// Sort bookings by date
sort.Slice(transformedBookings, func(i, j int) bool {
if transformedBookings[i].Journey != nil && transformedBookings[j].Journey != nil {
@ -744,6 +760,8 @@ func (h *ApplicationHandler) GetSolidarityTransportDriverData(ctx context.Contex
bookings = append(bookings, booking)
}
// Don't filter out replaced bookings from driver profile - show all bookings
// Collect unique passenger IDs
passengerIDs := []string{}
passengerIDsMap := make(map[string]bool)
@ -1048,7 +1066,19 @@ func (h *ApplicationHandler) GetSolidarityTransportJourneyData(ctx context.Conte
}, nil
}
func (h *ApplicationHandler) CreateSolidarityTransportJourneyBooking(ctx context.Context, driverID, journeyID, passengerID, motivation, message string, doNotSend bool, returnWaitingTimeMinutes int) (string, error) {
func (h *ApplicationHandler) CreateSolidarityTransportJourneyBooking(ctx context.Context, driverID, journeyID, passengerID, motivation, message string, doNotSend bool, returnWaitingTimeMinutes int, replacesBookingID string) (string, error) {
// If this is a replacement booking, get the old booking's group_id
var groupID string
if replacesBookingID != "" {
oldBookingResp, err := h.services.GRPC.SolidarityTransport.GetSolidarityTransportBooking(ctx, &gen.GetSolidarityTransportBookingRequest{
Id: replacesBookingID,
})
if err != nil {
return "", fmt.Errorf("could not get booking to replace: %w", err)
}
groupID = oldBookingResp.Booking.GroupId
}
// Get journey for pricing calculation
journeyRequest := &gen.GetDriverJourneyRequest{
DriverId: driverID,
@ -1083,6 +1113,12 @@ func (h *ApplicationHandler) CreateSolidarityTransportJourneyBooking(ctx context
returnWaitingDuration := int64(returnWaitingTimeMinutes) * int64(time.Minute)
// Create booking request
dataFields := map[string]*structpb.Value{
"motivation": structpb.NewStringValue(motivation),
"message": structpb.NewStringValue(message),
"do_not_send": structpb.NewBoolValue(doNotSend),
}
bookingRequest := &gen.BookDriverJourneyRequest{
PassengerId: passengerID,
DriverId: driverID,
@ -1093,19 +1129,45 @@ func (h *ApplicationHandler) CreateSolidarityTransportJourneyBooking(ctx context
DriverCompensationAmount: driverCompensation,
DriverCompensationCurrency: "EUR",
Data: &structpb.Struct{
Fields: map[string]*structpb.Value{
"motivation": structpb.NewStringValue(motivation),
"message": structpb.NewStringValue(message),
"do_not_send": structpb.NewBoolValue(doNotSend),
},
Fields: dataFields,
},
}
// Set group_id if this is a replacement booking
if groupID != "" {
bookingRequest.GroupId = &groupID
}
resp, err := h.services.GRPC.SolidarityTransport.BookDriverJourney(ctx, bookingRequest)
if err != nil {
return "", err
}
// If this is a replacement booking, update the old booking to mark it as replaced
if replacesBookingID != "" {
oldBookingResp, err := h.services.GRPC.SolidarityTransport.GetSolidarityTransportBooking(ctx, &gen.GetSolidarityTransportBookingRequest{
Id: replacesBookingID,
})
if err == nil {
oldBooking, err := solidaritytransformers.BookingProtoToType(oldBookingResp.Booking)
if err == nil && oldBooking != nil {
if oldBooking.Data == nil {
oldBooking.Data = make(map[string]any)
}
oldBooking.Data["replaced_by"] = resp.Booking.Id
// Update the booking
oldBookingProto, _ := solidaritytransformers.BookingTypeToProto(oldBooking)
_, err = h.services.GRPC.SolidarityTransport.UpdateSolidarityTransportBooking(ctx, &gen.UpdateSolidarityTransportBookingRequest{
Booking: oldBookingProto,
})
if err != nil {
log.Error().Err(err).Str("old_booking_id", replacesBookingID).Str("new_booking_id", resp.Booking.Id).Msg("failed to mark old booking as replaced")
}
}
}
}
// Send SMS if not disabled
if !doNotSend && message != "" {
send_message := strings.ReplaceAll(message, "{booking_id}", resp.Booking.Id)
@ -1253,6 +1315,20 @@ func (h *ApplicationHandler) pricingGeography(loc *geojson.Feature) pricing.Geog
}
}
// CalculateSolidarityTransportPricing is the exported wrapper for calculateSolidarityTransportPricing
func (h *ApplicationHandler) CalculateSolidarityTransportPricing(ctx context.Context, journey *gen.SolidarityTransportDriverJourney, passengerID string) (map[string]pricing.Price, error) {
// Get passenger account
var passenger mobilityaccountsstorage.Account
if passengerID != "" {
passengerResp, err := h.services.GetAccount(passengerID)
if err != nil {
return nil, fmt.Errorf("could not get passenger account: %w", err)
}
passenger = passengerResp
}
return h.calculateSolidarityTransportPricing(ctx, journey, passengerID, passenger)
}
func (h *ApplicationHandler) calculateSolidarityTransportPricing(ctx context.Context, journey *gen.SolidarityTransportDriverJourney, passengerID string, passenger mobilityaccountsstorage.Account) (map[string]pricing.Price, error) {
// Transform proto to type for geography access
journeyType, err := solidaritytransformers.DriverJourneyProtoToType(journey)

View File

@ -1,28 +1,29 @@
package geo
import (
"encoding/json"
"fmt"
"io"
"net/http"
"github.com/paulmach/orb/geojson"
"github.com/rs/zerolog/log"
)
type GeoService struct {
peliasURL string
geoType string
baseURL string
autocompleteURL string
}
func NewGeoService(peliasURL string) *GeoService {
return &GeoService{peliasURL: peliasURL}
func NewGeoService(geoType, baseURL, autocompleteEndpoint string) *GeoService {
return &GeoService{
geoType: geoType,
baseURL: baseURL,
autocompleteURL: baseURL + autocompleteEndpoint,
}
}
type AutocompleteResult struct {
Features []any
}
func (s *GeoService) Autocomplete(text string) (*AutocompleteResult, error) {
resp, err := http.Get(fmt.Sprintf("%s/autocomplete?text=%s", s.peliasURL, text))
func (s *GeoService) Autocomplete(text string) (*geojson.FeatureCollection, error) {
resp, err := http.Get(s.autocompleteURL + text)
if err != nil {
return nil, err
}
@ -34,17 +35,11 @@ func (s *GeoService) Autocomplete(text string) (*AutocompleteResult, error) {
return nil, err
}
var response map[string]any
if err := json.Unmarshal(body, &response); err != nil {
featureCollection, err := geojson.UnmarshalFeatureCollection(body)
if err != nil {
log.Error().Err(err).Msg("Failed to unmarshal feature collection")
return nil, err
}
features, ok := response["features"].([]any)
if !ok {
features = []any{}
}
return &AutocompleteResult{
Features: features,
}, nil
return featureCollection, nil
}

7
go.mod
View File

@ -57,12 +57,13 @@ require (
git.coopgo.io/coopgo-platform/routing-service v0.0.0-20250304234521-faabcc54f536
git.coopgo.io/coopgo-platform/saved-search v0.0.0-20251008070953-efccea3f6463
git.coopgo.io/coopgo-platform/sms v0.0.0-20250523074631-1f1e7fc6b7af
git.coopgo.io/coopgo-platform/solidarity-transport v0.0.0-20251008070137-723c12a6573d
git.coopgo.io/coopgo-platform/solidarity-transport v0.0.0-20251016152145-e0882db1bcbc
github.com/arran4/golang-ical v0.3.1
github.com/coreos/go-oidc/v3 v3.11.0
github.com/go-viper/mapstructure/v2 v2.4.0
github.com/gorilla/securecookie v1.1.1
github.com/minio/minio-go/v7 v7.0.43
github.com/modelcontextprotocol/go-sdk v1.0.0
github.com/paulmach/orb v0.12.0
github.com/rs/zerolog v1.34.0
github.com/stretchr/objx v0.5.3
@ -109,7 +110,6 @@ require (
require (
ariga.io/atlas v0.37.0 // indirect
github.com/AlexJarrah/go-ods v1.0.7 // indirect
github.com/agext/levenshtein v1.2.3 // indirect
github.com/coreos/go-semver v0.3.0 // indirect
github.com/coreos/go-systemd/v22 v22.5.0 // indirect
@ -124,6 +124,7 @@ require (
github.com/golang/protobuf v1.5.4 // indirect
github.com/golang/snappy v1.0.0 // indirect
github.com/google/go-cmp v0.7.0 // indirect
github.com/google/jsonschema-go v0.3.0 // indirect
github.com/hashicorp/hcl/v2 v2.24.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/compress v1.18.0 // indirect
@ -138,7 +139,6 @@ require (
github.com/mitchellh/go-wordwrap v1.0.1 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect
github.com/montanaflynn/stats v0.7.1 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/pkg/errors v0.9.1 // indirect
@ -158,6 +158,7 @@ require (
github.com/xdg-go/stringprep v1.0.4 // indirect
github.com/xuri/efp v0.0.1 // indirect
github.com/xuri/nfp v0.0.1 // indirect
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
github.com/zclconf/go-cty v1.17.0 // indirect
go.etcd.io/etcd/api/v3 v3.5.12 // indirect

49
go.sum
View File

@ -10,22 +10,14 @@ git.coopgo.io/coopgo-platform/emailing v0.0.0-20250212064257-167ef5864260 h1:Li3
git.coopgo.io/coopgo-platform/emailing v0.0.0-20250212064257-167ef5864260/go.mod h1:6cvvjv0RLSwBthIQ4TiuZoXFGvQXZ55hNSJchWXAgB4=
git.coopgo.io/coopgo-platform/fleets v1.1.0 h1:pfW/K3fWfap54yNfkLzBXjvOjjoTaEGFEqS/+VkHv7s=
git.coopgo.io/coopgo-platform/fleets v1.1.0/go.mod h1:nuK2mi1M2+DdntinqK/8C4ttW4WWyKCCY/xD1D7XjkE=
git.coopgo.io/coopgo-platform/geography v0.0.0-20250616160304-0285c9494673 h1:cth7a8Mnx1C6C6F5rv7SoKVMHYpI/CioFubyi0xB+Dw=
git.coopgo.io/coopgo-platform/geography v0.0.0-20250616160304-0285c9494673/go.mod h1:TbR3g1Awa8hpAe6LR1z1EQbv2IBVgN5JQ/FjXfKX4K0=
git.coopgo.io/coopgo-platform/geography v0.0.0-20251010131258-ec939649e858 h1:4E0tbT8jj5oxaK66Ny61o7zqPaVc0qRN2cZG9IUR4Es=
git.coopgo.io/coopgo-platform/geography v0.0.0-20251010131258-ec939649e858/go.mod h1:TbR3g1Awa8hpAe6LR1z1EQbv2IBVgN5JQ/FjXfKX4K0=
git.coopgo.io/coopgo-platform/groups-management v0.0.0-20230310123255-5ef94ee0746c h1:bY7PyrAgYY02f5IpDyf1WVfRqvWzivu31K6aEAYbWCw=
git.coopgo.io/coopgo-platform/groups-management v0.0.0-20230310123255-5ef94ee0746c/go.mod h1:lozSy6qlIIYhvKKXscZzz28HAtS0qBDUTv5nofLRmYA=
git.coopgo.io/coopgo-platform/mobility-accounts v0.0.0-20230329105908-a76c0412a386 h1:v1JUdx8sknw2YYhFGz5cOAa1dEWNIBKvyiOpKr3RR+s=
git.coopgo.io/coopgo-platform/mobility-accounts v0.0.0-20230329105908-a76c0412a386/go.mod h1:1typNYtO+PQT6KG77vs/PUv0fO60/nbeSGZL2tt1LLg=
git.coopgo.io/coopgo-platform/multimodal-routing v0.0.0-20251010131127-a2c82a1a5c8e h1:c0iCczcVxDbzbaQY04zzFpMXgHTRGcYOJ8LqYk9UYuo=
git.coopgo.io/coopgo-platform/multimodal-routing v0.0.0-20251010131127-a2c82a1a5c8e/go.mod h1:npYccQZcZj1WzTHhpLfFHFSBA3RiZOkO5R9x4uy1a9I=
git.coopgo.io/coopgo-platform/multimodal-routing v0.0.0-20251013125457-ab53f677d9cb h1:NotnSudZYn4cLAXJvtYor1XLkS5HXXNEPNgHy0Hw3Qs=
git.coopgo.io/coopgo-platform/multimodal-routing v0.0.0-20251013125457-ab53f677d9cb/go.mod h1:npYccQZcZj1WzTHhpLfFHFSBA3RiZOkO5R9x4uy1a9I=
git.coopgo.io/coopgo-platform/multimodal-routing v0.0.0-20251013140400-42fb40437ac3 h1:jo7fF7tLIAU110tUSIYXkMAvu30g8wHzZCLq3YomooQ=
git.coopgo.io/coopgo-platform/multimodal-routing v0.0.0-20251013140400-42fb40437ac3/go.mod h1:npYccQZcZj1WzTHhpLfFHFSBA3RiZOkO5R9x4uy1a9I=
git.coopgo.io/coopgo-platform/payments v0.0.0-20251008125601-e36cd6e557da h1:5D2B1WkRolbXFUHsqPHnYtTeweSZ+iCHlx6S2KKxmxQ=
git.coopgo.io/coopgo-platform/payments v0.0.0-20251008125601-e36cd6e557da/go.mod h1:gSAH2Tr9x8K8QC0vsUMwSWLrQOlsG+v64ACrjYw4BL0=
git.coopgo.io/coopgo-platform/payments v0.0.0-20251013175712-75d0288d2d4f h1:B/+AP+rLFx8AojO2bKV3R93kMU84g8Dhy7DNVoT8xCY=
git.coopgo.io/coopgo-platform/payments v0.0.0-20251013175712-75d0288d2d4f/go.mod h1:gSAH2Tr9x8K8QC0vsUMwSWLrQOlsG+v64ACrjYw4BL0=
git.coopgo.io/coopgo-platform/routing-service v0.0.0-20250304234521-faabcc54f536 h1:SllXX1VJXulfhNi+Pd0R9chksm8zO6gkWcTQ/uSMsdc=
@ -34,10 +26,8 @@ git.coopgo.io/coopgo-platform/saved-search v0.0.0-20251008070953-efccea3f6463 h1
git.coopgo.io/coopgo-platform/saved-search v0.0.0-20251008070953-efccea3f6463/go.mod h1:0fuGuYub5CBy9NB6YMqxawE0HoBaxPb9gmSw1gjfDy0=
git.coopgo.io/coopgo-platform/sms v0.0.0-20250523074631-1f1e7fc6b7af h1:KxHim1dFcOVbFhRqelec8cJ65QBD2cma6eytW8llgYY=
git.coopgo.io/coopgo-platform/sms v0.0.0-20250523074631-1f1e7fc6b7af/go.mod h1:mad9D+WICDdpJzB+8H/wEVVbllK2mU6VLVByrppc9x0=
git.coopgo.io/coopgo-platform/solidarity-transport v0.0.0-20251008070137-723c12a6573d h1:eBzRP50PXlXlLhgZjFhjTuoxIuQ3N/+5A6RIZyZEMAs=
git.coopgo.io/coopgo-platform/solidarity-transport v0.0.0-20251008070137-723c12a6573d/go.mod h1:iaFXcIn7DYtKlLrSYs9C47Dt7eeMGYkpx+unLCx8TpQ=
github.com/AlexJarrah/go-ods v1.0.7 h1:QxhYKncbsgf59BNNOcc4XB7wxKvOrSwtC0fpf6/gtsM=
github.com/AlexJarrah/go-ods v1.0.7/go.mod h1:tifLS6QTLIRhFV4zSjZ59700fZOGeqqQD8KBBOb/F3w=
git.coopgo.io/coopgo-platform/solidarity-transport v0.0.0-20251016152145-e0882db1bcbc h1:NLU5DUo5Kt3jkPhV3KkqQMahTHIrGildBvYlHwJ6JmM=
git.coopgo.io/coopgo-platform/solidarity-transport v0.0.0-20251016152145-e0882db1bcbc/go.mod h1:iaFXcIn7DYtKlLrSYs9C47Dt7eeMGYkpx+unLCx8TpQ=
github.com/Azure/go-ansiterm v0.0.0-20170929234023-d6e3b3328b78/go.mod h1:LmzpDX56iTiv29bbRTIsUNlaFfuhWRQBWjQdVyAevI8=
github.com/DATA-DOG/go-sqlmock v1.3.2/go.mod h1:f/Ixk793poVmq4qj/V1dPUg2JEAKC73Q5eFN3EC/SaM=
github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU=
@ -168,6 +158,8 @@ github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeN
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/jsonschema-go v0.3.0 h1:6AH2TxVNtk3IlvkkhjrtbUc4S8AvO0Xii0DxIygDg+Q=
github.com/google/jsonschema-go v0.3.0/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
@ -232,13 +224,13 @@ github.com/mitchellh/copystructure v1.0.0/go.mod h1:SNtv71yrdKgLRyLFxmLdkAbkKEFW
github.com/mitchellh/go-wordwrap v1.0.1 h1:TLuKupo69TCn6TQSyGxwI1EblZZEsQ0vMlAFQflz0v0=
github.com/mitchellh/go-wordwrap v1.0.1/go.mod h1:R62XHJLzvMFRBbcrT7m7WgmE1eOyTSsCt+hzestvNj0=
github.com/mitchellh/reflectwalk v1.0.0/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw=
github.com/modelcontextprotocol/go-sdk v1.0.0 h1:Z4MSjLi38bTgLrd/LjSmofqRqyBiVKRyQSJgw8q8V74=
github.com/modelcontextprotocol/go-sdk v1.0.0/go.mod h1:nYtYQroQ2KQiM0/SbyEPUWQ6xs4B95gJjEalc9AQyOs=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9M+97sNutRR1RKhG96O6jWumTTnw=
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8=
github.com/montanaflynn/stats v0.0.0-20171201202039-1bf9dbcd8cbe/go.mod h1:wL8QJuTMNUDYhXwkmfOly8iTdp5TEcJFWZD2D7SIkUc=
github.com/montanaflynn/stats v0.7.1 h1:etflOAAHORrCC44V+aR6Ftzort912ZU+YLiSTuV8eaE=
github.com/montanaflynn/stats v0.7.1/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt6R8Bnaayow=
@ -266,8 +258,6 @@ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH
github.com/richardlehane/mscfb v1.0.4 h1:WULscsljNPConisD5hR0+OyZjwK46Pfyr6mPu5ZawpM=
github.com/richardlehane/mscfb v1.0.4/go.mod h1:YzVpcZg9czvAuhk9T+a3avCpcFPMUWm7gK3DypaEsUk=
github.com/richardlehane/msoleps v1.0.1/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg=
github.com/richardlehane/msoleps v1.0.3 h1:aznSZzrwYRl3rLKRT3gUk9am7T/mLNSnJINvN0AQoVM=
github.com/richardlehane/msoleps v1.0.3/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg=
github.com/richardlehane/msoleps v1.0.4 h1:WuESlvhX3gH2IHcd8UqyCuFY5yiq/GR/yqaSM/9/g00=
github.com/richardlehane/msoleps v1.0.4/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg=
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
@ -336,18 +326,14 @@ github.com/xdg-go/scram v1.1.2/go.mod h1:RT/sEzTbU5y00aCK8UOx6R7YryM0iF1N2MOmC3k
github.com/xdg-go/stringprep v1.0.3/go.mod h1:W3f5j4i+9rC0kuIEJL0ky1VpHXQU3ocBgklLGvcBnW8=
github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8=
github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM=
github.com/xuri/efp v0.0.0-20220603152613-6918739fd470 h1:6932x8ltq1w4utjmfMPVj09jdMlkY0aiA6+Skbtl3/c=
github.com/xuri/efp v0.0.0-20220603152613-6918739fd470/go.mod h1:ybY/Jr0T0GTCnYjKqmdwxyxn2BQf2RcQIIvex5QldPI=
github.com/xuri/efp v0.0.1 h1:fws5Rv3myXyYni8uwj2qKjVaRP30PdjeYe2Y6FDsCL8=
github.com/xuri/efp v0.0.1/go.mod h1:ybY/Jr0T0GTCnYjKqmdwxyxn2BQf2RcQIIvex5QldPI=
github.com/xuri/excelize/v2 v2.7.1 h1:gm8q0UCAyaTt3MEF5wWMjVdmthm2EHAWesGSKS9tdVI=
github.com/xuri/excelize/v2 v2.7.1/go.mod h1:qc0+2j4TvAUrBw36ATtcTeC1VCM0fFdAXZOmcF4nTpY=
github.com/xuri/excelize/v2 v2.9.1 h1:VdSGk+rraGmgLHGFaGG9/9IWu1nj4ufjJ7uwMDtj8Qw=
github.com/xuri/excelize/v2 v2.9.1/go.mod h1:x7L6pKz2dvo9ejrRuD8Lnl98z4JLt0TGAwjhW+EiP8s=
github.com/xuri/nfp v0.0.0-20220409054826-5e722a1d9e22 h1:OAmKAfT06//esDdpi/DZ8Qsdt4+M5+ltca05dA5bG2M=
github.com/xuri/nfp v0.0.0-20220409054826-5e722a1d9e22/go.mod h1:WwHg+CVyzlv/TX9xqBFXEZAuxOPxn2k1GNHwG41IIUQ=
github.com/xuri/nfp v0.0.1 h1:MDamSGatIvp8uOmDP8FnmjuQpu90NzdJxo7242ANR9Q=
github.com/xuri/nfp v0.0.1/go.mod h1:WwHg+CVyzlv/TX9xqBFXEZAuxOPxn2k1GNHwG41IIUQ=
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
github.com/youmark/pkcs8 v0.0.0-20181117223130-1be2e3e5546d/go.mod h1:rHwXgn7JulP+udvsHwJoVG1YGAP6VLg4y9I5dyZdqmA=
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM=
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI=
@ -404,13 +390,8 @@ golang.org/x/crypto v0.0.0-20200323165209-0ec3e9974c59/go.mod h1:LzIPMQfyMNhhGPh
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.8.0/go.mod h1:mRqEX+O9/h5TFCrQhkgjo2yKi0yYA+9ecGkdQoHrywE=
golang.org/x/crypto v0.42.0 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI=
golang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8=
golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04=
golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0=
golang.org/x/image v0.5.0 h1:5JMiNunQeQw++mMOz48/ISeNu3Iweh/JaZU8ZLqHRrI=
golang.org/x/image v0.5.0/go.mod h1:FVC7BI/5Ym8R25iw5OLsgshdUBbT1h5jZTpA+mvAdZ4=
golang.org/x/image v0.25.0 h1:Y6uW6rH1y5y/LK1J8BPWZtr6yZ7hrsy6hFrXjgsc2fQ=
golang.org/x/image v0.25.0/go.mod h1:tCAmOEGthTtkalusGp1g3xa2gke8J6c2N565dTyl9Rs=
golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
@ -418,7 +399,6 @@ golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.28.0 h1:gQBtGhjxykdjY9YhZpSlZIsbnaE2+PgjfLWUQTnoZ1U=
golang.org/x/mod v0.28.0/go.mod h1:yfB/L0NOf/kmEbXjzCPOx1iK1fRutOydrCMsqRhEBxI=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
@ -430,10 +410,6 @@ golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v
golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns=
golang.org/x/net v0.45.0 h1:RLBg5JKixCy82FtLJpeNlVM0nrSqpCRYzVU1n8kj0tM=
golang.org/x/net v0.45.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY=
golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4=
golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210=
golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI=
@ -443,7 +419,6 @@ golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJ
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
@ -464,24 +439,17 @@ golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.7.0/go.mod h1:P32HKFT3hSsZrRxla30E9HqToFYAQPCMs/zFMBUFqPY=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
@ -491,7 +459,6 @@ golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roY
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.37.0 h1:DVSRzp7FwePZW356yEAChSdNcQo6Nsp+fex1SUW09lE=
golang.org/x/tools v0.37.0/go.mod h1:MBN5QPQtLMHVdvsbtarmTNukZDdgwdwlO5qGacAzF0w=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=

10
main.go
View File

@ -9,6 +9,7 @@ import (
"git.coopgo.io/coopgo-apps/parcoursmob/core/application"
"git.coopgo.io/coopgo-apps/parcoursmob/renderer"
"git.coopgo.io/coopgo-apps/parcoursmob/servers/mcp"
"git.coopgo.io/coopgo-apps/parcoursmob/servers/web"
"git.coopgo.io/coopgo-apps/parcoursmob/services"
"git.coopgo.io/coopgo-apps/parcoursmob/core/utils/identification"
@ -25,6 +26,7 @@ func main() {
var (
dev_env = cfg.GetBool("dev_env")
webEnabled = cfg.GetBool("server.web.enabled")
mcpEnabled = cfg.GetBool("server.mcp.enabled")
)
if dev_env {
@ -70,5 +72,13 @@ func main() {
}()
}
if mcpEnabled {
wg.Add(1)
go func() {
defer wg.Done()
mcp.Run(cfg, svc, applicationHandler, kv, filestorage)
}()
}
wg.Wait()
}

View File

@ -90,7 +90,7 @@ func (renderer *Renderer) SolidarityTransportDriverDisplay(w http.ResponseWriter
renderer.Render("solidarity transport driver creation", w, r, files, state)
}
func (renderer *Renderer) SolidarityTransportDriverJourney(w http.ResponseWriter, r *http.Request, driverJourney any, driver any, passenger any, beneficiaries any, passengerWalletBalance float64, pricingResult map[string]pricing.Price) {
func (renderer *Renderer) SolidarityTransportDriverJourney(w http.ResponseWriter, r *http.Request, driverJourney any, driver any, passenger any, beneficiaries any, passengerWalletBalance float64, pricingResult map[string]pricing.Price, replacesBookingID string) {
files := renderer.ThemeConfig.GetStringSlice("views.solidarity_transport.driver_journey.files")
bookingMotivations := renderer.GlobalConfig.Get("modules.solidarity_transport.booking_motivations")
state := NewState(r, renderer.ThemeConfig, solidarityTransportMenu)
@ -103,12 +103,13 @@ func (renderer *Renderer) SolidarityTransportDriverJourney(w http.ResponseWriter
"passenger_wallet_balance": passengerWalletBalance,
"pricing_result": pricingResult,
"booking_motivations": bookingMotivations,
"replaces_booking_id": replacesBookingID,
}
renderer.Render("solidarity transport driver creation", w, r, files, state)
}
func (renderer *Renderer) SolidarityTransportBookingDisplay(w http.ResponseWriter, r *http.Request, booking any, driver any, passenger any, passengerWalletBalance float64) {
func (renderer *Renderer) SolidarityTransportBookingDisplay(w http.ResponseWriter, r *http.Request, booking any, driver any, passenger any, passengerWalletBalance float64, replacementDrivers any, replacementDriversMap any, replacementPricing any, replacementLocations any) {
files := renderer.ThemeConfig.GetStringSlice("views.solidarity_transport.booking_display.files")
bookingMotivations := renderer.GlobalConfig.Get("modules.solidarity_transport.booking_motivations")
state := NewState(r, renderer.ThemeConfig, solidarityTransportMenu)
@ -116,8 +117,13 @@ func (renderer *Renderer) SolidarityTransportBookingDisplay(w http.ResponseWrite
"driver": driver,
"passenger": passenger,
"booking": booking,
"config": renderer.GlobalConfig,
"passenger_wallet_balance": passengerWalletBalance,
"booking_motivations": bookingMotivations,
"replacement_drivers": replacementDrivers,
"replacement_drivers_map": replacementDriversMap,
"replacement_pricing": replacementPricing,
"replacement_locations": replacementLocations,
}
renderer.Render("booking display", w, r, files, state)

View File

@ -17,9 +17,11 @@ func (r *XLSXRenderer) SolidarityTransportBookings(w http.ResponseWriter, result
// Build headers dynamically based on configuration
headers := []string{
"ID Réservation",
"ID Groupe",
"Statut",
"Motif de réservation",
"Raison d'annulation",
"Remplacé par (ID)",
"Date de prise en charge",
"Heure de prise en charge",
}
@ -102,6 +104,7 @@ func (r *XLSXRenderer) SolidarityTransportBookings(w http.ResponseWriter, result
// Booking information
row = append(row, booking.Id)
row = append(row, booking.GroupId)
row = append(row, booking.Status)
// Motivation (from booking.Data)
@ -122,6 +125,15 @@ func (r *XLSXRenderer) SolidarityTransportBookings(w http.ResponseWriter, result
}
row = append(row, cancellationReason)
// Replaced by (from booking.Data)
replacedBy := ""
if booking.Data != nil {
if replacedByVal, ok := booking.Data["replaced_by"]; ok && replacedByVal != nil {
replacedBy = fmt.Sprint(replacedByVal)
}
}
row = append(row, replacedBy)
// Journey date and time
if booking.Journey != nil {
row = append(row, booking.Journey.PassengerPickupDate.Format("2006-01-02"))

221
servers/mcp/README.md Normal file
View File

@ -0,0 +1,221 @@
# MCP Server for PARCOURSMOB
This package implements a Model Context Protocol (MCP) HTTP server for the PARCOURSMOB application, exposing journey search functionality as an MCP tool.
## Overview
The MCP server provides a standardized interface for AI assistants to search for multimodal journeys, including:
- Public transit routes
- Carpooling solutions (via operators like Mobicoop)
- Solidarity transport
- Organized carpools
- Fleet vehicles
- Local knowledge base solutions
## Configuration
Enable the MCP server in your config file:
```yaml
server:
mcp:
enabled: true
listen: "0.0.0.0:8081"
```
Or via environment variables:
```bash
export SERVER_MCP_ENABLED=true
export SERVER_MCP_LISTEN="0.0.0.0:8081"
```
## API Endpoints
### Initialize
```http
POST /mcp/v1/initialize
```
Returns server capabilities and protocol version.
### List Tools
```http
GET /mcp/v1/tools/list
```
Returns available MCP tools.
### Call Tool
```http
POST /mcp/v1/tools/call
Content-Type: application/json
{
"name": "search_journeys",
"arguments": {
"departure": "123 Main St, Paris, France",
"destination": "456 Oak Ave, Lyon, France",
"departure_date": "2025-01-20",
"departure_time": "14:30"
}
}
```
### Health Check
```http
GET /health
```
## Available Tools
### search_journeys
Searches for multimodal journey options between two locations.
**Parameters:**
- `departure` (string, required): Departure address as text
- `destination` (string, required): Destination address as text
- `departure_date` (string, required): Date in YYYY-MM-DD format
- `departure_time` (string, required): Time in HH:MM format (24-hour)
- `passenger_id` (string, optional): Passenger ID to retrieve address from account
- `exclude_driver_ids` (array, optional): List of driver IDs to exclude from solidarity transport results
**Example Request:**
```bash
curl -X POST http://localhost:8081/mcp/v1/tools/call \
-H "Content-Type: application/json" \
-d '{
"name": "search_journeys",
"arguments": {
"departure": "Gare de Lyon, Paris",
"destination": "Part-Dieu, Lyon",
"departure_date": "2025-01-20",
"departure_time": "09:00"
}
}'
```
**Response Format:**
```json
{
"search_parameters": {
"departure": {
"label": "Gare de Lyon, Paris, France",
"coordinates": { "type": "Point", "coordinates": [2.3736, 48.8443] }
},
"destination": {
"label": "Part-Dieu, Lyon, France",
"coordinates": { "type": "Point", "coordinates": [4.8575, 45.7605] }
},
"departure_date": "2025-01-20",
"departure_time": "09:00"
},
"results": {
"CarpoolResults": [...],
"TransitResults": [...],
"VehicleResults": [...],
"DriverJourneys": [...],
"OrganizedCarpools": [...],
"KnowledgeBaseResults": [...]
}
}
```
## Architecture
### Package Structure
- `mcp.go`: HTTP server and request routing
- `tools.go`: Tool registration and execution
- `journey_search.go`: Journey search tool implementation
### Flow
1. HTTP request received at MCP endpoint
2. Tool name and arguments extracted
3. Addresses geocoded using Pelias geocoding service
4. Journey search executed via ApplicationHandler
5. Results formatted and returned as JSON
### Dependencies
The MCP server uses:
- Pelias geocoding service (configured via `geo.pelias.url`)
- ApplicationHandler for journey search business logic
- All backend services (GRPC): solidarity transport, carpool service, transit routing, fleets, etc.
## Integration with AI Assistants
The MCP server follows the Model Context Protocol specification, making it compatible with AI assistants that support MCP, such as Claude Desktop or other MCP-enabled tools.
Example Claude Desktop configuration:
```json
{
"mcpServers": {
"parcoursmob": {
"url": "http://localhost:8081/mcp/v1"
}
}
}
```
## Development
### Testing
Test the health endpoint:
```bash
curl http://localhost:8081/health
```
List available tools:
```bash
curl http://localhost:8081/mcp/v1/tools/list
```
Test journey search:
```bash
curl -X POST http://localhost:8081/mcp/v1/tools/call \
-H "Content-Type: application/json" \
-d '{
"name": "search_journeys",
"arguments": {
"departure": "Paris",
"destination": "Lyon",
"departure_date": "2025-01-20",
"departure_time": "10:00"
}
}'
```
### Adding New Tools
To add a new MCP tool:
1. Define the tool in `tools.go`:
```go
func (h *ToolsHandler) registerNewTool() {
tool := &Tool{
Name: "tool_name",
Description: "Tool description",
InputSchema: map[string]any{...},
}
h.tools["tool_name"] = tool
}
```
2. Implement the handler:
```go
func (h *ToolsHandler) handleNewTool(ctx context.Context, arguments map[string]any) (any, error) {
// Implementation
}
```
3. Add to CallTool switch statement in `tools.go`
## Notes
- All times are handled in Europe/Paris timezone
- Geocoding uses the first result from Pelias
- Journey search runs multiple transport mode queries in parallel
- Results include all available transport options for the requested route

158
servers/mcp/journey_tool.go Normal file
View File

@ -0,0 +1,158 @@
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 ""
}

109
servers/mcp/mcp.go Normal file
View File

@ -0,0 +1,109 @@
package mcp
import (
"net/http"
"time"
mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp"
"github.com/rs/zerolog/log"
"github.com/spf13/viper"
"git.coopgo.io/coopgo-apps/parcoursmob/core/application"
"git.coopgo.io/coopgo-apps/parcoursmob/core/utils/geo"
cache "git.coopgo.io/coopgo-apps/parcoursmob/core/utils/storage"
"git.coopgo.io/coopgo-apps/parcoursmob/services"
)
// MCPServer represents the MCP HTTP server
type MCPServer struct {
cfg *viper.Viper
services *services.ServicesHandler
kv cache.KVHandler
filestorage cache.FileStorage
applicationHandler *application.ApplicationHandler
mcpServer *mcpsdk.Server
geoService *geo.GeoService
}
// NewMCPServer creates a new MCP server instance
func NewMCPServer(
cfg *viper.Viper,
svc *services.ServicesHandler,
applicationHandler *application.ApplicationHandler,
kv cache.KVHandler,
filestorage cache.FileStorage,
) *MCPServer {
// Initialize geocoding service
geoType := cfg.GetString("geo.type")
baseURL := cfg.GetString("geo." + geoType + ".url")
autocompleteEndpoint := cfg.GetString("geo." + geoType + ".autocomplete")
geoService := geo.NewGeoService(geoType, baseURL, autocompleteEndpoint)
server := &MCPServer{
cfg: cfg,
services: svc,
kv: kv,
filestorage: filestorage,
applicationHandler: applicationHandler,
geoService: geoService,
}
// Create MCP server with implementation info
server.mcpServer = mcpsdk.NewServer(&mcpsdk.Implementation{
Name: "parcoursmob-mcp-server",
Version: "1.0.0",
}, nil)
// Register journey search tool
server.registerJourneySearchTool()
return server
}
// Run starts the MCP HTTP server with SSE transport
func Run(
cfg *viper.Viper,
svc *services.ServicesHandler,
applicationHandler *application.ApplicationHandler,
kv cache.KVHandler,
filestorage cache.FileStorage,
) {
address := cfg.GetString("server.mcp.listen")
service_name := cfg.GetString("service_name")
mcpServer := NewMCPServer(cfg, svc, applicationHandler, kv, filestorage)
// Create HTTP server with SSE transport
mux := http.NewServeMux()
// Health check endpoint
mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"status":"healthy"}`))
})
// MCP Streamable HTTP endpoint (preferred over SSE as of 2025-03-26 spec)
streamHandler := mcpsdk.NewStreamableHTTPHandler(func(r *http.Request) *mcpsdk.Server {
return mcpServer.mcpServer
}, nil)
mux.Handle("/", streamHandler)
// Also support legacy SSE endpoint for backwards compatibility
sseHandler := mcpsdk.NewSSEHandler(func(r *http.Request) *mcpsdk.Server {
return mcpServer.mcpServer
}, nil)
mux.Handle("/sse", sseHandler)
srv := &http.Server{
Handler: mux,
Addr: address,
WriteTimeout: 60 * time.Second,
ReadTimeout: 30 * time.Second,
}
log.Info().Str("service_name", service_name).Str("address", address).Msg("Running MCP HTTP server with SSE transport")
err := srv.ListenAndServe()
log.Error().Err(err).Msg("MCP server error")
}

View File

@ -1,7 +1,6 @@
package api
import (
"encoding/json"
"net/http"
)
@ -14,13 +13,13 @@ func (h *Handler) GeoAutocomplete(w http.ResponseWriter, r *http.Request) {
text := t[0]
result, err := h.geoService.Autocomplete(text)
featureCollection, err := h.geoService.Autocomplete(text)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
return
}
j, err := json.Marshal(result.Features)
j, err := featureCollection.MarshalJSON()
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
return

View File

@ -21,7 +21,12 @@ type Handler struct {
func NewHandler(cfg *viper.Viper, idp *identification.IdentificationProvider, appHandler *application.ApplicationHandler, cacheHandler cacheStorage.CacheHandler) *Handler {
cacheService := cache.NewCacheService(cacheHandler)
geoService := geo.NewGeoService(cfg.GetString("geo.pelias.url"))
// Get geocoding configuration
geoType := cfg.GetString("geo.type")
baseURL := cfg.GetString("geo." + geoType + ".url")
autocompleteEndpoint := cfg.GetString("geo." + geoType + ".autocomplete")
geoService := geo.NewGeoService(geoType, baseURL, autocompleteEndpoint)
return &Handler{
config: cfg,

View File

@ -27,4 +27,5 @@ func (ws *WebServer) setupSolidarityTransportRoutes(appRouter *mux.Router) {
solidarityTransport.HandleFunc("/bookings/{bookingid}/confirm", ws.appHandler.SolidarityTransportBookingStatusHTTPHandler("confirm"))
solidarityTransport.HandleFunc("/bookings/{bookingid}/cancel", ws.appHandler.SolidarityTransportBookingStatusHTTPHandler("cancel"))
solidarityTransport.HandleFunc("/bookings/{bookingid}/waitconfirmation", ws.appHandler.SolidarityTransportBookingStatusHTTPHandler("waitconfirmation"))
solidarityTransport.HandleFunc("/bookings/{bookingid}/create-replacement", ws.appHandler.SolidarityTransportCreateReplacementBookingHTTPHandler())
}

View File

@ -105,6 +105,8 @@ func (h *Handler) JourneysSearchHTTPHandler() http.HandlerFunc {
destinationGeo,
passengerID,
solidarityTransportExcludeDriver,
"", // solidarityExcludeGroupId - for modal search replacement only
nil, // options - use defaults
)
if err != nil {
log.Error().Err(err).Msg("error in journey search")
@ -389,6 +391,8 @@ func (h *Handler) JourneysSearchCompactHTTPHandler() http.HandlerFunc {
destinationGeo,
passengerID,
solidarityTransportExcludeDriver,
"", // solidarityExcludeGroupId - for modal search replacement only
nil, // options - use defaults
)
if err != nil {
log.Error().Err(err).Msg("error in journey search")

View File

@ -10,6 +10,8 @@ import (
"time"
groupstorage "git.coopgo.io/coopgo-platform/groups-management/storage"
mobilityaccountsstorage "git.coopgo.io/coopgo-platform/mobility-accounts/storage"
gen "git.coopgo.io/coopgo-platform/solidarity-transport/servers/grpc/proto/gen"
"git.coopgo.io/coopgo-apps/parcoursmob/core/utils/identification"
"github.com/gorilla/mux"
"github.com/rs/zerolog/log"
@ -520,6 +522,7 @@ func (h *Handler) SolidarityTransportDriverJourneyHTTPHandler() http.HandlerFunc
driverID := vars["driverid"]
journeyID := vars["journeyid"]
passengerID := r.URL.Query().Get("passengerid")
replacesBookingID := r.URL.Query().Get("replaces_booking_id")
if r.Method == "POST" {
// Parse form data
@ -531,7 +534,8 @@ func (h *Handler) SolidarityTransportDriverJourneyHTTPHandler() http.HandlerFunc
fmt.Sscanf(r.PostFormValue("return_waiting_time"), "%d", &returnWaitingTimeMinutes)
}
bookingID, err := h.applicationHandler.CreateSolidarityTransportJourneyBooking(r.Context(), driverID, journeyID, passengerID, motivation, message, doNotSend, returnWaitingTimeMinutes)
replacesBookingID := r.PostFormValue("replaces_booking_id")
bookingID, err := h.applicationHandler.CreateSolidarityTransportJourneyBooking(r.Context(), driverID, journeyID, passengerID, motivation, message, doNotSend, returnWaitingTimeMinutes, replacesBookingID)
if err != nil {
log.Error().Err(err).Msg("error creating solidarity transport journey booking")
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
@ -559,7 +563,7 @@ func (h *Handler) SolidarityTransportDriverJourneyHTTPHandler() http.HandlerFunc
return
}
h.renderer.SolidarityTransportDriverJourney(w, r, result.Journey, result.Driver, result.Passenger, result.Beneficiaries, result.PassengerWalletBalance, result.PricingResult)
h.renderer.SolidarityTransportDriverJourney(w, r, result.Journey, result.Driver, result.Passenger, result.Beneficiaries, result.PassengerWalletBalance, result.PricingResult, replacesBookingID)
}
}
@ -592,7 +596,141 @@ func (h *Handler) SolidarityTransportBookingDisplayHTTPHandler() http.HandlerFun
return
}
h.renderer.SolidarityTransportBookingDisplay(w, r, result.Booking, result.Driver, result.Passenger, result.PassengerWalletBalance)
// If booking is cancelled, search for replacement drivers
var replacementDrivers any
var replacementDriversMap map[string]mobilityaccountsstorage.Account
var replacementPricing map[string]map[string]interface{}
var replacementLocations map[string]string
if result.Booking.Status == "CANCELLED" {
// Initialize maps to avoid nil pointer in template
replacementDriversMap = make(map[string]mobilityaccountsstorage.Account)
replacementPricing = make(map[string]map[string]interface{})
replacementLocations = make(map[string]string)
searchResult, err := h.applicationHandler.SearchJourneys(
r.Context(),
result.Booking.Journey.PassengerPickupDate,
result.Booking.Journey.PassengerPickup,
result.Booking.Journey.PassengerDrop,
result.Booking.PassengerId,
result.Booking.DriverId, // Exclude the original driver
result.Booking.GroupId, // Exclude drivers with bookings in this group
nil, // options - use defaults
)
if err == nil {
replacementDrivers = searchResult.DriverJourneys
replacementDriversMap = searchResult.Drivers
// Calculate pricing for each replacement driver journey
for _, dj := range searchResult.DriverJourneys {
// Extract driver departure location
if dj.DriverDeparture != nil && dj.DriverDeparture.Serialized != "" {
var feature map[string]interface{}
if err := json.Unmarshal([]byte(dj.DriverDeparture.Serialized), &feature); err == nil {
if props, ok := feature["properties"].(map[string]interface{}); ok {
if name, ok := props["name"].(string); ok {
replacementLocations[dj.Id] = name
} else if label, ok := props["label"].(string); ok {
replacementLocations[dj.Id] = label
}
}
}
}
pricingResult, err := h.applicationHandler.CalculateSolidarityTransportPricing(r.Context(), dj, result.Booking.PassengerId)
if err == nil {
pricing := map[string]interface{}{
"passenger": map[string]interface{}{
"amount": pricingResult["passenger"].Amount,
"currency": pricingResult["passenger"].Currency,
},
"driver": map[string]interface{}{
"amount": pricingResult["driver"].Amount,
"currency": pricingResult["driver"].Currency,
},
}
replacementPricing[dj.Id] = pricing
}
}
}
}
h.renderer.SolidarityTransportBookingDisplay(w, r, result.Booking, result.Driver, result.Passenger, result.PassengerWalletBalance, replacementDrivers, replacementDriversMap, replacementPricing, replacementLocations)
}
}
func (h *Handler) SolidarityTransportCreateReplacementBookingHTTPHandler() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
return
}
vars := mux.Vars(r)
oldBookingID := vars["bookingid"]
// Get the old booking to retrieve its data
oldBookingResult, err := h.applicationHandler.GetSolidarityTransportBookingData(r.Context(), oldBookingID)
if err != nil {
log.Error().Err(err).Msg("error retrieving old booking")
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
// Parse form data for new driver/journey
driverID := r.PostFormValue("driver_id")
journeyID := r.PostFormValue("journey_id")
message := r.PostFormValue("message")
doNotSend := r.PostFormValue("do_not_send") == "on"
// Use old booking's data
passengerID := oldBookingResult.Booking.PassengerId
motivation := ""
if oldBookingResult.Booking.Data != nil {
if m, ok := oldBookingResult.Booking.Data["motivation"].(string); ok {
motivation = m
}
}
// Get the new driver journey to retrieve journey information
driverJourneyResp, err := h.services.GRPC.SolidarityTransport.GetDriverJourney(r.Context(), &gen.GetDriverJourneyRequest{
DriverId: driverID,
JourneyId: journeyID,
})
if err != nil {
log.Error().Err(err).Msg("error retrieving new driver journey")
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
// Calculate return waiting time based on journey type
returnWaitingTimeMinutes := 30 // Default for round trips
if driverJourneyResp.DriverJourney.Noreturn {
returnWaitingTimeMinutes = 0
}
// Create the replacement booking with pricing calculated in CreateSolidarityTransportJourneyBooking
bookingID, err := h.applicationHandler.CreateSolidarityTransportJourneyBooking(
r.Context(),
driverID,
journeyID,
passengerID,
motivation,
message, // message from form
doNotSend, // doNotSend from form checkbox
returnWaitingTimeMinutes,
oldBookingID,
)
if err != nil {
log.Error().Err(err).Msg("error creating replacement booking")
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
log.Info().Str("booking_id", bookingID).Str("replaces", oldBookingID).Msg("Replacement booking created successfully")
// Redirect to the new booking
http.Redirect(w, r, fmt.Sprintf("/app/solidarity-transport/bookings/%s", bookingID), http.StatusFound)
}
}