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
109 changes: 57 additions & 52 deletions docs/mcp-tools.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,17 +38,6 @@ Exemple complet généré automatiquement à partir d'un appel de tool invalide
}
```

## Annotations MCP

Tous les tools exposent les mêmes annotations MCP dans leur définition `tools/list` :

| Annotation | Valeur | Signification |
| --- | --- | --- |
| `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 | Le tool interroge des sources externes ou ouvertes, dont le contenu peut évoluer. |

## Liste des tools

- [`geocode`](#geocode)
Expand Down Expand Up @@ -967,9 +956,9 @@ Description d’un type GPF
### Description du tool

```
Renvoie le schéma détaillé d'un type GPF à partir de son identifiant (`typename`).
Ce schéma contient notamment la description du type et un champ `properties` qui détaille, pour chaque propriété, son type, sa description et la liste des ses valeurs possibles (`oneOf`) lorsqu'elle est fixée.
Utiliser ce tool après `gpf_search_types` pour inspecter les propriétés disponibles avant d'appeler `gpf_get_features`.
Renvoie un résumé du schéma d'un type GPF à partir de son identifiant (`typename`).
Ce schéma contient notamment la description du type et un champ `properties` qui recense la liste des propriétés avec un début de description et la liste des ses valeurs possibles (`oneOf`) lorsqu'elle est fixée.
Utiliser ce tool après `gpf_search_types` pour inspecter les propriétés disponibles avant d'appeler `gpf_get_features`. Si le résumé ne suffit pas, télécharger le schéma complet via l'`url` renvoyée.
**IMPORTANT : Appel fortement recommandé si les noms exacts des propriétés ne sont pas connus : un nom de propriété incorrect provoque une erreur**.
```

Expand Down Expand Up @@ -1004,15 +993,11 @@ Utiliser ce tool après `gpf_search_types` pour inspecter les propriétés dispo

| Champ | Type | Requis | Description |
| --- | --- | --- | --- |
| `$id` | string | oui | |
| `$schema` | string | oui | |
| `description` | string | oui | |
| `required` | array | oui | |
| `title` | string | oui | |
| `type` | string | oui | |
| `x-ign-representedFeatures` | array | non | |
| `x-ign-selectionCriteria` | string | non | |
| `x-ign-theme` | string | non | |
| `description` | string | non | La description du contenu du type. |
| `geometry_kind` | string (enum) | non | Le type de la géométrie, si elle existe. Cela peut être un type GeoJSON en minuscules, une union comme "point-or-multipoint" ou encore "any". Ce champ est indéfini lorsque le schéma n'a pas de propriété géométrique.<br> Note : si tu as besoin d'une propriété géométrique dans une requête, utilise préférentiellement un `spatial_extra` adapté ; rabats-toi sur un tool `_layer` pour faire des calculs géomatiques avancés seulement si nécessaire. Valeurs : point, multipoint, point-or-multipoint, linestring, multilinestring, linestring-or-multilinestring, polygon, multipolygon, polygon-or-multipolygon, geometrycollection, any. |
| `properties` | array | oui | La liste des propriétés non géométriques du schéma. |
| `typename` | string | oui | L'identifiant du type (de la forme `prefixe:nom`). |
| `url` | string | oui | Le lien vers le schéma complet du type. Pour des recherches simples, gpf_describe_type suffit, ne télécharge le schéma complet que lorsque les résultats ne sont pas assez complets pour ta recherche. |

<details>
<summary>Schéma de sortie brut</summary>
Expand All @@ -1021,48 +1006,68 @@ Utiliser ce tool après `gpf_search_types` pour inspecter les propriétés dispo
{
"type": "object",
"properties": {
"$schema": {
"type": "string"
"typename": {
"type": "string",
"description": "L'identifiant du type (de la forme `prefixe:nom`)."
},
"$id": {
"url": {
"type": "string",
"description": "Le lien vers le schéma complet du type. Pour des recherches simples, gpf_describe_type suffit, ne télécharge le schéma complet que lorsque les résultats ne sont pas assez complets pour ta recherche.",
"format": "uri"
},
"type": {
"type": "string"
},
"title": {
"type": "string"
},
"x-ign-theme": {
"type": "string"
},
"description": {
"type": "string"
},
"x-ign-selectionCriteria": {
"type": "string"
"type": "string",
"description": "La description du contenu du type."
},
"x-ign-representedFeatures": {
"type": "array",
"items": {
"type": "string"
}
"geometry_kind": {
"type": "string",
"description": "Le type de la géométrie, si elle existe. Cela peut être un type GeoJSON en minuscules, une union comme \"point-or-multipoint\" ou encore \"any\". Ce champ est indéfini lorsque le schéma n'a pas de propriété géométrique.\n Note : si tu as besoin d'une propriété géométrique dans une requête, utilise préférentiellement un `spatial_extra` adapté ; rabats-toi sur un tool `_layer` pour faire des calculs géomatiques avancés seulement si nécessaire.",
"enum": [
"point",
"multipoint",
"point-or-multipoint",
"linestring",
"multilinestring",
"linestring-or-multilinestring",
"polygon",
"multipolygon",
"polygon-or-multipolygon",
"geometrycollection",
"any"
]
},
"required": {
"properties": {
"type": "array",
"description": "La liste des propriétés non géométriques du schéma.",
"items": {
"type": "string"
"type": "object",
"properties": {
"name": {
"type": "string",
"description": "Le nom de la propriété."
},
"description": {
"type": "string",
"description": "La description de la propriété."
},
"oneOf": {
"type": "array",
"description": "La liste des valeurs possibles, si elle existe.",
"items": {
"type": "string"
}
}
},
"required": [
"name"
]
}
}
},
"required": [
"$schema",
"$id",
"type",
"title",
"description",
"required"
"typename",
"url",
"properties"
]
}
```
Expand Down
7 changes: 7 additions & 0 deletions src/helpers/toolAnnotations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,10 @@ export const READ_ONLY_OPEN_WORLD_TOOL_ANNOTATIONS = {
idempotentHint: true,
openWorldHint: true,
};

export const READ_ONLY_CLOSED_WORLD_TOOL_ANNOTATIONS = {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
openWorldHint: false,
};
103 changes: 88 additions & 15 deletions src/tools/GpfDescribeTypeTool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,12 @@

import BaseTool from "./BaseTool.js";
import { z } from "zod";
import { zOgcCollectionSchema } from "@ignfab/gpf-schema-store";

import { wfsSchemaStore } from "../wfs/catalog.js";
import { READ_ONLY_OPEN_WORLD_TOOL_ANNOTATIONS } from "../helpers/toolAnnotations.js";
import type { OgcCollectionPropertyEnumValue } from "@ignfab/gpf-schema-store";
import { GpfFeatureType, wfsSchemaStore } from "../wfs/catalog.js";
import { READ_ONLY_CLOSED_WORLD_TOOL_ANNOTATIONS } from "../helpers/toolAnnotations.js";
import logger from "../logger.js";
import { getGeometryProperties } from "../wfs/properties.js";

// --- Schema ---

Expand All @@ -20,38 +21,110 @@ const gpfDescribeTypeInputSchema = z.object({
.describe("Le nom du type à décrire (de la forme `prefixe:nom`)."),
}).strict();

// FIXME: when mcp-framework is removed, remove this patch which is only here
// because mcp-framework does not accept z.record field types.
const gpfDescribeTypeOutput = zOgcCollectionSchema
.omit({ properties: true })
.catchall(z.unknown());
const gpfPropertySchema = z.object({
name: z.string().describe("Le nom de la propriété."),
description: z.string().optional().describe("La description de la propriété."),
oneOf: z.array(z.string()).optional().describe("La liste des valeurs possibles, si elle existe.")
});

const ogcGeometryKind = [
"point",
"multipoint",
"point-or-multipoint",
"linestring",
"multilinestring",
"linestring-or-multilinestring",
"polygon",
"multipolygon",
"polygon-or-multipolygon",
"geometrycollection",
"any"
] as const;

const gpfDescribeTypeOutput = z.object({
typename: z.string().describe("L'identifiant du type (de la forme `prefixe:nom`)."),
url: z.string().url().describe("Le lien vers le schéma complet du type. Pour des recherches simples, gpf_describe_type suffit, ne télécharge le schéma complet que lorsque les résultats ne sont pas assez complets pour ta recherche."),
description: z.string().optional().describe("La description du contenu du type."),
geometry_kind: z.enum(ogcGeometryKind).optional().describe("Le type de la géométrie, si elle existe. Cela peut être un type GeoJSON en minuscules, une union comme \"point-or-multipoint\" ou encore \"any\". Ce champ est indéfini lorsque le schéma n'a pas de propriété géométrique.\n Note : si tu as besoin d'une propriété géométrique dans une requête, utilise préférentiellement un `spatial_extra` adapté ; rabats-toi sur un tool `_layer` pour faire des calculs géomatiques avancés seulement si nécessaire."),
properties: z.array(gpfPropertySchema).describe("La liste des propriétés non-géométriques du schéma."),
required: z.array(z.string()).describe("La liste des propriétés non-géométriques toujours présentes. Toute propriété qui n'est pas dans cette liste est donc facultative."),
selection_criteria: z.string().optional().describe("Les critères de sélection des objets enregistrés dans ce type."),
});

// --- Types ---

type GpfDescribeTypeInput = z.infer<typeof gpfDescribeTypeInputSchema>;
type GpfDescribeTypeOutput = z.infer<typeof gpfDescribeTypeOutput>;

// --- Utility ---

function summarizeSchema(featureType: GpfFeatureType) : GpfDescribeTypeOutput {
const schema = featureType.schema;
const geometricPropertyNames = getGeometryProperties(featureType);
const mainGeometries = geometricPropertyNames.length < 2 ? geometricPropertyNames :
geometricPropertyNames.filter((s : string) => schema.properties[s]["x-ogc-role"] == "primary-geometry");
const geometry_kind = mainGeometries.length == 0 ? undefined : schema.properties[mainGeometries[0]].format;
const shortProperties = Object.keys(schema.properties)
.filter((name: string) => !geometricPropertyNames.includes(name))
.map((name: string) => {
const property = schema.properties[name];
return {
name,
description: property.description,
oneOf: property.oneOf?.map((v: OgcCollectionPropertyEnumValue) => v.const),
};
});
const required = schema.required.filter(
(name: string) => !geometricPropertyNames.includes(name),
);

return {
typename: featureType.typename,
url: schema["$id"],
description: schema.description,
// slice(9) below to remove the mandatory starting "geometry-" prefix
geometry_kind: geometry_kind?.slice(9) as GpfDescribeTypeOutput["geometry_kind"],
properties: shortProperties,
required,
selection_criteria: schema["x-ign-selectionCriteria"],
};
}

// --- Tool ---

class GpfDescribeTypeTool extends BaseTool<GpfDescribeTypeInput> {
name = "gpf_describe_type";
title = "Description d’un type GPF";
annotations = READ_ONLY_OPEN_WORLD_TOOL_ANNOTATIONS;
annotations = READ_ONLY_CLOSED_WORLD_TOOL_ANNOTATIONS;
description = [
"Renvoie le schéma détaillé d'un type GPF à partir de son identifiant (`typename`).",
"Ce schéma contient notamment la description du type et un champ `properties` qui détaille, pour chaque propriété, son type, sa description et la liste des ses valeurs possibles (`oneOf`) lorsqu'elle est fixée.",
"Utiliser ce tool après `gpf_search_types` pour inspecter les propriétés disponibles avant d'appeler `gpf_get_features`.",
"Renvoie un résumé du schéma d'un type GPF à partir de son identifiant (`typename`).",
"Ce schéma contient notamment la description du type et un champ `properties` qui recense la liste des propriétés avec un début de description et la liste des ses valeurs possibles (`oneOf`) lorsqu'elle est fixée.",
"Utiliser ce tool après `gpf_search_types` pour inspecter les propriétés disponibles avant d'appeler `gpf_get_features`. Si le résumé ne suffit pas, télécharger le schéma complet via l'`url` renvoyée.",
"**IMPORTANT : Appel fortement recommandé si les noms exacts des propriétés ne sont pas connus : un nom de propriété incorrect provoque une erreur**."
].join("\n");
protected outputSchemaShape = gpfDescribeTypeOutput;

schema = gpfDescribeTypeInputSchema;

/**
* Loads the detailed schema description for one GPF typename.
* Formats the summary payload into both text content and structuredContent.
*
* @param data Raw execution result.
* @returns An MCP success response with validated output shape.
*/
protected createSuccessResponse(data: unknown) {
const payload = gpfDescribeTypeOutput.parse(data);
return {
content: [{ type: "text" as const, text: JSON.stringify(payload) }],
structuredContent: payload,
};
}

/**
* Loads and summarizes the schema description for one GPF typename.
*
* @param input Normalized tool input.
* @returns The detailed feature type description from the embedded catalog.
* @returns The summarized feature type description from the embedded catalog.
*/
async execute(input: GpfDescribeTypeInput) {
logger.info(`[tool] execute ${this.name} ...`, {
Expand All @@ -60,7 +133,7 @@ class GpfDescribeTypeTool extends BaseTool<GpfDescribeTypeInput> {

try {
const featureType = await wfsSchemaStore.getFeatureType(input.typename);
return featureType.schema;
return summarizeSchema(featureType);
} catch (e: unknown) {
const message = e instanceof Error ? e.message : String(e);
throw new Error(`${message}. Utiliser gpf_search_types pour trouver un type valide.`);
Expand Down
4 changes: 2 additions & 2 deletions src/tools/GpfGetFeatureByIdLayerTool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@

import BaseTool from "./BaseTool.js";

import { READ_ONLY_OPEN_WORLD_TOOL_ANNOTATIONS } from "../helpers/toolAnnotations.js";
import { READ_ONLY_CLOSED_WORLD_TOOL_ANNOTATIONS } from "../helpers/toolAnnotations.js";
import { getEnv } from "../config/env.js";
import { encodeToken } from "../proxy/token.js";
import { buildDataUrl } from "../proxy/dataUrl.js";
Expand All @@ -47,7 +47,7 @@ import logger from "../logger.js";
class GpfGetFeatureByIdLayerTool extends BaseTool<GpfGetFeatureByIdLayerInput> {
name = "gpf_get_feature_by_id_layer";
title = "Couche cartographiable d’un objet GPF par identifiant";
annotations = READ_ONLY_OPEN_WORLD_TOOL_ANNOTATIONS;
annotations = READ_ONLY_CLOSED_WORLD_TOOL_ANNOTATIONS;
description = [
"Renvoie une **URL de couche cartographiable** (`data_url`) pour exactement un objet GPF, identifié par `typename` et `feature_id` : une URL opaque, à passer telle quelle à un outil d'affichage cartographique (MCP Carto, ...). L'ouvrir renvoie une FeatureCollection GeoJSON contenant le seul objet demandé, avec sa géométrie complète.",
"C'est le pendant cartographique de `gpf_get_feature_by_id` : utiliser ce tool dès qu'il faut **afficher / cartographier** un objet précis dont on connaît déjà la `feature_ref { typename, feature_id }` (issue d'un autre tool : `adminexpress`, `cadastre`, `urbanisme`, `assiette_sup`, `gpf_get_features`). Pour récupérer ses attributs sans géométrie, utiliser `gpf_get_feature_by_id`.",
Expand Down
4 changes: 2 additions & 2 deletions src/tools/GpfSearchTypesTool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
import BaseTool from "./BaseTool.js";
import { z } from "zod";

import { READ_ONLY_OPEN_WORLD_TOOL_ANNOTATIONS } from "../helpers/toolAnnotations.js";
import { READ_ONLY_CLOSED_WORLD_TOOL_ANNOTATIONS } from "../helpers/toolAnnotations.js";
import { wfsSchemaStore } from "../wfs/catalog.js";
import type { DetailedCollectionSearchMatch } from "../wfs/catalog.js";
import logger from "../logger.js";
Expand Down Expand Up @@ -65,7 +65,7 @@ const gpfSearchTypesOutputSchema = z.object({
class GpfSearchTypesTool extends BaseTool<GpfSearchTypesInput> {
name = "gpf_search_types";
title = "Recherche de types GPF";
annotations = READ_ONLY_OPEN_WORLD_TOOL_ANNOTATIONS;
annotations = READ_ONLY_CLOSED_WORLD_TOOL_ANNOTATIONS;
description = [
"Recherche des types de la Géoplateforme (GPF) à partir de mots-clés afin de trouver un identifiant de type (`typename`) valide.",
"La recherche est textuelle (mini-search) et retourne une liste ordonnée de candidats avec leur identifiant, leur titre, leur description et un score de pertinence éventuel.",
Expand Down
2 changes: 1 addition & 1 deletion src/wfs/properties.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import type { GpfFeatureType } from "./catalog.js";
* @param featureType Feature type definition loaded from the embedded catalog.
* @returns The list of spatial properties.
*/
function getGeometryProperties(featureType: GpfFeatureType) {
export function getGeometryProperties(featureType: GpfFeatureType) {
return Object.entries(featureType.schema.properties).filter(([_key, property]) => {
// only geometric properties do not have a `type` field
// (see OGC API Features, /req/schemas/properties A and B)
Expand Down
27 changes: 14 additions & 13 deletions test/integration/level1-protocol/describe.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,18 +9,16 @@ import { expectToolCallToThrow } from "../helpers/level1-assertions.js";
import { INTEGRATION_CONFIG } from "../config/shared.js";

interface DescribeResult {
title: string;
typename: string;
url: string;
description: string;
geometry_kind?: string;
required: string[];
properties: Record<string, {
type?: "string" | "boolean" | "integer" | "number";
title?: string;
selection_criteria?: string;
properties: Array<{
name: string;
description?: string;
oneOf?: Array<{
const: string;
title: string;
description?: string;
}>;
oneOf?: string[];
}>;
}

Expand All @@ -32,11 +30,14 @@ describe("GPF Describe Type (integration)", () => {
typename: "BDTOPO_V3:batiment",
});

expect(result.title).toBe("Bâtiment");
expect(result.typename).toBe("BDTOPO_V3:batiment");
expect(result.url).toContain("BDTOPO_V3");
expect(Array.isArray(result.required)).toBe(true);
expect(result.selection_criteria).toBeDefined();
expect(result.selection_criteria).toMatch(/50 m²/)
expect(result.properties).toBeDefined();
const propNames = Object.keys(result.properties);
expect(propNames.length).toBeGreaterThan(0);
expect(result.required).toBeDefined();
expect(result.properties.length).toBeGreaterThan(0);
expect(result.properties[0].name).toBeDefined();
}, INTEGRATION_CONFIG.timeout);

it("should return an error for empty typename", async () => {
Expand Down
Loading