diff --git a/docs/mcp-tools.md b/docs/mcp-tools.md
index 18b96fb5..7d751289 100644
--- a/docs/mcp-tools.md
+++ b/docs/mcp-tools.md
@@ -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)
@@ -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**.
```
@@ -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.
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. |
Schéma de sortie brut
@@ -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"
]
}
```
diff --git a/src/helpers/toolAnnotations.ts b/src/helpers/toolAnnotations.ts
index 9a7a651a..ef801c6b 100644
--- a/src/helpers/toolAnnotations.ts
+++ b/src/helpers/toolAnnotations.ts
@@ -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,
+};
diff --git a/src/tools/GpfDescribeTypeTool.ts b/src/tools/GpfDescribeTypeTool.ts
index ae5e5c86..d956e90b 100644
--- a/src/tools/GpfDescribeTypeTool.ts
+++ b/src/tools/GpfDescribeTypeTool.ts
@@ -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 ---
@@ -20,27 +21,85 @@ 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;
+type GpfDescribeTypeOutput = z.infer;
+
+// --- 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 {
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;
@@ -48,10 +107,24 @@ class GpfDescribeTypeTool extends BaseTool {
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} ...`, {
@@ -60,7 +133,7 @@ class GpfDescribeTypeTool extends BaseTool {
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.`);
diff --git a/src/tools/GpfGetFeatureByIdLayerTool.ts b/src/tools/GpfGetFeatureByIdLayerTool.ts
index 8c7b5392..fde43606 100644
--- a/src/tools/GpfGetFeatureByIdLayerTool.ts
+++ b/src/tools/GpfGetFeatureByIdLayerTool.ts
@@ -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";
@@ -47,7 +47,7 @@ import logger from "../logger.js";
class GpfGetFeatureByIdLayerTool extends BaseTool {
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`.",
diff --git a/src/tools/GpfSearchTypesTool.ts b/src/tools/GpfSearchTypesTool.ts
index 8a71a2f3..63f0e8f9 100644
--- a/src/tools/GpfSearchTypesTool.ts
+++ b/src/tools/GpfSearchTypesTool.ts
@@ -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";
@@ -65,7 +65,7 @@ const gpfSearchTypesOutputSchema = z.object({
class GpfSearchTypesTool extends BaseTool {
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.",
diff --git a/src/wfs/properties.ts b/src/wfs/properties.ts
index 9d662b86..87bd7b39 100644
--- a/src/wfs/properties.ts
+++ b/src/wfs/properties.ts
@@ -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)
diff --git a/test/integration/level1-protocol/describe.test.ts b/test/integration/level1-protocol/describe.test.ts
index 0c52d6f1..5aa73023 100644
--- a/test/integration/level1-protocol/describe.test.ts
+++ b/test/integration/level1-protocol/describe.test.ts
@@ -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;
+ oneOf?: string[];
}>;
}
@@ -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 () => {
diff --git a/test/tools/wfs/describeType.test.ts b/test/tools/wfs/describeType.test.ts
index 7ed0db94..7d0e160e 100644
--- a/test/tools/wfs/describeType.test.ts
+++ b/test/tools/wfs/describeType.test.ts
@@ -1,155 +1,261 @@
-import { describe, it, expect } from "vitest";
+import { vi, describe, it, expect, afterEach } from "vitest";
import type { OgcCollectionSchema } from "@ignfab/gpf-schema-store";
-
-import GpfDescribeTypeTool from "../../../src/tools/GpfDescribeTypeTool";
+import type { GpfFeatureType } from "../../../src/wfs/catalog.js";
import { validateStructuredContentAgainstOutputSchema } from "../helpers/outputSchema";
-describe("Test GpfDescribeTypeTool",() => {
- const mockCollection: OgcCollectionSchema = {
- $schema: 'https://json-schema.org/draft/2020-12/schema',
- $id: 'https://example.test/BDTOPO_V3/batiment.json',
- type: "object",
- title: "Batiment",
- description: "Description de test",
- properties: {
- hauteur: {
- type: "number"
- }
+const mockGetFeatureType = vi.fn<(typename: string) => Promise>();
+
+vi.doMock("../../../src/wfs/catalog.js", () => ({
+ wfsSchemaStore: {
+ getFeatureType: mockGetFeatureType,
+ },
+}));
+
+const { default: GpfDescribeTypeTool } = await import("../../../src/tools/GpfDescribeTypeTool");
+
+describe("Test GpfDescribeTypeTool", () => {
+ const COMMUNE_TYPENAME = "ADMINEXPRESS-COG.LATEST:commune";
+
+ const communeType: OgcCollectionSchema = {
+ $schema: "https://json-schema.org/draft/2020-12/schema",
+ $id: "https://example.test/ADMINEXPRESS-COG.LATEST/commune.json",
+ type: "object",
+ title: "Commune",
+ description: "Description de test",
+ properties: {
+ code_insee: {
+ type: "string",
+ description: "Code INSEE officiel de la commune",
+ },
+ statut: {
+ type: "string",
+ description: "Type de statut administratif de la commune",
+ oneOf: [
+ {
+ const: "A",
+ title: "Active",
+ description: "Commune active",
+ },
+ {
+ const: "D",
+ title: "Déléguée",
+ description: "Commune déléguée",
+ },
+ ],
+ },
+ geometrie: {
+ format: "geometry-multipolygon",
+ "x-ogc-role": "primary-geometry",
+ },
+ },
+ required: ["code_insee"],
+ "x-ign-selectionCriteria": "Code INSEE officiel non vide",
+ };
+
+ afterEach(() => {
+ vi.clearAllMocks();
+ mockGetFeatureType.mockReset();
+ });
+
+ it("should expose an enriched MCP definition", () => {
+ const tool = new GpfDescribeTypeTool();
+ expect(tool.toolDefinition.title).toEqual("Description d’un type GPF");
+ expect(tool.toolDefinition.inputSchema.properties?.typename).toMatchObject({
+ type: "string",
+ minLength: 1,
+ });
+ expect(tool.toolDefinition.outputSchema).toBeDefined();
+ });
+
+ it("should return both text content and structuredContent with summarized schema", async () => {
+ const tool = new GpfDescribeTypeTool();
+ mockGetFeatureType.mockResolvedValue({ typename: COMMUNE_TYPENAME, schema: communeType });
+
+ const response = await tool.toolCall({
+ params: {
+ name: "gpf_describe_type",
+ arguments: {
+ typename: COMMUNE_TYPENAME,
},
- required: []
- };
+ },
+ });
- class TestableGpfDescribeTypeTool extends GpfDescribeTypeTool {
- async execute(_: { typename: string }) {
- return mockCollection;
- }
+ expect(response.isError).toBeUndefined();
+ expect(response.content[0]).toMatchObject({ type: "text" });
+ const textContent = response.content[0];
+ if (textContent.type !== "text") {
+ throw new Error("expected text content");
}
- class TestableGpfDescribeTypeToolError extends GpfDescribeTypeTool {
- async execute(): Promise {
- throw new Error("Le type 'BDTOPO_V3:not_found' est introuvable. Utiliser gpf_search_types pour trouver un type valide.");
- }
- }
+ const parsed = JSON.parse(textContent.text);
+ expect(parsed).toEqual(response.structuredContent);
+ expect(parsed).toMatchObject({
+ typename: COMMUNE_TYPENAME,
+ url: "https://example.test/ADMINEXPRESS-COG.LATEST/commune.json",
+ geometry_kind: "multipolygon",
+ required: ["code_insee"],
+ selection_criteria: "Code INSEE officiel non vide",
+ });
+ expect(parsed.properties).toHaveLength(2);
+ expect(parsed.properties.find((p: { name: string }) => p.name === "geometrie")).toBeUndefined();
+ expect(parsed.properties.find((p: { name: string }) => p.name === "statut")).toMatchObject({
+ oneOf: ["A", "D"],
+ });
+ });
- it("should expose an enriched MCP definition", () => {
- const tool = new GpfDescribeTypeTool();
- expect(tool.toolDefinition.title).toEqual("Description d’un type GPF");
- expect(tool.toolDefinition.inputSchema.properties?.typename).toMatchObject({
- type: "string",
- minLength: 1,
- });
- expect(tool.toolDefinition.outputSchema).toBeDefined();
+ it("should include a description for non-geometry properties", async () => {
+ const tool = new GpfDescribeTypeTool();
+ mockGetFeatureType.mockResolvedValue({ typename: COMMUNE_TYPENAME, schema: communeType });
+
+ const response = await tool.toolCall({
+ params: {
+ name: "gpf_describe_type",
+ arguments: {
+ typename: COMMUNE_TYPENAME,
+ },
+ },
});
- it("should return both text content and structuredContent", async () => {
- const tool = new TestableGpfDescribeTypeTool();
- const response = await tool.toolCall({
- params: {
- name: "gpf_describe_type",
- arguments: {
- typename: "BDTOPO_V3:batiment",
- },
- },
- });
-
- expect(response.isError).toBeUndefined();
- expect(response.content[0]).toMatchObject({
- type: "text",
- });
- const textContent = response.content[0];
- if (textContent.type !== "text") {
- throw new Error("expected text content");
- }
- expect(JSON.parse(textContent.text)).toMatchObject({
- title: "Batiment",
- description: "Description de test",
- });
- expect(response.structuredContent).toBeDefined();
- expect(response.structuredContent).toMatchObject({
- title: "Batiment",
- description: "Description de test",
- });
+ expect(response.isError).toBeUndefined();
+ const payload = response.structuredContent as {
+ properties: Array<{ name: string; description?: string }>;
+ };
+ const description = payload.properties.find((p) => p.name === "code_insee")?.description;
+ expect(description).toEqual("Code INSEE officiel de la commune");
+ });
+
+ it("should omit selection_criteria when not provided by the schema", async () => {
+ const tool = new GpfDescribeTypeTool();
+ const { ["x-ign-selectionCriteria"]: _ignored, ...schemaWithoutCriteria } = communeType;
+ mockGetFeatureType.mockResolvedValue({ typename: COMMUNE_TYPENAME, schema: schemaWithoutCriteria });
+
+ const response = await tool.toolCall({
+ params: {
+ name: "gpf_describe_type",
+ arguments: {
+ typename: COMMUNE_TYPENAME,
+ },
+ },
});
- it("should return a payload that validates against its outputSchema", async () => {
- const tool = new TestableGpfDescribeTypeTool();
- const response = await tool.toolCall({
- params: {
- name: "gpf_describe_type",
- arguments: {
- typename: "BDTOPO_V3:batiment",
- },
- },
- });
-
- expect(response.isError).toBeUndefined();
- expect(response.structuredContent).toBeDefined();
- expect(tool.toolDefinition.outputSchema).toBeDefined();
-
- expect(
- validateStructuredContentAgainstOutputSchema(
- tool.toolDefinition.outputSchema,
- response.structuredContent,
- ),
- ).toBeNull();
+ expect(response.isError).toBeUndefined();
+ const payload = response.structuredContent as {
+ selection_criteria?: string;
+ };
+ expect(payload.selection_criteria).toBeUndefined();
+ });
+
+ it("should return a payload that validates against its outputSchema", async () => {
+ const tool = new GpfDescribeTypeTool();
+ mockGetFeatureType.mockResolvedValue({ typename: COMMUNE_TYPENAME, schema: communeType });
+
+ const response = await tool.toolCall({
+ params: {
+ name: "gpf_describe_type",
+ arguments: {
+ typename: COMMUNE_TYPENAME,
+ },
+ },
+ });
+
+ expect(response.isError).toBeUndefined();
+ expect(
+ validateStructuredContentAgainstOutputSchema(
+ tool.toolDefinition.outputSchema,
+ response.structuredContent,
+ ),
+ ).toBeNull();
+ });
+
+ it("should return isError=true for invalid input", async () => {
+ const tool = new GpfDescribeTypeTool();
+ const response = await tool.toolCall({
+ params: {
+ name: "gpf_describe_type",
+ arguments: {
+ typename: "",
+ },
+ },
+ });
+
+ expect(response.isError).toBe(true);
+ expect(response.structuredContent).toMatchObject({
+ type: "urn:geocontext:problem:invalid-tool-params",
+ errors: expect.arrayContaining([
+ expect.objectContaining({
+ name: "typename",
+ code: "too_small",
+ detail: "le nom du type ne doit pas être vide",
+ }),
+ ]),
+ });
+ });
+
+ it("should return isError=true when catalog lookup fails", async () => {
+ const tool = new GpfDescribeTypeTool();
+ mockGetFeatureType.mockRejectedValue(new Error("Le type 'BDTOPO_V3:not_found' est introuvable"));
+
+ const response = await tool.toolCall({
+ params: {
+ name: "gpf_describe_type",
+ arguments: {
+ typename: "BDTOPO_V3:not_found",
+ },
+ },
});
- it("should return isError=true for invalid input", async () => {
- const tool = new GpfDescribeTypeTool();
- const response = await tool.toolCall({
- params: {
- name: "gpf_describe_type",
- arguments: {
- typename: "",
- },
- },
- });
-
- expect(response.isError).toBe(true);
- expect(response.content[0]).toMatchObject({
- type: "text",
- });
- const textContent = response.content[0];
- if (textContent.type !== "text") {
- throw new Error("expected text content");
- }
- expect(textContent.text).toContain("Paramètres invalides");
- expect(response.structuredContent).toMatchObject({
- type: "urn:geocontext:problem:invalid-tool-params",
- errors: expect.arrayContaining([
- expect.objectContaining({
- name: "typename",
- code: "too_small",
- detail: "le nom du type ne doit pas être vide",
- }),
- ]),
- });
+ expect(response.isError).toBe(true);
+ const textContent = response.content[0];
+ if (textContent.type !== "text") {
+ throw new Error("expected text content");
+ }
+ expect(textContent.text).toContain("Le type 'BDTOPO_V3:not_found' est introuvable");
+ expect(textContent.text).toContain("gpf_search_types");
+ expect(response.structuredContent).toMatchObject({
+ type: "urn:geocontext:problem:execution-error",
});
+ });
- it("should return isError=true when execute fails", async () => {
- const tool = new TestableGpfDescribeTypeToolError();
- const response = await tool.toolCall({
- params: {
- name: "gpf_describe_type",
- arguments: {
- typename: "BDTOPO_V3:not_found",
- },
- },
- });
-
- expect(response.isError).toBe(true);
- expect(response.content[0]).toMatchObject({
- type: "text",
- });
- const textContent = response.content[0];
- if (textContent.type !== "text") {
- throw new Error("expected text content");
- }
- expect(textContent.text).toContain("Le type 'BDTOPO_V3:not_found' est introuvable");
- expect(textContent.text).toContain("gpf_search_types");
- expect(response.structuredContent).toMatchObject({
- type: "urn:geocontext:problem:execution-error",
- });
+ it("should select the primary geometry when several geometries exist", async () => {
+ const multiGeometryType: OgcCollectionSchema = {
+ ...communeType,
+ properties: {
+ code_insee: {
+ type: "string",
+ description: "Code INSEE officiel de la commune",
+ },
+ geometrie: {
+ format: "geometry-multipolygon",
+ "x-ogc-role": "primary-geometry",
+ },
+ emprise: {
+ format: "geometry-point",
+ },
+ },
+ required: ["code_insee", "geometrie"],
+ };
+
+ const tool = new GpfDescribeTypeTool();
+ mockGetFeatureType.mockResolvedValue({ typename: COMMUNE_TYPENAME, schema: multiGeometryType });
+
+ const response = await tool.toolCall({
+ params: {
+ name: "gpf_describe_type",
+ arguments: {
+ typename: COMMUNE_TYPENAME,
+ },
+ },
});
+
+ expect(response.isError).toBeUndefined();
+ const payload = response.structuredContent as {
+ geometry_kind?: string;
+ properties: Array<{ name: string }>;
+ required: string[];
+ };
+ expect(payload.geometry_kind).toEqual("multipolygon");
+ expect(payload.properties.map((p) => p.name)).toEqual(["code_insee"]);
+ expect(payload.required).toEqual(["code_insee"]);
+ });
});