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

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