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
4 changes: 2 additions & 2 deletions .github/workflows/node.js.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@ name: Node.js CI

on:
push:
branches: [ "main" ]
branches: [ "main", "release/**" ]
pull_request:
branches: [ "main" ]
branches: [ "main", "release/**" ]

jobs:
build:
Expand Down
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,5 @@
/.vscode/
.npmrc
.codex
.env.e2e
.env.e2e
.continue/
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,7 @@ Les fonctionnalités correspondent aux outils MCP documentés dans [`docs/mcp-to
| Récupérer le cadastre | `cadastre` | [WFS](https://cartes.gouv.fr/aide/fr/guides-utilisateur/utiliser-les-services-de-la-geoplateforme/diffusion/wfs/) + [PARCELLAIRE-EXPRESS](https://cartes.gouv.fr/rechercher-une-donnee/dataset/IGNF_PARCELLAIRE-EXPRESS-PCI) | Parcelle cadastrale |
| Récupérer les documents d'urbanisme | `urbanisme` | [WFS](https://cartes.gouv.fr/aide/fr/guides-utilisateur/utiliser-les-services-de-la-geoplateforme/diffusion/wfs/) + [données GPU](https://www.geoportail-urbanisme.gouv.fr/) | PLU, POS, CC |
| Récupérer les servitudes | `assiette_sup` | [WFS](https://cartes.gouv.fr/aide/fr/guides-utilisateur/utiliser-les-services-de-la-geoplateforme/diffusion/wfs/) + [données GPU](https://www.geoportail-urbanisme.gouv.fr/) | SUP autour d'un lieu |
| Trouver les points d'intérêt proches| `pointsdinteret` | [Géocodage Géoplateforme](https://cartes.gouv.fr/aide/fr/guides-utilisateur/utiliser-les-services-de-la-geoplateforme/geocodage/)
| Trouver une couche WFS | `gpf_wfs_search_types` | [gpf-schema-store](https://github.com/ignfab/gpf-schema-store) | Trouver la table des bâtiments |
| Décrire une couche WFS | `gpf_wfs_describe_type` | [gpf-schema-store](https://github.com/ignfab/gpf-schema-store) | Lister les champs disponibles |
| Interroger une couche WFS | `gpf_wfs_get_features` | [WFS](https://cartes.gouv.fr/aide/fr/guides-utilisateur/utiliser-les-services-de-la-geoplateforme/diffusion/wfs/) | Extraire ou compter des objets |
Expand Down
151 changes: 151 additions & 0 deletions docs/mcp-tools.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ Tous les tools exposent les mêmes annotations MCP dans leur définition `tools/
- [`gpf_wfs_describe_type`](#gpf_wfs_describe_type)
- [`gpf_wfs_get_feature_by_id`](#gpf_wfs_get_feature_by_id)
- [`gpf_wfs_get_features`](#gpf_wfs_get_features)
- [`pointsdinteret`](#pointsdinteret)

## `geocode`

Expand Down Expand Up @@ -1492,3 +1493,153 @@ Aucun `outputSchema` unique n'est exposé. La sortie dépend de `result_type` (`
| Succès `result_type="http_post_request"` | oui | oui | `content[0].text` est `JSON.stringify(structuredContent)`. |
| Succès `result_type="http_get_url"` | oui | oui | `content[0].text` est `JSON.stringify(structuredContent)`. |
| Erreur | oui | oui | `content[0].text` contient `structuredContent.detail`, pas le JSON d'erreur complet de `structuredContent`. |

## `pointsdinteret`

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

### Titre

Points d'intérêt obtenus par géocodage inverse

### Description du tool

```
Renvoie les points d'intérêt les plus proches des coordonnées en entrée.
Le champ `name` contient le nom du point d'intérêt et le champ `categories` liste ses classifications.
Chaque résultat peut aussi inclure les coordonnées du point d'intérêt (`centroid`), sa distance aux coordonnées de départ (`distance`), ainsi que des informations de localisation (`city`, `zipcode`).
Les réultats sont classés par distance, puis par importance : utilisez des coordonnées précises et montez la valeur de `maximumResponses` si l'information ne semble pas assez pertinente.
Pour obtenir un résultat plus détaillé sur un point d'intérêt trouvé, appelez ensuite `wfs_search_types` avec des éléments pertinents de `category`, puis `wfs_get_features` avec le `typename` obtenu et les coordonnées du centroïde.
(source : Géoplateforme (service de géocodage)).
```

### Schéma d’entrée

| Champ | Type | Requis | Description |
| --- | --- | --- | --- |
| `lat` | number | oui | La latitude du point. |
| `lon` | number | oui | La longitude du point. |
| `maximumResponses` | integer | non | Le nombre maximum de résultats à retourner (entre 1 et 20). Défaut : 3. |

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

```json
{
"type": "object",
"properties": {
"lon": {
"type": "number",
"description": "La longitude du point.",
"minimum": -180,
"maximum": 180
},
"lat": {
"type": "number",
"description": "La latitude du point.",
"minimum": -90,
"maximum": 90
},
"maximumResponses": {
"type": "integer",
"description": "Le nombre maximum de résultats à retourner (entre 1 et 20). Défaut : 3.",
"minimum": 1,
"maximum": 20
}
},
"required": [
"lon",
"lat"
]
}
```

</details>

### Schéma de sortie

| Champ | Type | Requis | Description |
| --- | --- | --- | --- |
| `results` | array | oui | La liste des points d'intérêt à proximité, ordonnée par distance. |

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

```json
{
"type": "object",
"properties": {
"results": {
"type": "array",
"description": "La liste des points d'intérêt à proximité, ordonnée par distance.",
"items": {
"type": "object",
"properties": {
"name": {
"type": "string",
"description": "Le nom du point d'intérêt trouvé."
},
"categories": {
"type": "array",
"description": "Les catégories du point d'intérêt.",
"items": {
"type": "string"
}
},
"city": {
"type": "string",
"description": "Le nom de la ville où est le point d'intérêt."
},
"zipcode": {
"type": "string",
"description": "Le code postal du point d'intérêt"
},
"distance": {
"type": "number",
"description": "La distance en mètres entre le point demandé et le point d'intérêt retenu."
},
"centroid": {
"type": "object",
"description": "Les coordonnées du centre du point d'intérêt.",
"properties": {
"lon": {
"type": "number",
"description": "La longitude du point.",
"minimum": -180,
"maximum": 180
},
"lat": {
"type": "number",
"description": "La latitude du point.",
"minimum": -90,
"maximum": 90
}
},
"required": [
"lon",
"lat"
]
}
},
"required": [
"name",
"categories",
"distance"
]
}
}
},
"required": [
"results"
]
}
```

</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 | oui | `content[0].text` contient `structuredContent.detail`, pas le JSON d'erreur complet de `structuredContent`. |
79 changes: 79 additions & 0 deletions src/gpf/pointsdinteret.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { fetchJSONGet } from "../helpers/http.js";
import logger from "../logger.js";
import type { JsonFetcher } from "../helpers/http.js";
import { RateLimiter } from "../helpers/RateLimiter.js";
import { getEnv } from "../config/env.js";

export const POINTSDINTERET_SOURCE = "Géoplateforme (service de géocodage)";

type RawPointsDInteretFeature = {
properties: {
toponym: string;
category: string[];
city?: string[];
postcode?: string[];
distance: number;
};
geometry: {
type: string;
coordinates: number[];
};
}

export type PointsDInteretResult = {
name: string;
categories: string[];
city?: string;
zipcode?: string;
distance: number;
centroid?: {
lon: number,
lat: number
};
};

type RawPointsDInteretResponse = {
features?: RawPointsDInteretFeature[];
};

export class PointsDInteretClient {
constructor(
private rateLimiter: RateLimiter,
private fetcher: JsonFetcher<RawPointsDInteretResponse> = fetchJSONGet,
) {}

/**
* Get the nearest points of interest for given coordinates
*
* @see https://geoservices.ign.fr/documentation/services/services-geoplateforme/geocodage
*/
async pointsdinteret(lon: number, lat: number, maximumResponses = 3): Promise<PointsDInteretResult[]> {
await this.rateLimiter.limit();
logger.debug(`[gpf:pointsdinteret] pointsdinteret(${lon}, ${lat}, ${maximumResponses})...`);

const url = 'https://data.geopf.fr/geocodage/reverse/?' + new URLSearchParams({
lon: String(lon),
lat: String(lat),
index: "poi", // we could also include "parcel" but it is redundant with the cadastre tool
limit: String(maximumResponses),
}).toString();

const json: RawPointsDInteretResponse = await this.fetcher(url);
const results = Array.isArray(json?.features) ? json.features : [];
return results.map((item) => ({
name: item.properties.toponym,
categories: item.properties.category,
city: Array.isArray(item.properties.city) ? item.properties.city[0] : undefined,
zipcode: Array.isArray(item.properties.postcode) ? item.properties.postcode[0] : undefined,
distance: item.properties.distance,
centroid: item.geometry.type == "Point" ? {
lon: item.geometry.coordinates[0],
lat: item.geometry.coordinates[1]
} : undefined,
}));
}
}

export const pointsdinteretClient = new PointsDInteretClient(
new RateLimiter({ name: "GPF_POINTSDINTERET", maxCalls: getEnv().GPF_GEOCODE_RATE_LIMIT, period: 1 }),
);
84 changes: 84 additions & 0 deletions src/tools/PointsDInteretTool.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
/**
* MCP tool exposing reverse geocoding for points of interest.
*/

import BaseTool from "./BaseTool.js";
import { z } from "zod";

import { pointsdinteretClient, POINTSDINTERET_SOURCE } from "../gpf/pointsdinteret.js";
import { READ_ONLY_OPEN_WORLD_TOOL_ANNOTATIONS } from "../helpers/toolAnnotations.js";
import { lonSchema, latSchema } from "../helpers/schemas.js";
import logger from "../logger.js";

// --- Schema ---

const pointsdinteretInputSchema = z.object({
lon: lonSchema,
lat: latSchema,
maximumResponses: z
.number()
.int()
.min(1)
.max(50)
.optional()
.describe("Le nombre maximum de résultats à retourner (entre 1 et 50). Défaut : 3."),
}).strict();

// --- Types ---

type PointsDInteretInput = z.infer<typeof pointsdinteretInputSchema>;

const pointsdinteretResultSchema = z
.object({
name: z.string().describe("Le nom du point d'intérêt trouvé"),
categories: z.array(z.string()).describe("Ses catégories"),
city: z.string().optional().describe("Sa ville"),
zipcode: z.string().optional().describe("Son code postal"),
distance: z.number().describe("La distance en mètres entre le point demandé et le point d'intérêt retenu"),
centroid: z.object({
lon: lonSchema,
lat: latSchema
}).optional().describe("Les coordonnées du centre du point d'intérêt")
})
.catchall(z.unknown());

const pointsdinteretOutputSchema = z.object({
results: z.array(pointsdinteretResultSchema).describe("La liste des points d'intérêt à proximité, ordonnée par distance."),
});

// --- Tool ---

class PointsDInteretTool extends BaseTool<PointsDInteretInput> {
name = "pointsdinteret";
title = "Points d'intérêt obtenus par géocodage inverse";
annotations = READ_ONLY_OPEN_WORLD_TOOL_ANNOTATIONS;
description = [
"Renvoie les points d'intérêt les plus proches des coordonnées en entrée.",
"Le champ `name` contient le nom du point d'intérêt et le champ `categories` liste ses classifications.",
"Chaque résultat peut aussi inclure les coordonnées du point d'intérêt (`centroid`), sa distance aux coordonnées de départ (`distance`), ainsi que des informations de localisation (`city`, `zipcode`).",
"Les réultats sont classés par distance, puis par importance : utilisez des coordonnées précises et montez la valeur de `maximumResponses` si l'information ne semble pas assez pertinente.",
"Pour obtenir un résultat plus détaillé sur un point d'intérêt trouvé, appelez ensuite `wfs_search_types` avec des éléments pertinents de `category`, puis `wfs_get_features` avec le `typename` obtenu et les coordonnées du centroïde.",
`(source : ${POINTSDINTERET_SOURCE}).`
].join("\n");
protected outputSchemaShape = pointsdinteretOutputSchema;

schema = pointsdinteretInputSchema;

/**
* Returns the points of interest relevant to the requested point.
*
* @param input Normalized tool input.
* @returns The relevant points of interest.
*/
async execute(input: PointsDInteretInput) {
logger.info(`[tool] execute ${this.name} ...`, {
input: input
});

return {
results: await pointsdinteretClient.pointsdinteret(input.lon, input.lat, input.maximumResponses),
};
}
}

export default PointsDInteretTool;
Loading
Loading