Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -197,7 +197,7 @@ Les fonctionnalités correspondent aux outils MCP documentés dans [`docs/mcp-to
| Récupérer un objet par identifiant | `gpf_get_feature_by_id` | [WFS](https://cartes.gouv.fr/aide/fr/guides-utilisateur/utiliser-les-services-de-la-geoplateforme/diffusion/wfs/) | Charger une commune précise |
| Télécharger le résultat d'une interrogation de couche GPF | `gpf_get_features_layer` | [WFS](https://cartes.gouv.fr/aide/fr/guides-utilisateur/utiliser-les-services-de-la-geoplateforme/diffusion/wfs/) + [isochrone](https://cartes.gouv.fr/aide/fr/guides-utilisateur/utiliser-les-services-de-la-geoplateforme/calcul-isochrone-isodistance/) | Cartographier un résultat |
| Télécharger un objet par identifiant | `gpf_get_feature_by_id_layer` | [WFS](https://cartes.gouv.fr/aide/fr/guides-utilisateur/utiliser-les-services-de-la-geoplateforme/diffusion/wfs/) | Cartographier un objet |
| Télécharger une isochrone | `gpf_isochrone_layer` | [isochrone](https://cartes.gouv.fr/aide/fr/guides-utilisateur/utiliser-les-services-de-la-geoplateforme/calcul-isochrone-isodistance/) | Cartographier une desserte |
| Télécharger une isochrone ou une isodistance | `gpf_isoline_layer` | [isochrone](https://cartes.gouv.fr/aide/fr/guides-utilisateur/utiliser-les-services-de-la-geoplateforme/calcul-isochrone-isodistance/) | Cartographier une desserte |

## Architecture en bref

Expand Down
4 changes: 2 additions & 2 deletions docs/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,11 +29,11 @@
| `PROXY_PUBLIC_BASE_URL` | URL de base publiquement joignable du proxy, utilisée pour construire la `data_url` absolue transmise à Carto. Derrière un reverse-proxy, elle diffère de l'adresse d'écoute ; en développement local, c'est typiquement `http://localhost:3002`. Requise avec `PROXY_URL_SECRET` pour activer les tools `*_layer`. | Aucune |
| `GPF_WFS_PROXY_RATE_LIMIT` | Limite de requêtes/s du proxy vers le WFS, distincte de `GPF_WFS_RATE_LIMIT`. Les deux comptent sur le même service IGN : répartir une seule allocation entre les deux. | `10` |
| `GPF_NAVIGATION_PROXY_RATE_LIMIT` | Limite de requêtes/s du proxy vers le service d'isochrone (filtre `travel_time` et `gpf_isochrone_layer`), distincte de `GPF_NAVIGATION_RATE_LIMIT`. Les deux comptent sur le même service IGN : répartir une seule allocation entre les deux. | `5` |
| `PROXY_UPSTREAM_TIMEOUT` | Délai (secondes) des appels amont du proxy (WFS **et** isochrone), plus court que `HTTP_TIMEOUT` pour qu'une requête à 2 appels (`intersects_feature` ou `travel_time`) reste sous le délai du navigateur/Carto. Borne aussi `gpf_isochrone_layer` : une isochrone `car` de longue durée peut le dépasser (erreur 504). | `10` |
| `PROXY_UPSTREAM_TIMEOUT` | Délai (secondes) des appels amont du proxy (WFS **et** isochrone), plus court que `HTTP_TIMEOUT` pour qu'une requête à 2 appels (`intersects_feature` ou `travel_time`) reste sous le délai du navigateur/Carto. Borne aussi `gpf_isoline_layer` : une isochrone `car` de longue durée peut le dépasser (erreur 504). | `10` |

## Génération de `PROXY_URL_SECRET`

Pour produire des URLs opaques d'affichage cartographique (tools `gpf_isochrone_layer`, `gpf_get_features_layer` et `gpf_get_feature_by_id_layer`), geocontext chiffre les paramètres de requête avec une clé symétrique AES-256, fournie via `PROXY_URL_SECRET`. La même clé est utilisée par le MCP (pour signer) et par le proxy geodata (pour déchiffrer).
Pour produire des URLs opaques d'affichage cartographique (tools `gpf_isoline_layer`, `gpf_get_features_layer` et `gpf_get_feature_by_id_layer`), geocontext chiffre les paramètres de requête avec une clé symétrique AES-256, fournie via `PROXY_URL_SECRET`. La même clé est utilisée par le MCP (pour signer) et par le proxy geodata (pour déchiffrer).

La clé doit être une valeur aléatoire de **32 octets encodée en hexadécimal** (soit 64 caractères `0-9a-f`). Générez-la avec :

Expand Down
2 changes: 1 addition & 1 deletion docs/dev.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ Avec certains clients MCP, vous serez amené à éditer un fichier JSON. Par exe

## Activer les tools cartographiques en local

Les tools `gpf_isochrone_layer`, `gpf_get_features_layer` et `gpf_get_feature_by_id_layer` renvoient une `data_url` opaque, servie par le **proxy geodata**, un processus séparé du serveur MCP. Ces tools sont listés dans tous les transports mais échouent tant qu'aucun proxy geodata joignable n'est configuré. Comme le proxy geodata est **indépendant du transport**, on peut les activer en local (**même en `stdio`**) en lançant les deux composants côte à côte, sans Docker.
Les tools `gpf_isoline_layer`, `gpf_get_features_layer` et `gpf_get_feature_by_id_layer` renvoient une `data_url` opaque, servie par le **proxy geodata**, un processus séparé du serveur MCP. Ces tools sont listés dans tous les transports mais échouent tant qu'aucun proxy geodata joignable n'est configuré. Comme le proxy geodata est **indépendant du transport**, on peut les activer en local (**même en `stdio`**) en lançant les deux composants côte à côte, sans Docker.

Il faut une clé partagée (`PROXY_URL_SECRET`) entre les deux processus, et pointer le MCP vers le proxy geodata local via `PROXY_PUBLIC_BASE_URL`.

Expand Down
54 changes: 32 additions & 22 deletions docs/mcp-tools.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ Annotations MCP exposées dans la définition `tools/list` de chaque tool :
| `readOnlyHint` | oui | Le tool consulte des données sans modifier d'état côté serveur. |
| `destructiveHint` | non | Le tool n'est pas signalé comme destructif. |
| `idempotentHint` | oui | Répéter le même appel ne déclenche pas d'effet de bord supplémentaire attendu. |
| `openWorldHint` | oui (non pour `gpf_search_types`, `gpf_describe_type`, `gpf_get_features_layer`, `gpf_get_feature_by_id_layer` et `gpf_isochrone_layer`) | Le tool interroge des sources externes ou ouvertes, dont le contenu peut évoluer. |
| `openWorldHint` | oui (non pour `gpf_search_types`, `gpf_describe_type`, `gpf_get_features_layer`, `gpf_get_feature_by_id_layer` et `gpf_isoline_layer`) | Le tool interroge des sources externes ou ouvertes, dont le contenu peut évoluer. |

## Liste des tools

Expand All @@ -52,7 +52,7 @@ Annotations MCP exposées dans la définition `tools/list` de chaque tool :
- [`gpf_count_features`](#gpf_count_features)
- [`gpf_get_feature_by_id`](#gpf_get_feature_by_id)
- [`gpf_get_feature_by_id_layer`](#gpf_get_feature_by_id_layer)
- [`gpf_isochrone_layer`](#gpf_isochrone_layer)
- [`gpf_isoline_layer`](#gpf_isoline_layer)
- [`distance`](#distance)

## `geocode`
Expand Down Expand Up @@ -1320,7 +1320,7 @@ Les noms de propriétés **ne peuvent pas être devinés** : ils sont spécifiqu
"car",
"pedestrian"
],
"description": "Mode de déplacement utilisé pour calculer l'isochrone (`car` ou `pedestrian`)."
"description": "Mode de déplacement utilisé pour calculer l'isochrone ou l'isodistance : `car` ou `pedestrian`."
}
},
"required": [
Expand Down Expand Up @@ -1645,7 +1645,7 @@ Mêmes filtres que `gpf_get_features` : `select` pour choisir les propriétés,
"car",
"pedestrian"
],
"description": "Mode de déplacement utilisé pour calculer l'isochrone (`car` ou `pedestrian`)."
"description": "Mode de déplacement utilisé pour calculer l'isochrone ou l'isodistance : `car` ou `pedestrian`."
}
},
"required": [
Expand Down Expand Up @@ -1972,7 +1972,7 @@ Les noms de propriétés utilisés dans `where` **ne peuvent pas être devinés*
"car",
"pedestrian"
],
"description": "Mode de déplacement utilisé pour calculer l'isochrone (`car` ou `pedestrian`)."
"description": "Mode de déplacement utilisé pour calculer l'isochrone ou l'isodistance : `car` ou `pedestrian`."
}
},
"required": [
Expand Down Expand Up @@ -2215,31 +2215,33 @@ Cet outil ne peut renvoyer qu'un unique objet (0 ou plusieurs résultats provoqu
| Succès | oui | oui | `content[0].text` est `JSON.stringify(structuredContent)`. |
| Erreur | oui | non | `content[0].text` porte le message d'erreur ; aucun `structuredContent` n'est ajouté (réservé au `outputSchema` du cas de succès). |

## `gpf_isochrone_layer`
## `gpf_isoline_layer`

Code Source : [src/tools/GpfIsochroneLayerTool.ts](../src/tools/GpfIsochroneLayerTool.ts)
Code Source : [src/tools/GpfIsolineLayerTool.ts](../src/tools/GpfIsolineLayerTool.ts)

### Titre

Couche cartographiable d’isochrone GPF
Couche cartographiable d’isochrone / d'isodistance GPF

### Description du tool

```
Interroge l'isochrone autour d'un point et renvoie une **URL de couche cartographiable** (`data_url`) : une URL opaque, à passer telle quelle à un outil d'affichage cartographique (MCP Carto, ...). L'ouvrir renvoie une FeatureCollection GeoJSON avec une géométrie complète.
Interroge l'isochrone ou l'isodistance autour d'un point : isochrone si `cost_type = "time"`, isodistance si `cost_type = "distance"`.
À utiliser pour afficher ou cartographier une zone de desserte.
Utiliser `lon`/`lat` pour le point de départ, `profile` pour le mode de déplacement et `minutes` pour fixer le seuil maximal.
(source : Géoplateforme (calcul d'isochrone)).
Renvoie une **URL de couche cartographiable** (`data_url`) : une URL opaque, à passer telle quelle à un outil d'affichage cartographique (MCP Carto, ...). L'ouvrir renvoie une FeatureCollection GeoJSON avec une géométrie complète.
Utiliser `lon`/`lat` pour le point de départ, `profile` pour le mode de déplacement, `cost_type` pour choisir le type de calcul et `cost_value` pour fixer le seuil maximal (en minutes si `time`, en mètres si `distance`).
(source : Géoplateforme (calcul d'isochrone / d'isodistance)).
```

### Schéma d’entrée

| Champ | Type | Requis | Description |
| --- | --- | --- | --- |
| `cost_type` | string (enum) | oui | Type de coût utilisé : `time` pour une isochrone, `distance` pour une isodistance. Valeurs : time, distance. |
| `cost_value` | number | oui | Valeur du coût maximal. Interprétée en minutes si `cost_type = "time"` (maximum : 600), et en mètres si `cost_type = "distance"` (maximum : 50000). |
| `lat` | number | oui | Latitude du point de départ en WGS84 `lon/lat`. |
| `lon` | number | oui | Longitude du point de départ en WGS84 `lon/lat`. |
| `minutes` | number | oui | Temps de trajet maximal en minutes. Maximum : 600. |
| `profile` | string (enum) | oui | Mode de déplacement utilisé pour calculer l'isochrone (`car` ou `pedestrian`). Valeurs : car, pedestrian. |
| `profile` | string (enum) | oui | Mode de déplacement utilisé pour calculer l'isochrone ou l'isodistance : `car` ou `pedestrian`. Valeurs : car, pedestrian. |

<details>
<summary>Schéma d’entrée brut</summary>
Expand All @@ -2260,26 +2262,34 @@ Utiliser `lon`/`lat` pour le point de départ, `profile` pour le mode de déplac
"maximum": 90,
"description": "Latitude du point de départ en WGS84 `lon/lat`."
},
"minutes": {
"type": "number",
"exclusiveMinimum": 0,
"maximum": 600,
"description": "Temps de trajet maximal en minutes. Maximum : 600."
},
"profile": {
"type": "string",
"enum": [
"car",
"pedestrian"
],
"description": "Mode de déplacement utilisé pour calculer l'isochrone (`car` ou `pedestrian`)."
"description": "Mode de déplacement utilisé pour calculer l'isochrone ou l'isodistance : `car` ou `pedestrian`."
},
"cost_type": {
"type": "string",
"enum": [
"time",
"distance"
],
"description": "Type de coût utilisé : `time` pour une isochrone, `distance` pour une isodistance."
},
"cost_value": {
"type": "number",
"exclusiveMinimum": 0,
"description": "Valeur du coût maximal. Interprétée en minutes si `cost_type = \"time\"` (maximum : 600), et en mètres si `cost_type = \"distance\"` (maximum : 50000)."
}
},
"required": [
"lon",
"lat",
"minutes",
"profile"
"profile",
"cost_type",
"cost_value"
],
"additionalProperties": false,
"$schema": "http://json-schema.org/draft-07/schema#"
Expand Down
2 changes: 1 addition & 1 deletion scripts/generate-mcp-docs.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ const toolDisplayOrder = [
"gpf_count_features",
"gpf_get_feature_by_id",
"gpf_get_feature_by_id_layer",
"gpf_isochrone_layer",
"gpf_isoline_layer",
];

