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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +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 |

## Architecture en bref

Expand Down
6 changes: 3 additions & 3 deletions docs/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,12 +28,12 @@
| `PROXY_ENDPOINT` | Chemin exposé par le proxy geodata. | `/api/v1/proxy` |
| `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`), 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. | `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` |

## Génération de `PROXY_URL_SECRET`

Pour produire des URLs opaques d'affichage cartographique (tools `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_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).

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_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_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.

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
110 changes: 109 additions & 1 deletion 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` et `gpf_get_feature_by_id_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_isochrone_layer`) | Le tool interroge des sources externes ou ouvertes, dont le contenu peut évoluer. |

## Liste des tools

Expand All @@ -52,6 +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)
- [`distance`](#distance)

## `geocode`
Expand Down Expand Up @@ -2214,6 +2215,113 @@ 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`

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

### Titre

Couche cartographiable d’isochrone 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.
À 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)).
```

### Schéma d’entrée

| Champ | Type | Requis | Description |
| --- | --- | --- | --- |
| `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. |

<details>
<summary>Schéma d’entrée brut</summary>

```json
{
"type": "object",
"properties": {
"lon": {
"type": "number",
"minimum": -180,
"maximum": 180,
"description": "Longitude du point de départ en WGS84 `lon/lat`."
},
"lat": {
"type": "number",
"minimum": -90,
"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`)."
}
},
"required": [
"lon",
"lat",
"minutes",
"profile"
],
"additionalProperties": false,
"$schema": "http://json-schema.org/draft-07/schema#"
}
```

</details>

### Schéma de sortie

| Champ | Type | Requis | Description |
| --- | --- | --- | --- |
| `data_url` | string | oui | URL renvoyant une FeatureCollection GeoJSON (géométries complètes) prête à être affichée dans un outil cartographique. |

<details>
<summary>Schéma de sortie brut</summary>

```json
{
"type": "object",
"properties": {
"data_url": {
"type": "string",
"description": "URL renvoyant une FeatureCollection GeoJSON (géométries complètes) prête à être affichée dans un outil cartographique.",
"format": "uri"
}
},
"required": [
"data_url"
]
}
```

</details>

### Réponse MCP

| Cas | `content` | `structuredContent` | Relation entre `content` et `structuredContent` |
| --- | --- | --- | --- |
| 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). |

## `distance`

Code Source : [src/tools/DistanceTool.ts](../src/tools/DistanceTool.ts)
Expand Down
1 change: 1 addition & 0 deletions scripts/generate-mcp-docs.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ const toolDisplayOrder = [
"gpf_count_features",
"gpf_get_feature_by_id",
"gpf_get_feature_by_id_layer",
"gpf_isochrone_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,14 +3,14 @@ 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 { TRAVEL_TIME_PROFILES, TRAVEL_TIME_RESOURCE } from "./navigation.js";
import { NAVIGATION_PROFILES, NAVIGATION_ISOCHRONE_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 = TRAVEL_TIME_RESOURCE;
export const ITINERARY_PROFILES = TRAVEL_TIME_PROFILES;
export const ITINERARY_RESOURCE = NAVIGATION_ISOCHRONE_RESOURCE;
export const ITINERARY_PROFILES = NAVIGATION_PROFILES;
export const ITINERARY_METRICS = ["time", "distance"] as const;

export type ItineraryProfile = typeof ITINERARY_PROFILES[number];
Expand Down
29 changes: 16 additions & 13 deletions src/gpf/navigation.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { fetchJSONGet } from "../helpers/http.js";
import { fetchJSONGet, ServiceResponseError } from "../helpers/http.js";
import logger from "../logger.js";
import type { JsonFetcher } from "../helpers/http.js";
import type { Geometry } from "geojson";
Expand All @@ -8,18 +8,19 @@ 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 TRAVEL_TIME_RESOURCE = "bdtopo-valhalla";
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_PROFILES = ["car", "pedestrian"] as const;
export const TRAVEL_TIME_MAX_MINUTES = 120;
export const TRAVEL_TIME_PROFILES = ["car", "pedestrian"] as const;

export type TravelTimeProfile = typeof TRAVEL_TIME_PROFILES[number];
export type NavigationProfile = typeof NAVIGATION_PROFILES[number];


export type TravelTimeGeometryInput = {
export type IsochroneInput = {
lon: number;
lat: number;
minutes: number;
profile: TravelTimeProfile;
profile: NavigationProfile;
};

export class NavigationIsochroneClient {
Expand All @@ -28,12 +29,12 @@ export class NavigationIsochroneClient {
private fetcher: JsonFetcher<{geometry?: unknown}> = fetchJSONGet,
) {}

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

const url = `${NAVIGATION_ISOCHRONE_URL}?${new URLSearchParams({
resource: TRAVEL_TIME_RESOURCE,
resource: NAVIGATION_ISOCHRONE_RESOURCE,
point: `${input.lon},${input.lat}`,
direction: "departure",
costType: "time",
Expand All @@ -47,7 +48,9 @@ export class NavigationIsochroneClient {

const json = await this.fetcher(url);
if (!isGeometryLike(json.geometry)) {
throw new Error("Le service d'isochrone n'a pas renvoyé de géométrie GeoJSON exploitable.");
throw new ServiceResponseError("Le service d'isochrone n'a pas renvoyé de géométrie GeoJSON exploitable.", {
http: { status: 502, statusText: "Bad Gateway" },
});
}

return json.geometry;
Expand All @@ -62,7 +65,7 @@ function getDefaultNavigationIsochroneClient() {
}

export const navigationIsochroneClient = {
getTravelTimeGeometry(input: TravelTimeGeometryInput) {
return getDefaultNavigationIsochroneClient().getTravelTimeGeometry(input);
getIsochrone(input: IsochroneInput) {
return getDefaultNavigationIsochroneClient().getIsochrone(input);
},
};
50 changes: 47 additions & 3 deletions src/proxy/execute.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/**
* Proxy-side WFS execution engine.
* Proxy-side WFS and isochrone execution engine.
*
* `runGeometryFeatureQuery` (entry point) compiles and runs the layer query;
* `resolveReferenceGeometry` (internal helper) resolves the reference geometry
Expand Down Expand Up @@ -37,8 +37,12 @@ import { resolveFeatureGeometry } from "../wfs/referenceGeometry.js";
import { rethrowIdentifiedCatalogDesyncError } from "../wfs/catalogDesync.js";
import { ServiceResponseError, extractJsonServiceError } from "../helpers/http.js";
import type { WfsFeatureCollectionResponse } from "../wfs/types.js";
import type { GpfGetFeaturesInput, GpfGetFeatureByIdLayerInput } from "../wfs/schema.js";
import type { Geometry } from "geojson";
import type { FeatureCollection, Geometry } from "geojson";
import type {
GpfGetFeaturesInput,
GpfGetFeatureByIdLayerInput,
GpfIsochroneLayerInput,
} from "../wfs/schema.js";

// --- Injected Dependencies ---

Expand Down Expand Up @@ -302,3 +306,43 @@ export async function runGeometryFeatureByIdQuery(
numberMatched: 1,
};
}

// --- Isochrone Public Engine ---

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

export type GeometryIsochroneQueryDeps = {
getGeometry: IsochroneGeometryResolver;
};

/**
* Resolves an isochrone 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.
* 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.
*/
export async function runGeometryIsochroneQuery(
input: GpfIsochroneLayerInput,
deps: GeometryIsochroneQueryDeps,
): Promise<FeatureCollection> {
const geometry = await deps.getGeometry(input);

return {
type: "FeatureCollection" as const,
features: [
{
type: "Feature" as const,
geometry,
properties: input,
}
]
};
}
Loading
Loading