/**
Expand Down
6 changes: 3 additions & 3 deletions src/gpf/itinerary.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,15 @@ import logger from "../logger.js";
import type { JsonFetcher } from "../helpers/http.js";
import type { RateLimiter } from "../helpers/RateLimiter.js";
import { getNavigationRateLimiter } from "./navigationRateLimiter.js";
import { NAVIGATION_PROFILES, NAVIGATION_ISOCHRONE_RESOURCE } from "./navigation.js";
import { NAVIGATION_METRICS, NAVIGATION_PROFILES, NAVIGATION_ISOLINE_RESOURCE } from "./navigation.js";

export const NAVIGATION_ITINERARY_SOURCE = "Géoplateforme (calcul d'itinéraire)";
export const NAVIGATION_ITINERARY_URL = "https://data.geopf.fr/navigation/itineraire";
// Same engine as the `travel_time_filter` isochrones, so that both report the
// same travel times.
export const ITINERARY_RESOURCE = NAVIGATION_ISOCHRONE_RESOURCE;
export const ITINERARY_RESOURCE = NAVIGATION_ISOLINE_RESOURCE;
export const ITINERARY_PROFILES = NAVIGATION_PROFILES;
export const ITINERARY_METRICS = ["time", "distance"] as const;
export const ITINERARY_METRICS = NAVIGATION_METRICS;

export type ItineraryProfile = typeof ITINERARY_PROFILES[number];
export type ItineraryMetric = typeof ITINERARY_METRICS[number];
Expand Down
47 changes: 26 additions & 21 deletions src/gpf/navigation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,39 +6,44 @@ import { isGeometryLike } from "../helpers/geojson.js";
import type { RateLimiter } from "../helpers/RateLimiter.js";
import { getNavigationRateLimiter } from "./navigationRateLimiter.js";

export const NAVIGATION_SOURCE = "Géoplateforme (calcul d'isochrone)";
export const NAVIGATION_ISOCHRONE_URL = "https://data.geopf.fr/navigation/isochrone";
export const NAVIGATION_ISOCHRONE_RESOURCE = "bdtopo-valhalla";
// Upstream ceiling accepted by the GPF isochrone service for a time cost.
export const NAVIGATION_ISOCHRONE_MAX_TIME_MINUTES = 600;
export const NAVIGATION_SOURCE = "Géoplateforme (calcul d'isochrone / d'isodistance)";
export const NAVIGATION_ISOLINE_URL = "https://data.geopf.fr/navigation/isochrone";
export const NAVIGATION_ISOLINE_RESOURCE = "bdtopo-valhalla";
// Upstream ceilings accepted by the GPF isochrone service, per cost type.
export const NAVIGATION_ISOCHRONE_MAX_MINUTES = 600;
export const NAVIGATION_ISODISTANCE_MAX_METERS = 50_000;
export const NAVIGATION_PROFILES = ["car", "pedestrian"] as const;
export const NAVIGATION_METRICS = ["time", "distance"] as const;

export const TRAVEL_TIME_MAX_MINUTES = 120;

export type NavigationProfile = typeof NAVIGATION_PROFILES[number];
export type NavigationMetric = typeof NAVIGATION_METRICS[number];

export type IsochroneInput = {
export type IsolineInput = {
lon: number;
lat: number;
minutes: number;
cost_type: NavigationMetric;
cost_value: number;
profile: NavigationProfile;
};

export class NavigationIsochroneClient {
export class NavigationIsolineClient {
constructor(
private rateLimiter: RateLimiter,
private fetcher: JsonFetcher<{geometry?: unknown}> = fetchJSONGet,
) {}

async getIsochrone(input: IsochroneInput): Promise<Geometry> {
async getIsoline(input: IsolineInput): Promise<Geometry> {
await this.rateLimiter.limit();
logger.debug(`[gpf:navigation] getIsochrone(${JSON.stringify(input)})...`);
logger.debug(`[gpf:navigation] getIsoline(${JSON.stringify(input)})...`);

const url = `${NAVIGATION_ISOCHRONE_URL}?${new URLSearchParams({
resource: NAVIGATION_ISOCHRONE_RESOURCE,
const url = `${NAVIGATION_ISOLINE_URL}?${new URLSearchParams({
resource: NAVIGATION_ISOLINE_RESOURCE,
point: `${input.lon},${input.lat}`,
direction: "departure",
costType: "time",
costValue: String(input.minutes),
costType: input.cost_type,
costValue: String(input.cost_value),
profile: input.profile,
timeUnit: "minute",
distanceUnit: "meter",
Expand All @@ -57,15 +62,15 @@ export class NavigationIsochroneClient {
}
}

let defaultNavigationIsochroneClient: NavigationIsochroneClient | undefined;
let defaultNavigationIsolineClient: NavigationIsolineClient | undefined;

function getDefaultNavigationIsochroneClient() {
defaultNavigationIsochroneClient ??= new NavigationIsochroneClient(getNavigationRateLimiter());
return defaultNavigationIsochroneClient;
function getDefaultNavigationIsolineClient() {
defaultNavigationIsolineClient ??= new NavigationIsolineClient(getNavigationRateLimiter());
return defaultNavigationIsolineClient;
}

export const navigationIsochroneClient = {
getIsochrone(input: IsochroneInput) {
return getDefaultNavigationIsochroneClient().getIsochrone(input);
export const navigationIsolineClient = {
getIsoline(input: IsolineInput) {
return getDefaultNavigationIsolineClient().getIsoline(input);
},
};
28 changes: 14 additions & 14 deletions src/proxy/execute.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ import type { FeatureCollection, Geometry } from "geojson";
import type {
GpfGetFeaturesInput,
GpfGetFeatureByIdLayerInput,
GpfIsochroneLayerInput,
GpfIsolineLayerInput,
} from "../wfs/schema.js";

// --- Injected Dependencies ---
Expand Down Expand Up @@ -309,29 +309,29 @@ export async function runGeometryFeatureByIdQuery(

// --- Isochrone Public Engine ---

export type IsochroneGeometryResolver = (
input: GpfIsochroneLayerInput,
export type IsolineGeometryResolver = (
input: GpfIsolineLayerInput,
) => Promise<Geometry>;

export type GeometryIsochroneQueryDeps = {
getGeometry: IsochroneGeometryResolver;
export type GeometryIsolineQueryDeps = {
getGeometry: IsolineGeometryResolver;
};

/**
* Resolves an isochrone and returns it as a GeoJSON `FeatureCollection` with full
* geometry (for map rendering by MCP Carto).
* Resolves an isoline (isochrone or isodistance) and returns it as a
* GeoJSON `FeatureCollection` with full geometry (for map rendering by MCP Carto).
*
* Counterpart of {@link runGeometryFeatureQuery} for the isochrone producer tool.
* Counterpart of {@link runGeometryFeatureQuery} for the isoline producer tool.
* The request params are echoed into `properties` so the rendered layer carries
* its own legend.
*
* @param input Validated isochrone layer input (`{ lon, lat, profile, minutes }`).
* @param deps Injected isochrone geometry resolver.
* @returns The isochrone as a single GeoJSON FeatureCollection.
* @param input Validated isoline layer input (`{ lon, lat, profile, cost_type, cost_value }`).
* @param deps Injected isoline geometry resolver.
* @returns The isoline as a GeoJSON FeatureCollection.
*/
export async function runGeometryIsochroneQuery(
input: GpfIsochroneLayerInput,
deps: GeometryIsochroneQueryDeps,
export async function runGeometryIsolineQuery(
input: GpfIsolineLayerInput,
deps: GeometryIsolineQueryDeps,
): Promise<FeatureCollection> {
const geometry = await deps.getGeometry(input);

Expand Down
Loading
Loading